diff --git a/.golangci.yaml b/.golangci.yaml index e04d2169..98d05b6b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -10,7 +10,6 @@ linters: enable: - "bidichk" - "bodyclose" - - "deadcode" - "errcheck" - "errname" - "errorlint" @@ -29,12 +28,10 @@ linters: - "revive" - "rowserrcheck" - "staticcheck" - - "structcheck" - "stylecheck" - "tenv" - "typecheck" - "unconvert" - "unused" - - "varcheck" - "wastedassign" - "whitespace" diff --git a/config/crds/authzed.com_spicedbclusters.yaml b/config/crds/authzed.com_spicedbclusters.yaml index 915611a1..4b20d65e 100644 --- a/config/crds/authzed.com_spicedbclusters.yaml +++ b/config/crds/authzed.com_spicedbclusters.yaml @@ -37,6 +37,14 @@ spec: spec: description: ClusterSpec holds the desired state of the cluster. properties: + channel: + description: Channel is the name of a series of updates that operator + should follow. The operator is configured with a datasource that + configures available channels and update paths. If `version` is + not specified, then the operator will keep SpiceDB up-to-date with + the current head of the channel. If `version` is specified, then + the operator will write available updates in the status. + type: string config: description: Config values to be passed to the cluster type: object @@ -46,10 +54,36 @@ spec: that holds secret config for the cluster like passwords, credentials, etc. If the secret is omitted, one will be generated type: string + version: + description: Version is the name of the version of SpiceDB that will + be run. The version is usually a simple version string like `v1.13.0`, + but the operator is configured with a data source that tells it + what versions are allowed, and they may have other names. If omitted, + the newest version in the head of the channel will be used. Note + that the `config.image` field will take precedence over version/channel, + if it is specified + type: string type: object status: description: ClusterStatus communicates the observed state of the cluster. properties: + availableVersions: + description: AvailableVersions is a list of versions that the currently + running version can be updated to. Only applies if using an update + channel. + items: + properties: + channel: + type: string + description: + type: string + name: + type: string + required: + - channel + - name + type: object + type: array conditions: description: Conditions for the current state of the Stack. items: @@ -147,6 +181,20 @@ spec: description: TargetMigrationHash is a hash of the desired migration target and config type: string + version: + description: CurrentVersion is a description of the currently selected + version from the channel, if an update channel is being used. + properties: + channel: + type: string + description: + type: string + name: + type: string + required: + - channel + - name + type: object type: object type: object served: true diff --git a/e2e/cluster_test.go b/e2e/cluster_test.go index 3683fa63..31cf7dc1 100644 --- a/e2e/cluster_test.go +++ b/e2e/cluster_test.go @@ -40,6 +40,7 @@ import ( "github.com/authzed/spicedb-operator/pkg/apis/authzed/v1alpha1" "github.com/authzed/spicedb-operator/pkg/config" "github.com/authzed/spicedb-operator/pkg/metadata" + "github.com/authzed/spicedb-operator/pkg/updates" ) var ( @@ -306,6 +307,7 @@ var _ = Describe("SpiceDBClusters", func() { defer watchCancel() Watch(watchCtx, client, v1alpha1ClusterGVR, ktypes.NamespacedName{Name: name, Namespace: namespace}, "0", func(c *v1alpha1.SpiceDBCluster) bool { condition = c.FindStatusCondition("Migrating") + GinkgoWriter.Println(c.Status) return condition == nil }) g.Expect(condition).To(EqualCondition(v1alpha1.NewMigratingCondition(datastoreEngine, migration))) @@ -440,7 +442,7 @@ var _ = Describe("SpiceDBClusters", func() { condition := c.FindStatusCondition("ValidatingFailed") if condition != nil { foundValidationFailed = true - Expect(condition).To(EqualCondition(v1alpha1.NewInvalidConfigCondition("", fmt.Errorf("[datastoreEngine is a required field, secret must be provided]")))) + Expect(condition).To(EqualCondition(v1alpha1.NewInvalidConfigCondition("", fmt.Errorf("[datastoreEngine is a required field, couldn't find channel for datastore \"\": no channel found for datastore \"\", no update found in channel, secret must be provided]")))) break } } @@ -642,6 +644,7 @@ var _ = Describe("SpiceDBClusters", func() { config := map[string]any{ "datastoreEngine": dsDef.datastoreEngine, "envPrefix": spicedbEnvPrefix, + "image": "spicedb:dev", "cmd": spicedbCmd, } for k, v := range dsDef.passthroughConfig { @@ -723,6 +726,7 @@ var _ = Describe("SpiceDBClusters", func() { config, err := json.Marshal(map[string]any{ "skipMigrations": true, "datastoreEngine": dsDef.datastoreEngine, + "image": "spicedb:dev", "envPrefix": spicedbEnvPrefix, "cmd": spicedbCmd, }) @@ -772,6 +776,7 @@ var _ = Describe("SpiceDBClusters", func() { "datastoreEngine": dsDef.datastoreEngine, "envPrefix": spicedbEnvPrefix, "cmd": spicedbCmd, + "image": "spicedb:dev", "tlsSecretName": "spicedb-grpc-tls", "dispatchUpstreamCASecretName": "spicedb-grpc-tls", } @@ -853,47 +858,6 @@ var _ = Describe("SpiceDBClusters", func() { return spiceCluster.Namespace, spiceCluster.Name }) }) - - When("the image name/tag are updated", Ordered, func() { - var imageName string - - BeforeAll(func() { - newConfig := config.OperatorConfig{ - ImageTag: "updated", - ImageName: "spicedb", - } - imageName = strings.Join([]string{newConfig.ImageName, newConfig.ImageTag}, ":") - WriteConfig(newConfig) - }) - - AfterAll(func() { - newConfig := config.OperatorConfig{ - ImageName: "spicedb", - ImageTag: "dev", - } - imageName = strings.Join([]string{newConfig.ImageName, newConfig.ImageTag}, ":") - WriteConfig(newConfig) - }) - - It("migrates to the latest version", func() { - AssertMigrationsCompleted(imageName, "", "", - func() (string, string, string) { - return spiceCluster.Namespace, spiceCluster.Name, dsDef.datastoreEngine - }) - }) - - It("updates the deployment", func() { - AssertHealthySpiceDBCluster(imageName, func() (string, string) { - return spiceCluster.Namespace, spiceCluster.Name - }, Not(ContainSubstring("ERROR: kuberesolver"))) - }) - - It("deletes the migration job", func() { - AssertMigrationJobCleanup(func() (string, string) { - return spiceCluster.Namespace, spiceCluster.Name - }) - }) - }) }) }) }) @@ -953,23 +917,36 @@ var _ = Describe("SpiceDBClusters", func() { DeferCleanup(cancel) newConfig := config.OperatorConfig{ - AllowedTags: []string{"v1.13.0", "dev", "updated"}, - AllowedImages: []string{ - "ghcr.io/authzed/spicedb", - "spicedb", + ImageName: "ghcr.io/authzed/spicedb", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "postgres", + Metadata: map[string]string{"datastore": "postgres"}, + Nodes: []updates.State{ + {ID: "v1.14.1", Tag: "v1.14.1", Migration: "drop-id-constraints"}, + {ID: "v1.14.0", Tag: "v1.14.0", Migration: "drop-id-constraints"}, + {ID: "v1.14.0-phase2", Tag: "v1.14.0", Migration: "add-xid-constraints", Phase: "write-both-read-new"}, + {ID: "v1.14.0-phase1", Tag: "v1.14.0", Migration: "add-xid-columns", Phase: "write-both-read-old"}, + {ID: "v1.13.0", Tag: "v1.13.0", Migration: "add-ns-config-id"}, + }, + Edges: map[string][]string{ + "v1.13.0": {"v1.14.0-phase1"}, + "v1.14.0-phase1": {"v1.14.0-phase2"}, + "v1.14.0-phase2": {"v1.14.0"}, + "v1.14.0": {"v1.14.1"}, + }, + }, + }, }, - UpdateGraph: config.NewUpdateGraph(), } WriteConfig(newConfig) classConfig := map[string]any{ "logLevel": "debug", "datastoreEngine": "postgres", - "envPrefix": spicedbEnvPrefix, - "cmd": spicedbCmd, "tlsSecretName": "spicedb4-grpc-tls", "dispatchUpstreamCASecretName": "spicedb4-grpc-tls", - "image": "spicedb:v1.13.0", } jsonConfig, err := json.Marshal(classConfig) Expect(err).To(BeNil()) @@ -983,6 +960,7 @@ var _ = Describe("SpiceDBClusters", func() { Namespace: testNamespace, }, Spec: v1alpha1.ClusterSpec{ + Version: "v1.13.0", Config: jsonConfig, SecretRef: "spicedb4", }, @@ -1016,7 +994,7 @@ var _ = Describe("SpiceDBClusters", func() { _, err = client.Resource(v1alpha1ClusterGVR).Namespace(spiceCluster.Namespace).Create(ctx, &unstructured.Unstructured{Object: u}, metav1.CreateOptions{}) Expect(err).To(Succeed()) - AssertMigrationsCompleted("spicedb:v1.13.0", "head", "", + AssertMigrationsCompleted("ghcr.io/authzed/spicedb:v1.13.0", "add-ns-config-id", "", func() (string, string, string) { return spiceCluster.Namespace, spiceCluster.Name, "postgres" }) @@ -1029,19 +1007,20 @@ var _ = Describe("SpiceDBClusters", func() { g.Expect(len(fetched.Status.Conditions)).To(BeZero()) }).Should(Succeed()) - // once the cluster is running at the initial version, - init := config.SpiceDBMigrationState{Tag: "v1.13.0", Migration: "add-ns-config-id"} - phase1 := config.SpiceDBMigrationState{Tag: "dev", Migration: "add-xid-columns", Phase: "write-both-read-old"} - phase2 := config.SpiceDBMigrationState{Tag: "dev", Migration: "add-xid-constraints", Phase: "write-both-read-new"} - phase3 := config.SpiceDBMigrationState{Tag: "dev", Migration: "drop-id-constraints"} - phase4 := config.SpiceDBMigrationState{Tag: "updated"} - - newConfig.AddHeadMigration(config.SpiceDBDatastoreState{Tag: "v1.13.0", Datastore: "postgres"}, "add-ns-config-id") - newConfig.AddEdge(init, phase1) - newConfig.AddEdge(phase1, phase2) - newConfig.AddEdge(phase2, phase3) - newConfig.AddEdge(phase3, phase4) - WriteConfig(newConfig) + // once the cluster is running at the initial version, update the target version + Eventually(func(g Gomega) { + clusterUnst, err := client.Resource(v1alpha1ClusterGVR).Namespace(spiceCluster.Namespace).Get(ctx, spiceCluster.Name, metav1.GetOptions{}) + g.Expect(err).To(Succeed()) + fetched, err := typed.UnstructuredObjToTypedObj[*v1alpha1.SpiceDBCluster](clusterUnst) + g.Expect(err).To(Succeed()) + g.Expect(len(fetched.Status.Conditions)).To(BeZero()) + + fetched.Spec.Version = "v1.14.1" + u, err = runtime.DefaultUnstructuredConverter.ToUnstructured(fetched) + Expect(err).To(Succeed()) + _, err = client.Resource(v1alpha1ClusterGVR).Namespace(spiceCluster.Namespace).Update(ctx, &unstructured.Unstructured{Object: u}, metav1.UpdateOptions{}) + Expect(err).To(Succeed()) + }).Should(Succeed()) }) AfterAll(func() { @@ -1050,7 +1029,6 @@ var _ = Describe("SpiceDBClusters", func() { newConfig := config.OperatorConfig{ ImageName: "spicedb", - ImageTag: "dev", } WriteConfig(newConfig) @@ -1061,31 +1039,31 @@ var _ = Describe("SpiceDBClusters", func() { When("there is a series of required migrations", Ordered, func() { It("migrates to phase1", func() { - AssertMigrationsCompleted("spicedb:dev", "add-xid-columns", "write-both-read-old", + AssertMigrationsCompleted("ghcr.io/authzed/spicedb:v1.14.0", "add-xid-columns", "write-both-read-old", func() (string, string, string) { return spiceCluster.Namespace, spiceCluster.Name, "postgres" }) }) It("migrates to phase2", func() { - AssertMigrationsCompleted("spicedb:dev", "add-xid-constraints", "write-both-read-new", + AssertMigrationsCompleted("ghcr.io/authzed/spicedb:v1.14.0", "add-xid-constraints", "write-both-read-new", func() (string, string, string) { return spiceCluster.Namespace, spiceCluster.Name, "postgres" }) }) It("migrates to phase3", func() { - AssertMigrationsCompleted("spicedb:dev", "drop-id-constraints", "", + AssertMigrationsCompleted("ghcr.io/authzed/spicedb:v1.14.0", "drop-id-constraints", "", func() (string, string, string) { return spiceCluster.Namespace, spiceCluster.Name, "postgres" }) }) - It("migrates to phase4", func() { - AssertMigrationsCompleted("spicedb:updated", "", "", - func() (string, string, string) { - return spiceCluster.Namespace, spiceCluster.Name, "postgres" - }) + It("updates to the next version", func() { + AssertHealthySpiceDBCluster("ghcr.io/authzed/spicedb:v1.14.1", + func() (string, string) { + return spiceCluster.Namespace, spiceCluster.Name + }, Not(ContainSubstring("ERROR: kuberesolver"))) }) }) }) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c572a460..2c33f2fc 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -136,7 +136,6 @@ func StartOperator() { opconfig := config.OperatorConfig{ ImageName: "spicedb", - ImageTag: "dev", } testRestConfig := rest.CopyConfig(restConfig) diff --git a/pkg/apis/authzed/v1alpha1/types.go b/pkg/apis/authzed/v1alpha1/types.go index b47b4284..1310b644 100644 --- a/pkg/apis/authzed/v1alpha1/types.go +++ b/pkg/apis/authzed/v1alpha1/types.go @@ -47,6 +47,24 @@ func (c *SpiceDBCluster) WithAnnotations(entries map[string]string) *SpiceDBClus // ClusterSpec holds the desired state of the cluster. type ClusterSpec struct { + // Version is the name of the version of SpiceDB that will be run. + // The version is usually a simple version string like `v1.13.0`, but the + // operator is configured with a data source that tells it what versions + // are allowed, and they may have other names. + // If omitted, the newest version in the head of the channel will be used. + // Note that the `config.image` field will take precedence over + // version/channel, if it is specified + Version string `json:"version,omitempty"` + + // Channel is the name of a series of updates that operator should follow. + // The operator is configured with a datasource that configures available + // channels and update paths. + // If `version` is not specified, then the operator will keep SpiceDB + // up-to-date with the current head of the channel. + // If `version` is specified, then the operator will write available updates + // in the status. + Channel string `json:"channel,omitempty"` + // Config values to be passed to the cluster // +optional // +kubebuilder:validation:Schemaless @@ -89,11 +107,25 @@ type ClusterStatus struct { // Phase is the currently running phase (used for phased migrations) Phase string `json:"phase,omitempty"` + // CurrentVersion is a description of the currently selected version from + // the channel, if an update channel is being used. + CurrentVersion *SpiceDBVersion `json:"version,omitempty"` + + // AvailableVersions is a list of versions that the currently running + // version can be updated to. Only applies if using an update channel. + AvailableVersions []SpiceDBVersion `json:"availableVersions,omitempty"` + // Conditions for the current state of the Stack. // +optional Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` } +type SpiceDBVersion struct { + Name string `json:"name"` + Channel string `json:"channel"` + Description string `json:"description,omitempty"` +} + // SpiceDBClusterList is a list of SpiceDBCluster resources // // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go index 5cb93821..5c9a74cc 100644 --- a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go @@ -34,6 +34,16 @@ func (in *ClusterSpec) DeepCopy() *ClusterSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { *out = *in + if in.CurrentVersion != nil { + in, out := &in.CurrentVersion, &out.CurrentVersion + *out = new(SpiceDBVersion) + **out = **in + } + if in.AvailableVersions != nil { + in, out := &in.AvailableVersions, &out.AvailableVersions + *out = make([]SpiceDBVersion, len(*in)) + copy(*out, *in) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -111,3 +121,18 @@ func (in *SpiceDBClusterList) DeepCopyObject() runtime.Object { } return nil } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SpiceDBVersion) DeepCopyInto(out *SpiceDBVersion) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SpiceDBVersion. +func (in *SpiceDBVersion) DeepCopy() *SpiceDBVersion { + if in == nil { + return nil + } + out := new(SpiceDBVersion) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 10df0f17..4a4a932f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/fatih/camelcase" - "golang.org/x/exp/slices" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" @@ -22,6 +21,7 @@ import ( "github.com/authzed/spicedb-operator/pkg/apis/authzed/v1alpha1" "github.com/authzed/spicedb-operator/pkg/metadata" + "github.com/authzed/spicedb-operator/pkg/updates" ) const ( @@ -111,6 +111,7 @@ type MigrationConfig struct { EnvPrefix string SpiceDBCmd string DatastoreTLSSecretName string + SpiceDBVersion *v1alpha1.SpiceDBVersion } // SpiceConfig contains config relevant to running spicedb or determining @@ -135,7 +136,7 @@ type SpiceConfig struct { } // NewConfig checks that the values in the config + the secret are sane -func NewConfig(nn types.NamespacedName, uid types.UID, currentState *SpiceDBMigrationState, globalConfig *OperatorConfig, rawConfig json.RawMessage, secret *corev1.Secret, rolling bool) (*Config, Warning, error) { +func NewConfig(nn types.NamespacedName, uid types.UID, version, channel string, currentVersion *v1alpha1.SpiceDBVersion, globalConfig *OperatorConfig, rawConfig json.RawMessage, secret *corev1.Secret, rolling bool) (*Config, Warning, error) { config := RawConfig(make(map[string]any)) if err := json.Unmarshal(rawConfig, &config); err != nil { return nil, nil, fmt.Errorf("couldn't parse config: %w", err) @@ -175,20 +176,24 @@ func NewConfig(nn types.NamespacedName, uid types.UID, currentState *SpiceDBMigr // if there's a required edge from the current image, that edge is taken // unless the current config is equal to the input. image := imageKey.pop(config) - image, imgWarnings := validateImage(image, globalConfig) - if len(migrationConfig.TargetMigration) == 0 && len(migrationConfig.TargetPhase) == 0 { - var err error - migrationConfig.TargetSpiceDBImage, migrationConfig.TargetMigration, migrationConfig.TargetPhase, err = computeTargets(image, datastoreEngine, currentState, globalConfig, rolling) - if err != nil { - imgWarnings = append(imgWarnings, err) - } - } else { - migrationConfig.TargetSpiceDBImage = image + baseImage, targetSpiceDBVersion, state, err := computeTargets(image, version, channel, datastoreEngine, currentVersion, globalConfig, rolling) + if err != nil { + errs = append(errs, err) } - if !globalConfig.DisableImageValidation { - warnings = append(warnings, imgWarnings...) + migrationConfig.SpiceDBVersion = targetSpiceDBVersion + migrationConfig.TargetMigration = state.Migration + migrationConfig.TargetPhase = state.Phase + if len(state.Digest) > 0 { + migrationConfig.TargetSpiceDBImage = baseImage + "@" + state.Digest + } else if len(state.Tag) > 0 { + migrationConfig.TargetSpiceDBImage = baseImage + ":" + state.Tag + } else { + errs = append(errs, fmt.Errorf("no update found in channel")) + } + if len(migrationConfig.TargetMigration) == 0 { + migrationConfig.TargetMigration = "head" } migrationConfig.DatastoreEngine = datastoreEngine @@ -322,108 +327,99 @@ func NewConfig(nn types.NamespacedName, uid types.UID, currentState *SpiceDBMigr }, warning, nil } -func validateImage(image string, globalConfig *OperatorConfig) (string, []error) { - if len(image) == 0 && len(globalConfig.ImageName) == 0 { - return "", []error{fmt.Errorf("no defualt image configured for operator and no image provided in spec")} +func computeTargets(image, version, channel, engine string, currentVersion *v1alpha1.SpiceDBVersion, globalConfig *OperatorConfig, rolling bool) (baseImage string, targetSpiceDBVersion *v1alpha1.SpiceDBVersion, state updates.State, err error) { + baseImage, tag, digest := ExplodeImage(image) + + // if digest or tag are set, we don't use an update graph + if len(digest) > 0 || len(tag) > 0 { + state = updates.State{Tag: tag, Digest: digest} + return } - if len(image) == 0 { - return globalConfig.DefaultImage(), nil + + // use the default base image from the global config if none is set + if len(baseImage) == 0 { + baseImage = globalConfig.ImageName } - warnings := make([]error, 0) + // error if we can't figure out a base image + if len(baseImage) == 0 { + err = fmt.Errorf("no base image in operator config, and none specified in image") + return + } - baseImage, tag, digest := ExplodeImage(image) - if !slices.Contains(globalConfig.AllowedImages, baseImage) { - warnings = append(warnings, fmt.Errorf("%q invalid: %q is not in the configured list of allowed images", image, baseImage)) - } - - // check tag - if len(tag) > 0 { - allowedTag := false - for _, t := range globalConfig.AllowedTags { - tagInList, _, _ := strings.Cut(t, "@") - if tagInList == tag { - allowedTag = true - break - } - } - if !allowedTag { - warnings = append(warnings, fmt.Errorf("%q invalid: %q is not in the configured list of allowed tags", image, tag)) + // default to spec.channel, or if undefined, status.version.channel + var updateSource updates.Source + if len(channel) == 0 && currentVersion != nil { + channel = currentVersion.Channel + } + // if there's no spec.channel and no status.version.channel, pick a default + // based on the configured datastore + if len(channel) == 0 { + channel, err = globalConfig.ChannelForDatastore(engine) + if err != nil { + err = fmt.Errorf("couldn't find channel for datastore %q: %w", engine, err) + return } } - // check digest - if len(digest) > 0 { - allowedDigest := false - for _, t := range globalConfig.AllowedTags { - // plain digest - if strings.HasPrefix(t, "sha") && t == digest { - allowedDigest = true - break - } - - // compound tag@digest - _, digestInList, _ := strings.Cut(t, "@") - if digestInList == digest { - allowedDigest = true - break - } - } - if !allowedDigest { - warnings = append(warnings, fmt.Errorf("%q invalid: %q is not in the configured list of allowed digests", image, digest)) + if len(channel) > 0 { + updateSource, err = globalConfig.SourceForChannel(channel) + if err != nil { + err = fmt.Errorf("error fetching update source: %w", err) + return } } - return image, warnings -} + // default to the version we're working toward + targetSpiceDBVersion = currentVersion -func computeTargets(image, engine string, currentState *SpiceDBMigrationState, globalConfig *OperatorConfig, rolling bool) (targetImage, targetMigration, targetPhase string, err error) { - specBaseImage, tag, _ := ExplodeImage(image) - targetImage = image - targetMigration = "head" + var currentState updates.State + if currentVersion != nil { + currentState = updateSource.State(currentVersion.Name) + } - // if already migrating or rolling, use current state + // if cluster is rolling, return the current state as reported by the status + // and update graph + // TODO: this can change if the update graph is modified - do we want to actually return status.image/etc? if rolling { - targetImage = specBaseImage + ":" + currentState.Tag - // if the migration is set, use that - if len(currentState.Migration) > 0 { - targetMigration = currentState.Migration + if len(currentState.ID) == 0 { + err = fmt.Errorf("cluster is rolling out, but no current state is defined") + return } - targetPhase = currentState.Phase + state = currentState return } - // look up the actual head migration name, if any - if currentState != nil && (currentState.Migration == "" || currentState.Migration == "head") { - headMigrationName, ok := globalConfig.HeadMigrations[SpiceDBDatastoreState{Tag: tag, Datastore: engine}.String()] - if !ok { - headMigrationName = "head" + // if spec.version is set, we only use the subset of the update graph that + // leads to that version + if len(version) > 0 { + updateSource, err = updateSource.Source(version) + if err != nil { + err = fmt.Errorf("error finding update path from %s to %s", currentVersion.Name, version) + return } - currentState.Migration = headMigrationName } - // if there's a required edge, take it - if globalConfig.RequiredEdges == nil || globalConfig.Nodes == nil { - return - } - - requiredEdge, ok := globalConfig.RequiredEdges[currentState.String()] - if !ok { - return - } - requiredNode, ok := globalConfig.Nodes[requiredEdge] - if !ok { - err = fmt.Errorf("required edge defined but not found: %s -> %s", currentState.String(), requiredEdge) - return + var targetVersion string + if currentVersion != nil && len(currentVersion.Name) > 0 { + targetVersion = updateSource.Next(currentVersion.Name) + if len(targetVersion) == 0 { + // no next version, use the current state + state = currentState + targetSpiceDBVersion = currentVersion + return + } + } else { + // no current version, install head + targetVersion = updateSource.Latest("") } - targetImage = specBaseImage + ":" + requiredNode.Tag - targetMigration = requiredNode.Migration - targetPhase = requiredNode.Phase - if targetMigration == "" { - targetMigration = "head" + // if we found the next step to take, return it + state = updateSource.State(targetVersion) + targetSpiceDBVersion = &v1alpha1.SpiceDBVersion{ + Name: state.ID, + Channel: channel, } - return } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 727f3ba8..72db426b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -9,6 +9,9 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/errors" + + "github.com/authzed/spicedb-operator/pkg/apis/authzed/v1alpha1" + "github.com/authzed/spicedb-operator/pkg/updates" ) func TestToEnvVarName(t *testing.T) { @@ -34,13 +37,14 @@ func TestToEnvVarName(t *testing.T) { func TestNewConfig(t *testing.T) { type args struct { - nn types.NamespacedName - uid types.UID - currentState *SpiceDBMigrationState - globalConfig OperatorConfig - rawConfig json.RawMessage - secret *corev1.Secret - rolling bool + nn types.NamespacedName + uid types.UID + version, channel string + currentVersion *v1alpha1.SpiceDBVersion + globalConfig OperatorConfig + rawConfig json.RawMessage + secret *corev1.Secret + rolling bool } tests := []struct { name string @@ -55,8 +59,7 @@ func TestNewConfig(t *testing.T) { nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", }, rawConfig: json.RawMessage(` { @@ -66,6 +69,8 @@ func TestNewConfig(t *testing.T) { }, wantErrs: []error{ fmt.Errorf("datastoreEngine is a required field"), + fmt.Errorf("couldn't find channel for datastore \"\": %w", fmt.Errorf("no channel found for datastore \"\"")), + fmt.Errorf("no update found in channel"), fmt.Errorf("secret must be provided"), }, wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, @@ -76,8 +81,19 @@ func TestNewConfig(t *testing.T) { nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { @@ -95,10 +111,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -123,8 +143,19 @@ func TestNewConfig(t *testing.T) { nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "memory", + Metadata: map[string]string{"datastore": "memory"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { @@ -141,10 +172,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "memory", DatastoreURI: "", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "memory", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -164,18 +199,17 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set supported image", + name: "set image with tag explicitly", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image", "image2"}, + ImageName: "image", }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "image": "image2" + "image": "adifferentimage:tag" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -189,7 +223,7 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image2", + TargetSpiceDBImage: "adifferentimage:tag", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -212,19 +246,17 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set supported tag", + name: "set image with digest explicitly", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image", "other"}, - AllowedTags: []string{"tag", "tag2", "tag3@sha256:abc", "sha256:abcd"}, + ImageName: "image", }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "image": "other:tag" + "image": "adifferentimage@sha256:abc" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -238,7 +270,7 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "other:tag", + TargetSpiceDBImage: "adifferentimage@sha256:abc", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", @@ -261,19 +293,29 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set supported digest", + name: "set replicas as int", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image", "other"}, - AllowedTags: []string{"tag", "tag2", "tag3@sha256:abc", "sha256:abcd"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "image": "other@sha256:abc" + "replicas": 3 } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -287,10 +329,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "other@sha256:abc", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -298,7 +344,7 @@ func TestNewConfig(t *testing.T) { Name: "test", Namespace: "test", UID: "1", - Replicas: 2, + Replicas: 3, PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", @@ -310,19 +356,29 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set supported tagless digest", + name: "set replicas as string", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image", "other"}, - AllowedTags: []string{"tag", "tag2", "tag3@sha256:abc", "sha256:abcd"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "image": "other@sha256:abcd" + "replicas": "3" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -336,70 +392,22 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "other@sha256:abcd", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", - }, - SpiceConfig: SpiceConfig{ - LogLevel: "info", - SkipMigrations: false, - Name: "test", - Namespace: "test", - UID: "1", - Replicas: 2, - PresharedKey: "psk", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", }, }, - }, - }, - { - name: "set an unsupported image", - args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), - globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, - AllowedTags: []string{"tag"}, - }, - rawConfig: json.RawMessage(` - { - "datastoreEngine": "cockroachdb", - "image": "otherImage:tag" - } - `), - secret: &corev1.Secret{Data: map[string][]byte{ - "datastore_uri": []byte("uri"), - "preshared_key": []byte("psk"), - }}, - }, - wantWarnings: []error{ - fmt.Errorf(`"otherImage:tag" invalid: "otherImage" is not in the configured list of allowed images`), - fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\""), - }, - want: &Config{ - MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "otherImage:tag", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", - }, SpiceConfig: SpiceConfig{ LogLevel: "info", SkipMigrations: false, Name: "test", Namespace: "test", UID: "1", - Replicas: 2, + Replicas: 3, PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", @@ -411,71 +419,29 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set an unsupported tag", + name: "set extra labels as string", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, - AllowedTags: []string{"taggood", "taggood@sha256:abcd"}, - }, - rawConfig: json.RawMessage(` - { - "datastoreEngine": "cockroachdb", - "image": "image:tagbad" - } - `), - secret: &corev1.Secret{Data: map[string][]byte{ - "datastore_uri": []byte("uri"), - "preshared_key": []byte("psk"), - }}, - }, - wantWarnings: []error{ - fmt.Errorf(`"image:tagbad" invalid: "tagbad" is not in the configured list of allowed tags`), - fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\""), - }, - want: &Config{ - MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "image:tagbad", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", - }, - SpiceConfig: SpiceConfig{ - LogLevel: "info", - SkipMigrations: false, - Name: "test", - Namespace: "test", - UID: "1", - Replicas: 2, - PresharedKey: "psk", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, }, }, - }, - }, - { - name: "set an unsupported digest", - args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), - globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, - AllowedTags: []string{"taggood", "taggood@sha256:abcd"}, - }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "image": "image@sha256:1234" + "extraPodLabels": "test=label,other=label" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -483,72 +449,21 @@ func TestNewConfig(t *testing.T) { "preshared_key": []byte("psk"), }}, }, - wantWarnings: []error{ - fmt.Errorf(`"image@sha256:1234" invalid: "sha256:1234" is not in the configured list of allowed digests`), - fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\""), - }, + wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, want: &Config{ MigrationConfig: MigrationConfig{ MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image@sha256:1234", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", - }, - SpiceConfig: SpiceConfig{ - LogLevel: "info", - SkipMigrations: false, - Name: "test", - Namespace: "test", - UID: "1", - Replicas: 2, - PresharedKey: "psk", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", }, }, - }, - }, - { - name: "set an unsupported image with validation disabled", - args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), - globalConfig: OperatorConfig{ - DisableImageValidation: true, - ImageName: "image", - AllowedImages: []string{"image"}, - AllowedTags: []string{"tag"}, - }, - rawConfig: json.RawMessage(` - { - "datastoreEngine": "cockroachdb", - "image": "otherImage:otherTag" - } - `), - secret: &corev1.Secret{Data: map[string][]byte{ - "datastore_uri": []byte("uri"), - "preshared_key": []byte("psk"), - }}, - }, - wantWarnings: []error{ - fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\""), - }, - want: &Config{ - MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "otherImage:otherTag", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", - }, SpiceConfig: SpiceConfig{ LogLevel: "info", SkipMigrations: false, @@ -559,54 +474,10 @@ func TestNewConfig(t *testing.T) { PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", - Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + ExtraPodLabels: map[string]string{ + "test": "label", + "other": "label", }, - }, - }, - }, - { - name: "set replicas as int", - args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), - globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, - }, - rawConfig: json.RawMessage(` - { - "datastoreEngine": "cockroachdb", - "replicas": 3 - } - `), - secret: &corev1.Secret{Data: map[string][]byte{ - "datastore_uri": []byte("uri"), - "preshared_key": []byte("psk"), - }}, - }, - wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, - want: &Config{ - MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "image", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", - }, - SpiceConfig: SpiceConfig{ - LogLevel: "info", - SkipMigrations: false, - Name: "test", - Namespace: "test", - UID: "1", - Replicas: 3, - PresharedKey: "psk", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", Passthrough: map[string]string{ "datastoreEngine": "cockroachdb", "dispatchClusterEnabled": "true", @@ -615,18 +486,32 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set replicas as string", + name: "set extra labels as map", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "replicas": "3" + "extraPodLabels": { + "test": "label", + "other": "label" + } } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -640,10 +525,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -651,10 +540,14 @@ func TestNewConfig(t *testing.T) { Name: "test", Namespace: "test", UID: "1", - Replicas: 3, + Replicas: 2, PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", + ExtraPodLabels: map[string]string{ + "test": "label", + "other": "label", + }, Passthrough: map[string]string{ "datastoreEngine": "cockroachdb", "dispatchClusterEnabled": "true", @@ -663,18 +556,29 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set extra labels as string", + name: "skip migrations bool", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "extraPodLabels": "test=label,other=label" + "skipMigrations": true } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -685,17 +589,23 @@ func TestNewConfig(t *testing.T) { wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, want: &Config{ MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "image", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", + MigrationLogLevel: "debug", + DatastoreEngine: "cockroachdb", + DatastoreURI: "uri", + SpannerCredsSecretRef: "", + TargetSpiceDBImage: "image:v1", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + DatastoreTLSSecretName: "", + TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", - SkipMigrations: false, + SkipMigrations: true, Name: "test", Namespace: "test", UID: "1", @@ -703,10 +613,6 @@ func TestNewConfig(t *testing.T) { PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", - ExtraPodLabels: map[string]string{ - "test": "label", - "other": "label", - }, Passthrough: map[string]string{ "datastoreEngine": "cockroachdb", "dispatchClusterEnabled": "true", @@ -715,21 +621,29 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "set extra labels as map", + name: "skip migrations string", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "datastoreEngine": "cockroachdb", - "extraPodLabels": { - "test": "label", - "other": "label" - } + "skipMigrations": "true" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -740,17 +654,23 @@ func TestNewConfig(t *testing.T) { wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, want: &Config{ MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", - DatastoreEngine: "cockroachdb", - DatastoreURI: "uri", - TargetSpiceDBImage: "image", - EnvPrefix: "SPICEDB", - SpiceDBCmd: "spicedb", - TargetMigration: "head", + MigrationLogLevel: "debug", + DatastoreEngine: "cockroachdb", + DatastoreURI: "uri", + SpannerCredsSecretRef: "", + TargetSpiceDBImage: "image:v1", + EnvPrefix: "SPICEDB", + SpiceDBCmd: "spicedb", + DatastoreTLSSecretName: "", + TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", - SkipMigrations: false, + SkipMigrations: true, Name: "test", Namespace: "test", UID: "1", @@ -758,10 +678,6 @@ func TestNewConfig(t *testing.T) { PresharedKey: "psk", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", - ExtraPodLabels: map[string]string{ - "test": "label", - "other": "label", - }, Passthrough: map[string]string{ "datastoreEngine": "cockroachdb", "dispatchClusterEnabled": "true", @@ -775,8 +691,19 @@ func TestNewConfig(t *testing.T) { nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { @@ -795,10 +722,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -827,8 +758,19 @@ func TestNewConfig(t *testing.T) { nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { @@ -850,10 +792,14 @@ func TestNewConfig(t *testing.T) { MigrationLogLevel: "debug", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "info", @@ -877,18 +823,31 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "skip migrations bool", + name: "set different migration and spicedb log level", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { + "logLevel": "debug", + "migrationLogLevel": "info", "datastoreEngine": "cockroachdb", - "skipMigrations": true + "skipMigrations": "true" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -899,18 +858,22 @@ func TestNewConfig(t *testing.T) { wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, want: &Config{ MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", + MigrationLogLevel: "info", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", SpannerCredsSecretRef: "", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v1", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", TargetMigration: "head", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ - LogLevel: "info", + LogLevel: "debug", SkipMigrations: true, Name: "test", Namespace: "test", @@ -927,18 +890,31 @@ func TestNewConfig(t *testing.T) { }, }, { - name: "skip migrations string", + name: "update graph pushes the current version forward", args: args{ nn: types.NamespacedName{Namespace: "test", Name: "test"}, uid: types.UID("1"), globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v2", Tag: "v2", Migration: "migration1", Phase: "phase1"}, + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {"v2"}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { - "datastoreEngine": "cockroachdb", - "skipMigrations": "true" + "logLevel": "debug", + "migrationLogLevel": "info", + "datastoreEngine": "cockroachdb" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -949,19 +925,24 @@ func TestNewConfig(t *testing.T) { wantWarnings: []error{fmt.Errorf("no TLS configured, consider setting \"tlsSecretName\"")}, want: &Config{ MigrationConfig: MigrationConfig{ - MigrationLogLevel: "debug", + MigrationLogLevel: "info", DatastoreEngine: "cockroachdb", DatastoreURI: "uri", SpannerCredsSecretRef: "", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v2", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", - TargetMigration: "head", + TargetMigration: "migration1", + TargetPhase: "phase1", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v2", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ - LogLevel: "info", - SkipMigrations: true, + LogLevel: "debug", + SkipMigrations: false, Name: "test", Namespace: "test", UID: "1", @@ -970,27 +951,41 @@ func TestNewConfig(t *testing.T) { EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + "datastoreEngine": "cockroachdb", + "datastoreMigrationPhase": "phase1", + "dispatchClusterEnabled": "true", }, }, }, }, { - name: "set different migration and spicedb log level", + name: "explicit channel and version, updates to the next in the channel", args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), + nn: types.NamespacedName{Namespace: "test", Name: "test"}, + uid: types.UID("1"), + channel: "cockroachdb", + version: "v2", globalConfig: OperatorConfig{ - ImageName: "image", - AllowedImages: []string{"image"}, + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v2", Tag: "v2", Migration: "migration1", Phase: "phase1"}, + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {"v2"}}, + }, + }, + }, }, rawConfig: json.RawMessage(` { "logLevel": "debug", "migrationLogLevel": "info", - "datastoreEngine": "cockroachdb", - "skipMigrations": "true" + "datastoreEngine": "cockroachdb" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -1005,15 +1000,20 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", SpannerCredsSecretRef: "", - TargetSpiceDBImage: "image", + TargetSpiceDBImage: "image:v2", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", - TargetMigration: "head", + TargetMigration: "migration1", + TargetPhase: "phase1", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v2", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "debug", - SkipMigrations: true, + SkipMigrations: false, Name: "test", Namespace: "test", UID: "1", @@ -1022,41 +1022,46 @@ func TestNewConfig(t *testing.T) { EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", Passthrough: map[string]string{ - "datastoreEngine": "cockroachdb", - "dispatchClusterEnabled": "true", + "datastoreEngine": "cockroachdb", + "datastoreMigrationPhase": "phase1", + "dispatchClusterEnabled": "true", }, }, }, }, { - name: "required edge different from input image", + name: "explicit channel and version, doesn't update past the explicit version", args: args{ - nn: types.NamespacedName{Namespace: "test", Name: "test"}, - uid: types.UID("1"), + nn: types.NamespacedName{Namespace: "test", Name: "test"}, + uid: types.UID("1"), + channel: "cockroachdb", + version: "v2", globalConfig: OperatorConfig{ - ImageName: "image", - ImageTag: "init", - AllowedImages: []string{"image"}, - AllowedTags: []string{"init", "tag", "tag2"}, - UpdateGraph: NewUpdateGraph().AddEdge(SpiceDBMigrationState{ - Tag: "init", - Migration: "head", - }, SpiceDBMigrationState{ - Tag: "tag", - Migration: "migration", - Phase: "phase", - }), + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v3", Tag: "v3", Migration: "migration2", Phase: "phase2"}, + {ID: "v2", Tag: "v2", Migration: "migration1", Phase: "phase1"}, + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {"v2", "v3"}, "v2": {"v3"}}, + }, + }, + }, }, - currentState: &SpiceDBMigrationState{ - Tag: "init", + currentVersion: &v1alpha1.SpiceDBVersion{ + Name: "v2", + Channel: "cockroachdb", }, rawConfig: json.RawMessage(` { "logLevel": "debug", - "image": "image:init", "migrationLogLevel": "info", - "datastoreEngine": "cockroachdb", - "skipMigrations": "true" + "datastoreEngine": "cockroachdb" } `), secret: &corev1.Secret{Data: map[string][]byte{ @@ -1071,16 +1076,20 @@ func TestNewConfig(t *testing.T) { DatastoreEngine: "cockroachdb", DatastoreURI: "uri", SpannerCredsSecretRef: "", - TargetSpiceDBImage: "image:tag", + TargetSpiceDBImage: "image:v2", EnvPrefix: "SPICEDB", SpiceDBCmd: "spicedb", DatastoreTLSSecretName: "", - TargetMigration: "migration", - TargetPhase: "phase", + TargetMigration: "migration1", + TargetPhase: "phase1", + SpiceDBVersion: &v1alpha1.SpiceDBVersion{ + Name: "v2", + Channel: "cockroachdb", + }, }, SpiceConfig: SpiceConfig{ LogLevel: "debug", - SkipMigrations: true, + SkipMigrations: false, Name: "test", Namespace: "test", UID: "1", @@ -1090,7 +1099,7 @@ func TestNewConfig(t *testing.T) { SpiceDBCmd: "spicedb", Passthrough: map[string]string{ "datastoreEngine": "cockroachdb", - "datastoreMigrationPhase": "phase", + "datastoreMigrationPhase": "phase1", "dispatchClusterEnabled": "true", }, }, @@ -1100,10 +1109,11 @@ func TestNewConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { global := tt.args.globalConfig.Copy() - got, gotWarning, err := NewConfig(tt.args.nn, tt.args.uid, tt.args.currentState, &global, tt.args.rawConfig, tt.args.secret, tt.args.rolling) - require.Equal(t, tt.want, got) - require.EqualValues(t, errors.NewAggregate(tt.wantWarnings), gotWarning) + + got, gotWarning, err := NewConfig(tt.args.nn, tt.args.uid, tt.args.version, tt.args.channel, tt.args.currentVersion, &global, tt.args.rawConfig, tt.args.secret, tt.args.rolling) require.EqualValues(t, errors.NewAggregate(tt.wantErrs), err) + require.EqualValues(t, errors.NewAggregate(tt.wantWarnings), gotWarning) + require.Equal(t, tt.want, got) }) } } diff --git a/pkg/config/global.go b/pkg/config/global.go index d21c5635..5027f1fd 100644 --- a/pkg/config/global.go +++ b/pkg/config/global.go @@ -3,11 +3,10 @@ package config import ( "encoding/json" "fmt" - "strings" "github.com/cespare/xxhash/v2" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" + + "github.com/authzed/spicedb-operator/pkg/updates" ) type SpiceDBMigrationState struct { @@ -39,95 +38,19 @@ func (s SpiceDBDatastoreState) String() string { // OperatorConfig holds operator-wide config that is used across all objects type OperatorConfig struct { - DisableImageValidation bool `json:"disableImageValidation"` - ImageName string `json:"imageName,omitempty"` - ImageTag string `json:"imageTag,omitempty"` - ImageDigest string `json:"imageDigest,omitempty"` - AllowedTags []string `json:"allowedTags,omitempty"` - AllowedImages []string `json:"allowedImages,omitempty"` - UpdateGraph + ImageName string `json:"imageName,omitempty"` + updates.UpdateGraph } func NewOperatorConfig() OperatorConfig { return OperatorConfig{ - AllowedTags: make([]string, 0), - AllowedImages: make([]string, 0), - UpdateGraph: NewUpdateGraph(), - } -} - -func (o OperatorConfig) DefaultImage() string { - if len(o.ImageDigest) > 0 { - return strings.Join([]string{o.ImageName, o.ImageDigest}, "@") - } - if len(o.ImageTag) > 0 { - return strings.Join([]string{o.ImageName, o.ImageTag}, ":") + UpdateGraph: updates.UpdateGraph{}, } - return o.ImageName } func (o OperatorConfig) Copy() OperatorConfig { return OperatorConfig{ - DisableImageValidation: o.DisableImageValidation, - ImageName: o.ImageName, - ImageTag: o.ImageTag, - ImageDigest: o.ImageDigest, - AllowedTags: slices.Clone(o.AllowedTags), - AllowedImages: slices.Clone(o.AllowedImages), - UpdateGraph: o.UpdateGraph.Copy(), - } -} - -// UpdateGraph holds a graph of required update edges -type UpdateGraph struct { - HeadMigrations map[string]string `json:"headMigrations,omitempty"` - RequiredEdges map[string]string `json:"requiredEdges,omitempty"` - Nodes map[string]SpiceDBMigrationState `json:"nodes,omitempty"` -} - -func NewUpdateGraph() UpdateGraph { - return UpdateGraph{ - HeadMigrations: make(map[string]string, 0), - RequiredEdges: make(map[string]string, 0), - Nodes: make(map[string]SpiceDBMigrationState, 0), - } -} - -func (o UpdateGraph) GetHeadMigration(state SpiceDBDatastoreState) string { - if m, ok := o.HeadMigrations[state.String()]; ok { - return m - } - return "head" -} - -func (o UpdateGraph) GetRequiredEdge(state SpiceDBMigrationState) (*SpiceDBMigrationState, error) { - requiredEdge, ok := o.RequiredEdges[state.String()] - if !ok { - return nil, nil - } - requiredNode, ok := o.Nodes[requiredEdge] - if !ok { - return nil, fmt.Errorf("required edge defined but not found: %s -> %s", state.String(), requiredEdge) - } - return &requiredNode, nil -} - -func (o UpdateGraph) AddHeadMigration(state SpiceDBDatastoreState, migrationName string) UpdateGraph { - o.HeadMigrations[state.String()] = migrationName - return o -} - -func (o UpdateGraph) AddEdge(from, to SpiceDBMigrationState) UpdateGraph { - o.Nodes[from.String()] = from - o.Nodes[to.String()] = to - o.RequiredEdges[from.String()] = to.String() - return o -} - -func (o UpdateGraph) Copy() UpdateGraph { - return UpdateGraph{ - HeadMigrations: maps.Clone(o.HeadMigrations), - RequiredEdges: maps.Clone(o.RequiredEdges), - Nodes: maps.Clone(o.Nodes), + ImageName: o.ImageName, + UpdateGraph: o.UpdateGraph.Copy(), } } diff --git a/pkg/controller/validate_config.go b/pkg/controller/validate_config.go index 131808c5..cf62dc92 100644 --- a/pkg/controller/validate_config.go +++ b/pkg/controller/validate_config.go @@ -33,17 +33,14 @@ func (c *ValidateConfigHandler) Handle(ctx context.Context) { } secret := CtxSecret.Value(ctx) operatorConfig := CtxOperatorConfig.MustValue(ctx) + status := CtxClusterStatus.MustValue(ctx).Status - _, statusTag, _ := config.ExplodeImage(status.Image) - currentState := &config.SpiceDBMigrationState{ - Tag: statusTag, - Phase: status.Phase, - Migration: status.Migration, - } + rolloutInProgress := currentStatus.IsStatusConditionTrue(v1alpha1.ConditionTypeMigrating) || currentStatus.IsStatusConditionTrue(v1alpha1.ConditionTypeRolling) || currentStatus.Status.CurrentMigrationHash != currentStatus.Status.TargetMigrationHash - validatedConfig, warning, err := config.NewConfig(nn, cluster.UID, currentState, operatorConfig, rawConfig, secret, rolloutInProgress) + + validatedConfig, warning, err := config.NewConfig(nn, cluster.UID, cluster.Spec.Version, cluster.Spec.Channel, status.CurrentVersion, operatorConfig, rawConfig, secret, rolloutInProgress) if err != nil { failedCondition := v1alpha1.NewInvalidConfigCondition(CtxSecretHash.Value(ctx), err) if existing := currentStatus.FindStatusCondition(v1alpha1.ConditionValidatingFailed); existing != nil && existing.Message == failedCondition.Message { @@ -81,8 +78,11 @@ func (c *ValidateConfigHandler) Handle(ctx context.Context) { currentStatus.IsStatusConditionTrue(v1alpha1.ConditionTypeValidating) || currentStatus.Status.Image != validatedConfig.TargetSpiceDBImage || currentStatus.Status.TargetMigrationHash != migrationHash || - currentStatus.IsStatusConditionChanged(v1alpha1.ConditionTypeConfigWarnings, warningCondition) { + currentStatus.IsStatusConditionChanged(v1alpha1.ConditionTypeConfigWarnings, warningCondition) || + // TODO: this should deref and check the values if they're not nil + currentStatus.Status.CurrentVersion != validatedConfig.SpiceDBVersion { currentStatus.RemoveStatusCondition(v1alpha1.ConditionValidatingFailed) + currentStatus.Status.CurrentVersion = validatedConfig.SpiceDBVersion currentStatus.Status.Image = validatedConfig.TargetSpiceDBImage currentStatus.Status.TargetMigrationHash = migrationHash currentStatus.Status.ObservedGeneration = currentStatus.GetGeneration() diff --git a/pkg/controller/validate_config_test.go b/pkg/controller/validate_config_test.go index d7aa158c..da0a9943 100644 --- a/pkg/controller/validate_config_test.go +++ b/pkg/controller/validate_config_test.go @@ -16,6 +16,7 @@ import ( "github.com/authzed/spicedb-operator/pkg/apis/authzed/v1alpha1" "github.com/authzed/spicedb-operator/pkg/config" + "github.com/authzed/spicedb-operator/pkg/updates" ) func TestValidateConfigHandler(t *testing.T) { @@ -38,9 +39,13 @@ func TestValidateConfigHandler(t *testing.T) { { name: "valid config, no changes, no warnings", currentStatus: &v1alpha1.SpiceDBCluster{Status: v1alpha1.ClusterStatus{ - Image: "image:tag", - TargetMigrationHash: "n5dbh8fh58dh5b7h79h599h64bh5dbq", - CurrentMigrationHash: "n5dbh8fh58dh5b7h79h599h64bh5dbq", + Image: "image:v1", + TargetMigrationHash: "ndchdch68dh69h566h56fhb9h5dq", + CurrentMigrationHash: "ndchdch68dh69h566h56fhb9h5dq", + CurrentVersion: &v1alpha1.SpiceDBVersion{ + Name: "v1", + Channel: "cockroachdb", + }, }}, rawConfig: json.RawMessage(`{ "datastoreEngine": "cockroachdb", @@ -53,16 +58,17 @@ func TestValidateConfigHandler(t *testing.T) { }, }, expectPatchStatus: false, - expectStatusImage: "image:tag", + expectStatusImage: "image:v1", expectNext: nextKey, }, { name: "valid config, new target migrationhash", currentStatus: &v1alpha1.SpiceDBCluster{Status: v1alpha1.ClusterStatus{ - Image: "image:tag", + Image: "image:v1", CurrentMigrationHash: "old", }}, rawConfig: json.RawMessage(`{ + "image": "image:v1", "datastoreEngine": "cockroachdb", "tlsSecretName": "secret" }`), @@ -73,7 +79,7 @@ func TestValidateConfigHandler(t *testing.T) { }, }, expectPatchStatus: true, - expectStatusImage: "image:tag", + expectStatusImage: "image:v1", expectNext: nextKey, }, { @@ -92,26 +98,9 @@ func TestValidateConfigHandler(t *testing.T) { }, expectPatchStatus: true, expectConditions: []string{"ConfigurationWarning"}, - expectStatusImage: "image:tag", + expectStatusImage: "image:v1", expectNext: nextKey, }, - { - name: "invalid config", - currentStatus: &v1alpha1.SpiceDBCluster{}, - rawConfig: json.RawMessage(`{ - "badkey": "cockroachdb" - }`), - existingSecret: &corev1.Secret{ - Data: map[string][]byte{ - "datastore_uri": []byte("uri"), - "preshared_key": []byte("testtest"), - }, - }, - expectConditions: []string{"ValidatingFailed"}, - expectEvents: []string{"Warning InvalidSpiceDBConfig invalid config: datastoreEngine is a required field"}, - expectPatchStatus: true, - expectDone: true, - }, { name: "invalid config, missing secret", currentStatus: &v1alpha1.SpiceDBCluster{}, @@ -129,7 +118,7 @@ func TestValidateConfigHandler(t *testing.T) { rawConfig: json.RawMessage(`{ "nope": "cockroachdb" }`), - expectEvents: []string{"Warning InvalidSpiceDBConfig invalid config: [datastoreEngine is a required field, secret must be provided]"}, + expectEvents: []string{"Warning InvalidSpiceDBConfig invalid config: [datastoreEngine is a required field, couldn't find channel for datastore \"\": no channel found for datastore \"\", no update found in channel, secret must be provided]"}, expectConditions: []string{"ValidatingFailed"}, expectPatchStatus: true, expectDone: true, @@ -152,7 +141,7 @@ func TestValidateConfigHandler(t *testing.T) { }, }, expectPatchStatus: true, - expectStatusImage: "image:tag", + expectStatusImage: "image:v1", expectNext: nextKey, }, { @@ -175,7 +164,7 @@ func TestValidateConfigHandler(t *testing.T) { }, }, expectPatchStatus: true, - expectStatusImage: "image:tag", + expectStatusImage: "image:v1", expectNext: nextKey, }, { @@ -183,7 +172,7 @@ func TestValidateConfigHandler(t *testing.T) { currentStatus: &v1alpha1.SpiceDBCluster{Status: v1alpha1.ClusterStatus{Conditions: []metav1.Condition{{ Type: "ValidatingFailed", Status: metav1.ConditionTrue, - Message: "Error validating config with secret hash \"\": [datastoreEngine is a required field, secret must be provided]", + Message: "Error validating config with secret hash \"\": [datastoreEngine is a required field, couldn't find channel for datastore \"\": no channel found for datastore \"\", no update found in channel, secret must be provided]", }}}}, rawConfig: json.RawMessage(`{ "nope": "cockroachdb" @@ -220,7 +209,21 @@ func TestValidateConfigHandler(t *testing.T) { ctx = CtxClusterNN.WithValue(ctx, types.NamespacedName{Namespace: "test", Name: "test"}) ctx = CtxClusterStatus.WithValue(ctx, tt.currentStatus) ctx = CtxCluster.WithValue(ctx, &v1alpha1.SpiceDBCluster{Spec: v1alpha1.ClusterSpec{Config: tt.rawConfig}}) - ctx = CtxOperatorConfig.WithValue(ctx, &config.OperatorConfig{ImageName: "image", ImageTag: "tag"}) + ctx = CtxOperatorConfig.WithValue(ctx, &config.OperatorConfig{ + ImageName: "image", + UpdateGraph: updates.UpdateGraph{ + Channels: []updates.Channel{ + { + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Nodes: []updates.State{ + {ID: "v1", Tag: "v1"}, + }, + Edges: map[string][]string{"v1": {}}, + }, + }, + }, + }) var called handler.Key h := &ValidateConfigHandler{ patchStatus: func(ctx context.Context, patch *v1alpha1.SpiceDBCluster) error { diff --git a/pkg/crds/authzed.com_spicedbclusters.yaml b/pkg/crds/authzed.com_spicedbclusters.yaml index 915611a1..4b20d65e 100644 --- a/pkg/crds/authzed.com_spicedbclusters.yaml +++ b/pkg/crds/authzed.com_spicedbclusters.yaml @@ -37,6 +37,14 @@ spec: spec: description: ClusterSpec holds the desired state of the cluster. properties: + channel: + description: Channel is the name of a series of updates that operator + should follow. The operator is configured with a datasource that + configures available channels and update paths. If `version` is + not specified, then the operator will keep SpiceDB up-to-date with + the current head of the channel. If `version` is specified, then + the operator will write available updates in the status. + type: string config: description: Config values to be passed to the cluster type: object @@ -46,10 +54,36 @@ spec: that holds secret config for the cluster like passwords, credentials, etc. If the secret is omitted, one will be generated type: string + version: + description: Version is the name of the version of SpiceDB that will + be run. The version is usually a simple version string like `v1.13.0`, + but the operator is configured with a data source that tells it + what versions are allowed, and they may have other names. If omitted, + the newest version in the head of the channel will be used. Note + that the `config.image` field will take precedence over version/channel, + if it is specified + type: string type: object status: description: ClusterStatus communicates the observed state of the cluster. properties: + availableVersions: + description: AvailableVersions is a list of versions that the currently + running version can be updated to. Only applies if using an update + channel. + items: + properties: + channel: + type: string + description: + type: string + name: + type: string + required: + - channel + - name + type: object + type: array conditions: description: Conditions for the current state of the Stack. items: @@ -147,6 +181,20 @@ spec: description: TargetMigrationHash is a hash of the desired migration target and config type: string + version: + description: CurrentVersion is a description of the currently selected + version from the channel, if an update channel is being used. + properties: + channel: + type: string + description: + type: string + name: + type: string + required: + - channel + - name + type: object type: object type: object served: true diff --git a/pkg/updates/file.go b/pkg/updates/file.go index c3091f28..9b1ba538 100644 --- a/pkg/updates/file.go +++ b/pkg/updates/file.go @@ -26,13 +26,13 @@ type UpdateGraph struct { Channels []Channel `json:"channels,omitempty"` } -func (g *UpdateGraph) SourceForDatastore(datastore string) (Source, error) { +func (g *UpdateGraph) ChannelForDatastore(datastore string) (string, error) { for _, c := range g.Channels { if c.Metadata["datastore"] == datastore { - return NewMemorySource(c.Nodes, c.Edges) + return c.Name, nil } } - return nil, fmt.Errorf("no channel found for datastore %q", datastore) + return "", fmt.Errorf("no channel found for datastore %q", datastore) } func (g *UpdateGraph) SourceForChannel(channel string) (Source, error) {