diff --git a/charts/spire/charts/spire-server/README.md b/charts/spire/charts/spire-server/README.md index 7c17a2b..5a867cc 100644 --- a/charts/spire/charts/spire-server/README.md +++ b/charts/spire/charts/spire-server/README.md @@ -142,7 +142,8 @@ In order to run Tornjak with simple HTTP Connection only, make sure you don't cr | `dataStore.sql.port` | If 0 (default), it will auto set to 5432 for postgres and 3306 for mysql. Only used by those databases. | `0` | | `dataStore.sql.username` | Only used when type != "sqlite3" | `spire` | | `dataStore.sql.password` | Only used when type != "sqlite3" | `""` | -| `dataStore.sql.file` | Data source file. Only used when type == "sqlite3" | `/run/spire/data/datastore.sqlite3` | +| `dataStore.sql.file` | Data source file. Only used when type == "sqlite3" and inMemory is false | `/run/spire/data/datastore.sqlite3` | +| `dataStore.sql.inMemory` | Hold the sqlite3 datastore in memory instead of in a file, in which case `file` is unused. The datastore starts empty on every restart, so this only suits a single replica whose registration entries are recreated at startup, for example by the controller manager writing static entries. Required to run as a deployment on sqlite3, since a deployment has no durable per-pod storage. | `false` | | `dataStore.sql.options` | takes an array of objects of form {: } to use when building the database connection string | `[]` | | `dataStore.sql.rootCAPath` | Path to Root CA bundle (MySQL only) | `""` | | `dataStore.sql.clientCertPath` | Path to client certificate (MySQL only) | `""` | diff --git a/charts/spire/charts/spire-server/templates/_helpers.tpl b/charts/spire/charts/spire-server/templates/_helpers.tpl index e2c0019..80c53f4 100644 --- a/charts/spire/charts/spire-server/templates/_helpers.tpl +++ b/charts/spire/charts/spire-server/templates/_helpers.tpl @@ -307,8 +307,15 @@ current-context: cluster {{- $ropw := "" }} {{- if eq .Values.dataStore.sql.databaseType "sqlite3" }} {{- $_ := set $config "database_type" "sqlite3" }} + {{- if .Values.dataStore.sql.inMemory }} + {{- /* cache=shared is not optional: without it every pooled connection opens its own + empty database, so the server silently loses every write it did not make itself. */}} + {{- $query := include "spire-server.config-sqlite-query" (concat (list (dict "mode" "memory") (dict "cache" "shared")) .Values.dataStore.sql.options) }} + {{- $_ := set $config "connection_string" (printf "memdb%s" $query) }} + {{- else }} {{- $query := include "spire-server.config-sqlite-query" .Values.dataStore.sql.options }} {{- $_ := set $config "connection_string" (printf "%s%s" .Values.dataStore.sql.file $query) }} + {{- end }} {{- else if or (eq .Values.dataStore.sql.databaseType "mysql") (eq .Values.dataStore.sql.databaseType "aws_mysql") (eq .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") }} {{- if eq .Values.dataStore.sql.databaseType "mysql" }} {{- $_ := set $config "database_type" "mysql" }} diff --git a/charts/spire/charts/spire-server/templates/server-resource.yaml b/charts/spire/charts/spire-server/templates/server-resource.yaml index 64307bd..ea6a428 100644 --- a/charts/spire/charts/spire-server/templates/server-resource.yaml +++ b/charts/spire/charts/spire-server/templates/server-resource.yaml @@ -41,8 +41,14 @@ {{- if (has .Values.persistence.type (list "pvc" "hostPath")) }} {{- fail "When running as deployment, persistence can't be set. 'persistence.type' must be [\"emptyDir\"]" }} {{- end }} -{{- if (eq .Values.dataStore.sql.databaseType "sqlite3") }} -{{- fail "When running as deployment, sqlite3 can't be used." }} +{{- if and (eq .Values.dataStore.sql.databaseType "sqlite3") (not .Values.dataStore.sql.inMemory) }} +{{- fail "When running as deployment, sqlite3 can only be used in memory. Set 'dataStore.sql.inMemory' to true." }} +{{- end }} +{{- if and (eq .Values.dataStore.sql.databaseType "sqlite3") .Values.dataStore.sql.inMemory }} +{{- $surge := dig "rollingUpdate" "maxSurge" "" .Values.updateStrategy | toString }} +{{- if not (or (eq (dig "type" "" .Values.updateStrategy) "Recreate") (eq $surge "0") (eq $surge "0%")) }} +{{- fail "An in-memory datastore on a deployment must not surge, or two servers run with separate datastores. Set 'updateStrategy' to {type: Recreate} or {rollingUpdate: {maxSurge: 0}}." }} +{{- end }} {{- end }} {{- if (eq (.Values.keyManager.disk.enabled | toString) "true") }} {{- fail "When running as deployment, disk keymanager can't be used. 'keyManager.disk.enabled' must be false." }} diff --git a/charts/spire/charts/spire-server/values.yaml b/charts/spire/charts/spire-server/values.yaml index 1fdfeb0..47b0fbc 100644 --- a/charts/spire/charts/spire-server/values.yaml +++ b/charts/spire/charts/spire-server/values.yaml @@ -196,8 +196,10 @@ dataStore: username: spire ## @param dataStore.sql.password Only used when type != "sqlite3" password: "" - ## @param dataStore.sql.file Data source file. Only used when type == "sqlite3" + ## @param dataStore.sql.file Data source file. Only used when type == "sqlite3" and inMemory is false file: "/run/spire/data/datastore.sqlite3" + ## @param dataStore.sql.inMemory Hold the sqlite3 datastore in memory instead of in a file, in which case `file` is unused. The datastore starts empty on every restart, so this only suits a single replica whose registration entries are recreated at startup, for example by the controller manager writing static entries. Required to run as a deployment on sqlite3, since a deployment has no durable per-pod storage. + inMemory: false ## @param dataStore.sql.options [array] takes an array of objects of form {: } to use when building the database connection string options: [] diff --git a/charts/spire/templates/NOTES.txt b/charts/spire/templates/NOTES.txt index 0b8a729..de8cc74 100644 --- a/charts/spire/templates/NOTES.txt +++ b/charts/spire/templates/NOTES.txt @@ -22,6 +22,24 @@ Warning: You're using an unsupported plugin. Functionality of this release and f Warning: You're using an experimental config. Functionality of this release and future upgrades aren't guaranteed to work smoothly. {{- end }} {{- if (index .Values "spire-server").enabled }} +{{- $ss := index .Values "spire-server" }} +{{- if and (eq $ss.dataStore.sql.databaseType "sqlite3") $ss.dataStore.sql.inMemory }} +{{- $upstream := false }} +{{- range $name, $cfg := $ss.upstreamAuthority }} +{{- if kindIs "map" $cfg }}{{ if eq ($cfg.enabled | toString) "true" }}{{ $upstream = true }}{{ end }}{{ end }} +{{- end }} +{{- $reconciled := and $ss.controllerManager.enabled (or $ss.controllerManager.reconcile.clusterSPIFFEIDs $ss.controllerManager.reconcile.clusterStaticEntries) }} +{{- if not $reconciled }} + +Warning: dataStore.sql.inMemory is set, but no controller manager reconciler is enabled. Registration entries live only in memory and nothing recreates them, so every entry is lost when the server restarts. Enable controllerManager with reconcile.clusterSPIFFEIDs or reconcile.clusterStaticEntries. +{{- end }} +{{- if and $ss.keyManager.memory.enabled (not $upstream) }} + +Warning: dataStore.sql.inMemory is set with keyManager.memory and no upstreamAuthority. The server mints a new CA on every restart, so the whole trust domain has to re-attest and previously issued SVIDs stop verifying. Suitable for testing only; configure a KMS key manager or an upstream authority for anything else. +{{- end }} +{{- end }} +{{- end }} +{{- if (index .Values "spire-server").enabled }} {{- $className := include "spire-server.controller-manager-class-name" (dict "Values" (index .Values "spire-server") "Release" .Release) }} {{- if (index .Values "spire-server").controllerManager.enabled }} {{- if (index .Values "spire-server").controllerManager.watchClassless }} diff --git a/tests/unit/spire_test.go b/tests/unit/spire_test.go index 1eccff9..c383e7a 100644 --- a/tests/unit/spire_test.go +++ b/tests/unit/spire_test.go @@ -428,4 +428,202 @@ spire-server: Expect(serverResource).ShouldNot(ContainSubstring("\n updateStrategy:")) }) }) + Describe("spire-server.kind.deployment.sqlite3", func() { + deployment := func(sql string) string { + return ` +spire-server: + kind: deployment + persistence: + type: emptyDir + keyManager: + disk: + enabled: false + memory: + enabled: true + updateStrategy: + type: Recreate + dataStore: + sql: +` + sql + } + + It("renders a Deployment when the sqlite3 datastore is in memory", func() { + objs, err := ValueStringRender(chart, deployment(` inMemory: true +`)) + Expect(err).Should(Succeed()) + serverResource := objs["spire/charts/spire-server/templates/server-resource.yaml"] + Expect(serverResource).Should(ContainSubstring("kind: Deployment")) + Expect(serverResource).ShouldNot(ContainSubstring("kind: StatefulSet")) + }) + + It("rejects a file backed sqlite3 datastore", func() { + _, err := ValueStringRender(chart, deployment(` inMemory: false +`)) + Expect(err).Should(MatchError(ContainSubstring("sqlite3 can only be used in memory"))) + }) + }) + Describe("spire-server.dataStore.sql.inMemory", func() { + It("builds a shared cache connection string and ignores file", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + dataStore: + sql: + inMemory: true + file: /run/spire/data/datastore.sqlite3 +`) + Expect(err).Should(Succeed()) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + Should(ContainSubstring(`"connection_string": "memdb?mode=memory\u0026cache=shared"`)) + }) + + It("keeps the file connection string when left off", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + dataStore: + sql: + file: /run/spire/data/datastore.sqlite3 +`) + Expect(err).Should(Succeed()) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + Should(ContainSubstring(`"connection_string": "/run/spire/data/datastore.sqlite3"`)) + }) + }) + Describe("spire-server.dataStore.sql.inMemory warnings", func() { + notes := func(values string) string { + objs, err := ValueStringRender(chart, values) + ExpectWithOffset(1, err).Should(Succeed()) + return objs["spire/templates/NOTES.txt"] + } + safe := ` +spire-server: + dataStore: + sql: + inMemory: true + controllerManager: + enabled: true + reconcile: + clusterStaticEntries: true + upstreamAuthority: + vault: + enabled: true +` + + It("stays quiet on the default values", func() { + Expect(notes(`spire-server: {}`)).ShouldNot(ContainSubstring("Warning: dataStore.sql.inMemory")) + }) + + It("stays quiet when entries are reconciled and a CA is upstream", func() { + Expect(notes(safe)).ShouldNot(ContainSubstring("Warning: dataStore.sql.inMemory")) + }) + + It("warns when nothing recreates the registration entries", func() { + Expect(notes(` +spire-server: + dataStore: + sql: + inMemory: true + controllerManager: + enabled: false +`)).Should(ContainSubstring("nothing recreates them")) + }) + + It("warns when the CA is also in memory with no upstream authority", func() { + Expect(notes(` +spire-server: + dataStore: + sql: + inMemory: true + controllerManager: + enabled: true + reconcile: + clusterStaticEntries: true + keyManager: + disk: + enabled: false + memory: + enabled: true +`)).Should(ContainSubstring("mints a new CA on every restart")) + }) + + It("stays quiet on a deployment that cannot surge", func() { + Expect(notes(safe + ` + kind: deployment + persistence: + type: emptyDir + keyManager: + disk: + enabled: false + memory: + enabled: true + updateStrategy: + type: Recreate +`)).ShouldNot(ContainSubstring("Warning: dataStore.sql.inMemory")) + }) + }) + Describe("spire-server.updateStrategy surge guard", func() { + deployment := func(strategy string) string { + return ` +spire-server: + kind: deployment + persistence: + type: emptyDir + keyManager: + disk: + enabled: false + memory: + enabled: true + dataStore: + sql: + inMemory: true +` + strategy + } + + It("rejects an in-memory deployment that can surge", func() { + _, err := ValueStringRender(chart, deployment(``)) + Expect(err).Should(MatchError(ContainSubstring("must not surge"))) + }) + + It("rejects an explicit rolling update that can surge", func() { + _, err := ValueStringRender(chart, deployment(` updateStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 +`)) + Expect(err).Should(MatchError(ContainSubstring("must not surge"))) + }) + + It("accepts Recreate", func() { + _, err := ValueStringRender(chart, deployment(` updateStrategy: + type: Recreate +`)) + Expect(err).Should(Succeed()) + }) + + It("accepts a rolling update pinned to maxSurge 0", func() { + _, err := ValueStringRender(chart, deployment(` updateStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 +`)) + Expect(err).Should(Succeed()) + }) + + It("accepts maxSurge expressed as a percentage", func() { + _, err := ValueStringRender(chart, deployment(` updateStrategy: + rollingUpdate: + maxSurge: 0% +`)) + Expect(err).Should(Succeed()) + }) + + It("leaves a file backed statefulset alone", func() { + _, err := ValueStringRender(chart, ` +spire-server: + updateStrategy: + type: RollingUpdate +`) + Expect(err).Should(Succeed()) + }) + }) })