From 2d3a03fccf4eeafe211aebc4f1beaab5bad6ffb3 Mon Sep 17 00:00:00 2001 From: ivanauth Date: Tue, 2 Dec 2025 00:31:06 -0500 Subject: [PATCH 1/4] Add baseImage validation, ResolvedBaseImage status field, and CEL validation --- config/crds/authzed.com_spicedbclusters.yaml | 9 + examples/alternative-registry/README.md | 128 +++++++++ .../alternative-registry/spicedb-cluster.yaml | 58 ++++ pkg/apis/authzed/v1alpha1/types.go | 10 + pkg/config/config.go | 21 ++ pkg/config/config_test.go | 264 ++++++++++++++++++ pkg/controller/validate_config.go | 1 + pkg/controller/validate_config_test.go | 5 +- pkg/crds/authzed.com_spicedbclusters.yaml | 9 + 9 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 examples/alternative-registry/README.md create mode 100644 examples/alternative-registry/spicedb-cluster.yaml diff --git a/config/crds/authzed.com_spicedbclusters.yaml b/config/crds/authzed.com_spicedbclusters.yaml index ea2306ec..d619d927 100644 --- a/config/crds/authzed.com_spicedbclusters.yaml +++ b/config/crds/authzed.com_spicedbclusters.yaml @@ -74,6 +74,9 @@ spec: baseImage: description: |- BaseImage specifies the base container image to use for SpiceDB. + This is useful for air-gapped environments or when using a private registry. + The operator will append the appropriate tag based on version/channel. + Must not include a tag or digest - use spec.version or spec.config.image instead. If not specified, will fall back to the operator's --base-image flag, then to the imageName defined in the update graph. type: string @@ -310,6 +313,12 @@ spec: description: Phase is the currently running phase (used for phased migrations) type: string + resolvedBaseImage: + description: |- + ResolvedBaseImage is the base image that was resolved for this cluster. + This shows which registry/image the operator is using before appending + the version tag. Useful for debugging alternative registry configurations. + type: string secretHash: description: SecretHash is a digest of the last applied secret type: string diff --git a/examples/alternative-registry/README.md b/examples/alternative-registry/README.md new file mode 100644 index 00000000..0650c85d --- /dev/null +++ b/examples/alternative-registry/README.md @@ -0,0 +1,128 @@ +# Using Alternative Container Registry + +This example demonstrates how to configure the SpiceDB operator to use an alternative container registry instead of the default one. + +## Overview + +The SpiceDB operator supports specifying a custom base image for SpiceDB containers through the `baseImage` field in the `SpiceDBCluster` spec. This is useful when: + +- You need to use a private container registry +- You want to mirror images to your own registry for security or compliance reasons +- You need to use a registry proxy for better performance +- You're running in an air-gapped environment + +## Configuration + +The image selection follows this precedence order (highest to lowest): + +1. `.spec.config.image` with explicit tag/digest (overrides everything) +2. `.spec.baseImage` field (what this example uses) +3. The operator's `--base-image` flag +4. The `imageName` defined in the update graph + +**Important:** The `baseImage` field must NOT contain a tag (`:tag`) or digest (`@sha256:...`). The operator will automatically append the appropriate tag based on the `version` or `channel` you specify. If you need to specify an exact image with tag, use `.spec.config.image` instead. + +## Example + +See [spicedb-cluster.yaml](spicedb-cluster.yaml) for a complete example. + +```yaml +apiVersion: authzed.com/v1alpha1 +kind: SpiceDBCluster +metadata: + name: example-with-custom-registry +spec: + # Specify your alternative registry here (NO TAG!) + baseImage: "my-registry.company.com/authzed/spicedb" + + # The operator will append the appropriate tag based on the version/channel + version: "v1.33.0" + + config: + datastoreEngine: postgres + # ... other config + + # If using a private registry, use patches to add imagePullSecrets + patches: + - kind: Deployment + patch: | + spec: + template: + spec: + imagePullSecrets: + - name: registry-credentials +``` + +## How it Works + +When you specify a `baseImage`, the operator will: + +1. Use your specified registry as the base +2. Append the appropriate tag or digest based on the `version` or `channel` you specify +3. The final image will be: `:` or `@` + +For example, if you specify: + +- `baseImage: "my-registry.company.com/authzed/spicedb"` +- `version: "v1.33.0"` + +The operator will use: `my-registry.company.com/authzed/spicedb:v1.33.0` + +## Private Registry Authentication + +If your alternative registry requires authentication, you need to: + +1. Create an image pull secret with your registry credentials: + + ```bash + kubectl create secret docker-registry registry-credentials \ + --docker-server=my-registry.company.com \ + --docker-username=YOUR-USERNAME \ + --docker-password=YOUR-PASSWORD \ + --namespace=spicedb-custom-registry + ``` + +2. Use the `patches` field to inject the image pull secret into the deployment: + + ```yaml + spec: + patches: + - kind: Deployment + patch: | + spec: + template: + spec: + imagePullSecrets: + - name: registry-credentials + ``` + +## Common Mistakes + +### Including a tag in baseImage + +**Wrong:** + +```yaml +spec: + baseImage: "my-registry.company.com/authzed/spicedb:v1.33.0" # Don't include tag! +``` + +**Correct:** + +```yaml +spec: + baseImage: "my-registry.company.com/authzed/spicedb" + version: "v1.33.0" +``` + +### Confusing baseImage with config.image + +- Use `baseImage` when you want the operator to manage versions via the update graph +- Use `config.image` (with full tag/digest) when you want to bypass the update graph entirely + +## Important Notes + +- Make sure your Kubernetes nodes can pull from your alternative registry +- If using a private registry, use the `patches` field to configure image pull secrets +- The operator still uses the update graph to determine valid versions and migration paths +- The alternative registry must contain the exact same images as the official registry diff --git a/examples/alternative-registry/spicedb-cluster.yaml b/examples/alternative-registry/spicedb-cluster.yaml new file mode 100644 index 00000000..e4af9bcc --- /dev/null +++ b/examples/alternative-registry/spicedb-cluster.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: spicedb-custom-registry +--- +apiVersion: authzed.com/v1alpha1 +kind: SpiceDBCluster +metadata: + name: example-with-custom-registry + namespace: spicedb-custom-registry +spec: + # Use an alternative container registry + # The operator will append the appropriate tag based on version/channel + # NOTE: Do NOT include a tag here - just the registry and image name + baseImage: "my-registry.company.com/authzed/spicedb" + + # Specify the version to use + version: "v1.33.0" + + # Alternatively, use a channel for automatic updates within that channel + # channel: "stable" + + config: + datastoreEngine: postgres + logLevel: info + + secretName: spicedb-config + + # If using a private registry, use patches to add imagePullSecrets to the deployment + patches: + - kind: Deployment + patch: | + spec: + template: + spec: + imagePullSecrets: + - name: registry-credentials +--- +apiVersion: v1 +kind: Secret +metadata: + name: spicedb-config + namespace: spicedb-custom-registry +stringData: + datastore_uri: "postgresql://:@:5432/?sslmode=require" + preshared_key: "" +--- +# If using a private registry, you may need an image pull secret +apiVersion: v1 +kind: Secret +metadata: + name: registry-credentials + namespace: spicedb-custom-registry +type: kubernetes.io/dockerconfigjson +data: + # CHANGE-ME: This is a placeholder - replace with your actual registry credentials + # Generate with: kubectl create secret docker-registry registry-credentials --docker-server=my-registry.company.com --docker-username=YOUR-USERNAME --docker-password=YOUR-PASSWORD --dry-run=client -o yaml + .dockerconfigjson: diff --git a/pkg/apis/authzed/v1alpha1/types.go b/pkg/apis/authzed/v1alpha1/types.go index 39618e70..d6e5ac12 100644 --- a/pkg/apis/authzed/v1alpha1/types.go +++ b/pkg/apis/authzed/v1alpha1/types.go @@ -105,6 +105,9 @@ type ClusterSpec struct { Patches []Patch `json:"patches,omitempty"` // BaseImage specifies the base container image to use for SpiceDB. + // This is useful for air-gapped environments or when using a private registry. + // The operator will append the appropriate tag based on version/channel. + // Must not include a tag or digest - use spec.version or spec.config.image instead. // If not specified, will fall back to the operator's --base-image flag, // then to the imageName defined in the update graph. // +optional @@ -182,6 +185,12 @@ type ClusterStatus struct { // Image is the image that is or will be used for this cluster Image string `json:"image,omitempty"` + // ResolvedBaseImage is the base image that was resolved for this cluster. + // This shows which registry/image the operator is using before appending + // the version tag. Useful for debugging alternative registry configurations. + // +optional + ResolvedBaseImage string `json:"resolvedBaseImage,omitempty"` + // Migration is the name of the last migration applied Migration string `json:"migration,omitempty"` @@ -208,6 +217,7 @@ func (s ClusterStatus) Equals(other ClusterStatus) bool { s.CurrentMigrationHash == other.TargetMigrationHash && s.SecretHash == other.SecretHash && s.Image == other.Image && + s.ResolvedBaseImage == other.ResolvedBaseImage && s.Migration == other.Migration && s.Phase == other.Phase && s.CurrentVersion.Equals(other.CurrentVersion) && diff --git a/pkg/config/config.go b/pkg/config/config.go index dcc80844..7e6656d9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -150,6 +150,7 @@ type MigrationConfig struct { DatastoreURI string SpannerCredsSecretRef string TargetSpiceDBImage string + ResolvedBaseImage string EnvPrefix string SpiceDBCmd string DatastoreTLSSecretName string @@ -242,12 +243,32 @@ func NewConfig(cluster *v1alpha1.SpiceDBCluster, globalConfig *OperatorConfig, s // unless the current config is equal to the input. image := imageKey.pop(config) + // Validate that baseImage does not contain a tag or digest + if cluster.Spec.BaseImage != "" { + if strings.Contains(cluster.Spec.BaseImage, "@") { + errs = append(errs, fmt.Errorf("baseImage must not contain a digest (@sha256:...) - version is determined by the update graph")) + } else { + // Check for tag - a tag appears after the last colon, but only if that colon + // isn't part of a port number. Port numbers are followed by a slash (path), + // while tags are at the end of the string. + lastColon := strings.LastIndex(cluster.Spec.BaseImage, ":") + if lastColon != -1 { + afterColon := cluster.Spec.BaseImage[lastColon+1:] + // If there's no slash after the colon, it's a tag (not a port) + if !strings.Contains(afterColon, "/") { + errs = append(errs, fmt.Errorf("baseImage must not contain a tag (:tag) - version is determined by the update graph. Use spec.version or spec.config.image instead")) + } + } + } + } + baseImage, targetSpiceDBVersion, state, err := globalConfig.ComputeTarget(globalConfig.ImageName, cluster.Spec.BaseImage, image, cluster.Spec.Version, cluster.Spec.Channel, datastoreEngine, cluster.Status.CurrentVersion, cluster.RolloutInProgress()) if err != nil { errs = append(errs, err) } migrationConfig.SpiceDBVersion = targetSpiceDBVersion + migrationConfig.ResolvedBaseImage = baseImage migrationConfig.TargetPhase = state.Phase migrationConfig.TargetMigration = state.Migration if len(migrationConfig.TargetMigration) == 0 { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3a333768..52b6bf15 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -143,6 +143,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -230,6 +231,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -315,6 +317,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "memory", DatastoreURI: "", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -388,6 +391,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "adifferentimage:tag", + ResolvedBaseImage: "adifferentimage", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -456,6 +460,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "adifferentimage@sha256:abc", + ResolvedBaseImage: "adifferentimage", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -536,6 +541,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -623,6 +629,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -710,6 +717,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -804,6 +812,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -896,6 +905,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -985,6 +995,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1073,6 +1084,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -1167,6 +1179,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -1259,6 +1272,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -1352,6 +1366,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -1445,6 +1460,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1535,6 +1551,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1625,6 +1642,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v2", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1721,6 +1739,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v2", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1824,6 +1843,7 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "image:v2", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", @@ -1912,6 +1932,7 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "spanner", DatastoreURI: "uri", TargetSpiceDBImage: "image:v1", + ResolvedBaseImage: "image", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -2004,6 +2025,249 @@ func TestNewConfig(t *testing.T) { DatastoreURI: "uri", SpannerCredsSecretRef: "", TargetSpiceDBImage: "public.ecr.aws/authzed/spicedb:v1", + ResolvedBaseImage: "public.ecr.aws/authzed/spicedb", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + DatastoreTLSSecretName: "", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + Attributes: []v1alpha1.SpiceDBVersionAttributes{ + v1alpha1.SpiceDBVersionAttributesMigration, + }, + }, + }, + SpiceConfig: SpiceConfig{ + LogLevel: "info", + SkipMigrations: false, + Name: "test", + Namespace: "test", + UID: "1", + Replicas: 2, + PresharedKey: "psk", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + ServiceAccountName: "test", + DispatchEnabled: true, + DispatchUpstreamCASecretPath: "tls.crt", + ProjectLabels: true, + ProjectAnnotations: true, + Passthrough: map[string]string{ + "datastoreEngine": "cockroachdb", + "dispatchClusterEnabled": "true", + "terminationLogPath": "/dev/termination-log", + }, + }, + }, + wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, + wantEnvs: []string{ + "SPICEDB_POD_NAME=FIELD_REF=metadata.name", + "SPICEDB_LOG_LEVEL=info", + "SPICEDB_GRPC_PRESHARED_KEY=preshared_key", + "SPICEDB_DATASTORE_CONN_URI=datastore_uri", + "SPICEDB_DISPATCH_UPSTREAM_ADDR=kubernetes:///test.test:dispatch", + "SPICEDB_DATASTORE_ENGINE=cockroachdb", + "SPICEDB_DISPATCH_CLUSTER_ENABLED=true", + "SPICEDB_TERMINATION_LOG_PATH=/dev/termination-log", + }, + wantPortCount: 4, + }, + { + name: "baseImage with tag is rejected", + args: args{ + cluster: v1alpha1.ClusterSpec{ + BaseImage: "public.ecr.aws/authzed/spicedb:v1.33.0", + Config: json.RawMessage(` + { + "datastoreEngine": "cockroachdb" + } + `), + }, + globalConfig: OperatorConfig{ + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb", "default": "true"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, + }, + secret: &corev1.Secret{Data: map[string][]byte{ + "datastore_uri": []byte("uri"), + "preshared_key": []byte("psk"), + }}, + }, + wantErrs: []error{fmt.Errorf("baseImage must not contain a tag (:tag) - version is determined by the update graph. Use spec.version or spec.config.image instead")}, + wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, + }, + { + name: "baseImage with digest is rejected", + args: args{ + cluster: v1alpha1.ClusterSpec{ + BaseImage: "public.ecr.aws/authzed/spicedb@sha256:abc123", + Config: json.RawMessage(` + { + "datastoreEngine": "cockroachdb" + } + `), + }, + globalConfig: OperatorConfig{ + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb", "default": "true"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, + }, + secret: &corev1.Secret{Data: map[string][]byte{ + "datastore_uri": []byte("uri"), + "preshared_key": []byte("psk"), + }}, + }, + wantErrs: []error{fmt.Errorf("baseImage must not contain a digest (@sha256:...) - version is determined by the update graph")}, + wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, + }, + { + name: "baseImage with port number in registry is accepted", + args: args{ + cluster: v1alpha1.ClusterSpec{ + BaseImage: "my-registry.company.com:5000/authzed/spicedb", + Config: json.RawMessage(` + { + "datastoreEngine": "cockroachdb" + } + `), + }, + globalConfig: OperatorConfig{ + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb", "default": "true"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, + }, + secret: &corev1.Secret{Data: map[string][]byte{ + "datastore_uri": []byte("uri"), + "preshared_key": []byte("psk"), + }}, + }, + want: &Config{ + MigrationConfig: MigrationConfig{ + TargetMigration: "head", + TargetPhase: "", + MigrationLogLevel: "debug", + DatastoreEngine: "cockroachdb", + DatastoreURI: "uri", + SpannerCredsSecretRef: "", + TargetSpiceDBImage: "my-registry.company.com:5000/authzed/spicedb:v1", + ResolvedBaseImage: "my-registry.company.com:5000/authzed/spicedb", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + DatastoreTLSSecretName: "", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + Attributes: []v1alpha1.SpiceDBVersionAttributes{ + v1alpha1.SpiceDBVersionAttributesMigration, + }, + }, + }, + SpiceConfig: SpiceConfig{ + LogLevel: "info", + SkipMigrations: false, + Name: "test", + Namespace: "test", + UID: "1", + Replicas: 2, + PresharedKey: "psk", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + ServiceAccountName: "test", + DispatchEnabled: true, + DispatchUpstreamCASecretPath: "tls.crt", + ProjectLabels: true, + ProjectAnnotations: true, + Passthrough: map[string]string{ + "datastoreEngine": "cockroachdb", + "dispatchClusterEnabled": "true", + "terminationLogPath": "/dev/termination-log", + }, + }, + }, + wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, + wantEnvs: []string{ + "SPICEDB_POD_NAME=FIELD_REF=metadata.name", + "SPICEDB_LOG_LEVEL=info", + "SPICEDB_GRPC_PRESHARED_KEY=preshared_key", + "SPICEDB_DATASTORE_CONN_URI=datastore_uri", + "SPICEDB_DISPATCH_UPSTREAM_ADDR=kubernetes:///test.test:dispatch", + "SPICEDB_DATASTORE_ENGINE=cockroachdb", + "SPICEDB_DISPATCH_CLUSTER_ENABLED=true", + "SPICEDB_TERMINATION_LOG_PATH=/dev/termination-log", + }, + wantPortCount: 4, + }, + { + name: "valid baseImage populates ResolvedBaseImage in config", + args: args{ + cluster: v1alpha1.ClusterSpec{ + BaseImage: "gcr.io/my-project/spicedb", + Config: json.RawMessage(` + { + "datastoreEngine": "cockroachdb" + } + `), + }, + globalConfig: OperatorConfig{ + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb", "default": "true"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, + }, + secret: &corev1.Secret{Data: map[string][]byte{ + "datastore_uri": []byte("uri"), + "preshared_key": []byte("psk"), + }}, + }, + want: &Config{ + MigrationConfig: MigrationConfig{ + TargetMigration: "head", + TargetPhase: "", + MigrationLogLevel: "debug", + DatastoreEngine: "cockroachdb", + DatastoreURI: "uri", + SpannerCredsSecretRef: "", + TargetSpiceDBImage: "gcr.io/my-project/spicedb:v1", + ResolvedBaseImage: "gcr.io/my-project/spicedb", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", diff --git a/pkg/controller/validate_config.go b/pkg/controller/validate_config.go index 97b50260..399d514b 100644 --- a/pkg/controller/validate_config.go +++ b/pkg/controller/validate_config.go @@ -65,6 +65,7 @@ func (c *ValidateConfigHandler) Handle(ctx context.Context) { CurrentMigrationHash: cluster.Status.CurrentMigrationHash, SecretHash: cluster.Status.SecretHash, Image: validatedConfig.TargetSpiceDBImage, + ResolvedBaseImage: validatedConfig.ResolvedBaseImage, Migration: validatedConfig.TargetMigration, Phase: validatedConfig.TargetPhase, CurrentVersion: validatedConfig.SpiceDBVersion, diff --git a/pkg/controller/validate_config_test.go b/pkg/controller/validate_config_test.go index 49c7a8d2..dded5f43 100644 --- a/pkg/controller/validate_config_test.go +++ b/pkg/controller/validate_config_test.go @@ -48,9 +48,10 @@ func TestValidateConfigHandler(t *testing.T) { }, Status: v1alpha1.ClusterStatus{ Image: "image:v1", + ResolvedBaseImage: "image", Migration: "head", - TargetMigrationHash: "69066f71d9cf4a1c", - CurrentMigrationHash: "69066f71d9cf4a1c", + TargetMigrationHash: "a690b4600777a4f6", + CurrentMigrationHash: "a690b4600777a4f6", CurrentVersion: &v1alpha1.SpiceDBVersion{ Name: "v1", Channel: "cockroachdb", diff --git a/pkg/crds/authzed.com_spicedbclusters.yaml b/pkg/crds/authzed.com_spicedbclusters.yaml index ea2306ec..d619d927 100644 --- a/pkg/crds/authzed.com_spicedbclusters.yaml +++ b/pkg/crds/authzed.com_spicedbclusters.yaml @@ -74,6 +74,9 @@ spec: baseImage: description: |- BaseImage specifies the base container image to use for SpiceDB. + This is useful for air-gapped environments or when using a private registry. + The operator will append the appropriate tag based on version/channel. + Must not include a tag or digest - use spec.version or spec.config.image instead. If not specified, will fall back to the operator's --base-image flag, then to the imageName defined in the update graph. type: string @@ -310,6 +313,12 @@ spec: description: Phase is the currently running phase (used for phased migrations) type: string + resolvedBaseImage: + description: |- + ResolvedBaseImage is the base image that was resolved for this cluster. + This shows which registry/image the operator is using before appending + the version tag. Useful for debugging alternative registry configurations. + type: string secretHash: description: SecretHash is a digest of the last applied secret type: string From 099594303a9a1ed28f440e6394d1b0f139a9a345 Mon Sep 17 00:00:00 2001 From: ivanauth Date: Thu, 5 Mar 2026 12:37:00 -0500 Subject: [PATCH 2/4] Improve alternative-registry example clarity per review feedback --- examples/alternative-registry/README.md | 20 +++++++++++-------- .../alternative-registry/spicedb-cluster.yaml | 14 ++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/examples/alternative-registry/README.md b/examples/alternative-registry/README.md index 0650c85d..ffda0275 100644 --- a/examples/alternative-registry/README.md +++ b/examples/alternative-registry/README.md @@ -26,6 +26,8 @@ The image selection follows this precedence order (highest to lowest): See [spicedb-cluster.yaml](spicedb-cluster.yaml) for a complete example. +Unlike `spec.config.image`, using `baseImage` preserves the update graph — the operator still selects the correct version and handles migration safety based on your channel, but pulls from your registry. + ```yaml apiVersion: authzed.com/v1alpha1 kind: SpiceDBCluster @@ -34,14 +36,14 @@ metadata: spec: # Specify your alternative registry here (NO TAG!) baseImage: "my-registry.company.com/authzed/spicedb" - - # The operator will append the appropriate tag based on the version/channel - version: "v1.33.0" - + + # Use a channel to get automatic updates from the update graph + channel: "stable" + config: datastoreEngine: postgres # ... other config - + # If using a private registry, use patches to add imagePullSecrets patches: - kind: Deployment @@ -58,15 +60,17 @@ spec: When you specify a `baseImage`, the operator will: 1. Use your specified registry as the base -2. Append the appropriate tag or digest based on the `version` or `channel` you specify +2. Resolve the appropriate tag or digest from the update graph based on your `channel` 3. The final image will be: `:` or `@` For example, if you specify: - `baseImage: "my-registry.company.com/authzed/spicedb"` -- `version: "v1.33.0"` +- `channel: "stable"` + +The operator resolves the latest version in the `stable` channel and uses e.g.: `my-registry.company.com/authzed/spicedb:v1.33.0` -The operator will use: `my-registry.company.com/authzed/spicedb:v1.33.0` +You can also pin a specific version with `version: "v1.33.0"` instead of `channel`. Use `spec.config.image` only if you need to use an image that is not present in the update graph (this bypasses migration safety checks). ## Private Registry Authentication diff --git a/examples/alternative-registry/spicedb-cluster.yaml b/examples/alternative-registry/spicedb-cluster.yaml index e4af9bcc..c3b2538b 100644 --- a/examples/alternative-registry/spicedb-cluster.yaml +++ b/examples/alternative-registry/spicedb-cluster.yaml @@ -10,15 +10,15 @@ metadata: namespace: spicedb-custom-registry spec: # Use an alternative container registry - # The operator will append the appropriate tag based on version/channel + # The operator will resolve the tag from the update graph based on your channel # NOTE: Do NOT include a tag here - just the registry and image name baseImage: "my-registry.company.com/authzed/spicedb" - - # Specify the version to use - version: "v1.33.0" - - # Alternatively, use a channel for automatic updates within that channel - # channel: "stable" + + # Use a channel to get automatic updates from the update graph + channel: "stable" + + # Alternatively, pin a specific version while still using the update graph + # version: "v1.33.0" config: datastoreEngine: postgres From 641c5909b55ba598d49be6fef38a19d6efed7086 Mon Sep 17 00:00:00 2001 From: ivanauth Date: Thu, 5 Mar 2026 15:52:44 -0500 Subject: [PATCH 3/4] fix test cases after rebase on secrets support --- pkg/config/config_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 52b6bf15..3226a972 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2050,6 +2050,9 @@ func TestNewConfig(t *testing.T) { ServiceAccountName: "test", DispatchEnabled: true, DispatchUpstreamCASecretPath: "tls.crt", + DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, + PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, + MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -2076,6 +2079,7 @@ func TestNewConfig(t *testing.T) { name: "baseImage with tag is rejected", args: args{ cluster: v1alpha1.ClusterSpec{ + SecretRef: "test-secret", BaseImage: "public.ecr.aws/authzed/spicedb:v1.33.0", Config: json.RawMessage(` { @@ -2110,6 +2114,7 @@ func TestNewConfig(t *testing.T) { name: "baseImage with digest is rejected", args: args{ cluster: v1alpha1.ClusterSpec{ + SecretRef: "test-secret", BaseImage: "public.ecr.aws/authzed/spicedb@sha256:abc123", Config: json.RawMessage(` { @@ -2144,6 +2149,7 @@ func TestNewConfig(t *testing.T) { name: "baseImage with port number in registry is accepted", args: args{ cluster: v1alpha1.ClusterSpec{ + SecretRef: "test-secret", BaseImage: "my-registry.company.com:5000/authzed/spicedb", Config: json.RawMessage(` { @@ -2205,6 +2211,9 @@ func TestNewConfig(t *testing.T) { ServiceAccountName: "test", DispatchEnabled: true, DispatchUpstreamCASecretPath: "tls.crt", + DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, + PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, + MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -2231,6 +2240,7 @@ func TestNewConfig(t *testing.T) { name: "valid baseImage populates ResolvedBaseImage in config", args: args{ cluster: v1alpha1.ClusterSpec{ + SecretRef: "test-secret", BaseImage: "gcr.io/my-project/spicedb", Config: json.RawMessage(` { From d8116e6156cf25a936e01c8719dba6650a2947bd Mon Sep 17 00:00:00 2001 From: ivanauth Date: Mon, 30 Mar 2026 13:31:06 -0400 Subject: [PATCH 4/4] Fix two-secret test case to match ResolvedBaseImage hash change The two-secret credentials test was not updated when ResolvedBaseImage was added to MigrationConfig. Update the expected status to include ResolvedBaseImage and the correct migration hash, and use the same tlsSecretName as other test cases for consistent hashing. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/controller/validate_config_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/controller/validate_config_test.go b/pkg/controller/validate_config_test.go index dded5f43..681948af 100644 --- a/pkg/controller/validate_config_test.go +++ b/pkg/controller/validate_config_test.go @@ -241,7 +241,7 @@ func TestValidateConfigHandler(t *testing.T) { Spec: v1alpha1.ClusterSpec{ Config: json.RawMessage(`{ "datastoreEngine": "cockroachdb", - "tlsSecretName": "tls-secret" + "tlsSecretName": "secret" }`), Credentials: &v1alpha1.ClusterCredentials{ DatastoreURI: &v1alpha1.CredentialRef{ @@ -256,9 +256,10 @@ func TestValidateConfigHandler(t *testing.T) { }, Status: v1alpha1.ClusterStatus{ Image: "image:v1", + ResolvedBaseImage: "image", Migration: "head", - TargetMigrationHash: "69066f71d9cf4a1c", - CurrentMigrationHash: "69066f71d9cf4a1c", + TargetMigrationHash: "a690b4600777a4f6", + CurrentMigrationHash: "a690b4600777a4f6", CurrentVersion: &v1alpha1.SpiceDBVersion{ Name: "v1", Channel: "cockroachdb",