diff --git a/config/crds/authzed.com_spicedbclusters.yaml b/config/crds/authzed.com_spicedbclusters.yaml index 43e0be51..33e5e929 100644 --- a/config/crds/authzed.com_spicedbclusters.yaml +++ b/config/crds/authzed.com_spicedbclusters.yaml @@ -73,11 +73,23 @@ spec: channel. items: properties: + attributes: + description: Attributes is an optional set of descriptors for + the update, which carry additional information like whether + there will be a migration if this version is selected. + items: + type: string + type: array channel: + description: Channel is the name of the channel this version + is in type: string description: + description: Description a human-readable description of the + update. type: string name: + description: Name is the identifier for this version type: string required: - channel @@ -185,11 +197,22 @@ spec: description: CurrentVersion is a description of the currently selected version from the channel, if an update channel is being used. properties: + attributes: + description: Attributes is an optional set of descriptors for + the update, which carry additional information like whether + there will be a migration if this version is selected. + items: + type: string + type: array channel: + description: Channel is the name of the channel this version is + in type: string description: + description: Description a human-readable description of the update. type: string name: + description: Name is the identifier for this version type: string required: - channel diff --git a/pkg/apis/authzed/v1alpha1/types.go b/pkg/apis/authzed/v1alpha1/types.go index 1dfc41e7..a31434e7 100644 --- a/pkg/apis/authzed/v1alpha1/types.go +++ b/pkg/apis/authzed/v1alpha1/types.go @@ -131,7 +131,9 @@ func (s ClusterStatus) Equals(other ClusterStatus) bool { s.Migration == other.Migration && s.Phase == other.Phase && s.CurrentVersion.Equals(other.CurrentVersion) && - slices.Equal(s.AvailableVersions, other.AvailableVersions) && + slices.EqualFunc(s.AvailableVersions, other.AvailableVersions, func(a, b SpiceDBVersion) bool { + return a.Equals(&b) + }) && slices.Equal(s.Conditions, other.Conditions): return true default: @@ -139,9 +141,30 @@ func (s ClusterStatus) Equals(other ClusterStatus) bool { } } +type SpiceDBVersionAttributes string + +var ( + SpiceDBVersionAttributesNext SpiceDBVersionAttributes = "next" + SpiceDBVersionAttributesMigration SpiceDBVersionAttributes = "migration" + SpiceDBVersionAttributesIncompatibleDispatch SpiceDBVersionAttributes = "incompatibleDispatch" + SpiceDBVersionAttributesLatest SpiceDBVersionAttributes = "latest" +) + type SpiceDBVersion struct { - Name string `json:"name"` - Channel string `json:"channel"` + // Name is the identifier for this version + Name string `json:"name"` + + // Channel is the name of the channel this version is in + Channel string `json:"channel"` + + // Attributes is an optional set of descriptors for the update, which + // carry additional information like whether there will be a migration + // if this version is selected. + // +optional + Attributes []SpiceDBVersionAttributes `json:"attributes,omitempty"` + + // Description a human-readable description of the update. + // +optional Description string `json:"description,omitempty"` } @@ -149,7 +172,7 @@ func (v *SpiceDBVersion) Equals(other *SpiceDBVersion) bool { if v == other { return true } - if v != nil && other != nil && v.Name == other.Name && v.Channel == other.Channel { + if v != nil && other != nil && v.Name == other.Name && v.Channel == other.Channel && slices.Equal(v.Attributes, other.Attributes) { return true } return false diff --git a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go index 5c9a74cc..edf44583 100644 --- a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go @@ -37,12 +37,14 @@ func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { if in.CurrentVersion != nil { in, out := &in.CurrentVersion, &out.CurrentVersion *out = new(SpiceDBVersion) - **out = **in + (*in).DeepCopyInto(*out) } if in.AvailableVersions != nil { in, out := &in.AvailableVersions, &out.AvailableVersions *out = make([]SpiceDBVersion, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions @@ -125,6 +127,11 @@ func (in *SpiceDBClusterList) DeepCopyObject() runtime.Object { // 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 + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make([]SpiceDBVersionAttributes, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SpiceDBVersion. diff --git a/pkg/controller/validate_config_test.go b/pkg/controller/validate_config_test.go index f22404c6..85fa51c7 100644 --- a/pkg/controller/validate_config_test.go +++ b/pkg/controller/validate_config_test.go @@ -41,8 +41,8 @@ func TestValidateConfigHandler(t *testing.T) { currentStatus: &v1alpha1.SpiceDBCluster{Status: v1alpha1.ClusterStatus{ Image: "image:v1", Migration: "head", - TargetMigrationHash: "ndchdch68dh69h566h56fhb9h5dq", - CurrentMigrationHash: "ndchdch68dh69h566h56fhb9h5dq", + TargetMigrationHash: "n549hbh555h557h65ch64chc8h6dq", + CurrentMigrationHash: "n549hbh555h557h65ch64chc8h6dq", CurrentVersion: &v1alpha1.SpiceDBVersion{ Name: "v1", Channel: "cockroachdb", diff --git a/pkg/crds/authzed.com_spicedbclusters.yaml b/pkg/crds/authzed.com_spicedbclusters.yaml index 43e0be51..33e5e929 100644 --- a/pkg/crds/authzed.com_spicedbclusters.yaml +++ b/pkg/crds/authzed.com_spicedbclusters.yaml @@ -73,11 +73,23 @@ spec: channel. items: properties: + attributes: + description: Attributes is an optional set of descriptors for + the update, which carry additional information like whether + there will be a migration if this version is selected. + items: + type: string + type: array channel: + description: Channel is the name of the channel this version + is in type: string description: + description: Description a human-readable description of the + update. type: string name: + description: Name is the identifier for this version type: string required: - channel @@ -185,11 +197,22 @@ spec: description: CurrentVersion is a description of the currently selected version from the channel, if an update channel is being used. properties: + attributes: + description: Attributes is an optional set of descriptors for + the update, which carry additional information like whether + there will be a migration if this version is selected. + items: + type: string + type: array channel: + description: Channel is the name of the channel this version is + in type: string description: + description: Description a human-readable description of the update. type: string name: + description: Name is the identifier for this version type: string required: - channel diff --git a/pkg/updates/file.go b/pkg/updates/file.go index 3c983b29..0165cc45 100644 --- a/pkg/updates/file.go +++ b/pkg/updates/file.go @@ -71,7 +71,7 @@ func (g *UpdateGraph) Copy() UpdateGraph { } // AvailableVersions traverses an UpdateGraph and collects a list of the -// safe versions for updating from the provided version. +// safe versions for updating from the provided currentVersion. func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion) ([]v1alpha1.SpiceDBVersion, error) { source, err := g.SourceForChannel(v.Channel) if err != nil { @@ -82,13 +82,16 @@ func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion nextWithoutMigrations := source.NextVersionWithoutMigrations(v.Name) latest := source.LatestVersion(v.Name) if len(nextWithoutMigrations) > 0 { + // TODO: should also account for downtime, i.e. dispatch api changes nextDirectVersion := v1alpha1.SpiceDBVersion{ Name: nextWithoutMigrations, Channel: v.Channel, + Attributes: []v1alpha1.SpiceDBVersionAttributes{v1alpha1.SpiceDBVersionAttributesNext}, Description: "direct update with no migrations", } if nextWithoutMigrations == latest { nextDirectVersion.Description += ", head of channel" + nextDirectVersion.Attributes = append(nextDirectVersion.Attributes, v1alpha1.SpiceDBVersionAttributesLatest) } availableVersions = append(availableVersions, nextDirectVersion) } @@ -98,10 +101,12 @@ func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion nextVersion := v1alpha1.SpiceDBVersion{ Name: next, Channel: v.Channel, + Attributes: []v1alpha1.SpiceDBVersionAttributes{v1alpha1.SpiceDBVersionAttributesNext, v1alpha1.SpiceDBVersionAttributesMigration}, Description: "update will run a migration", } if next == latest { nextVersion.Description += ", head of channel" + nextVersion.Attributes = append(nextVersion.Attributes, v1alpha1.SpiceDBVersionAttributesLatest) } availableVersions = append(availableVersions, nextVersion) } @@ -109,6 +114,7 @@ func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion availableVersions = append(availableVersions, v1alpha1.SpiceDBVersion{ Name: latest, Channel: v.Channel, + Attributes: []v1alpha1.SpiceDBVersionAttributes{v1alpha1.SpiceDBVersionAttributesLatest, v1alpha1.SpiceDBVersionAttributesMigration}, Description: "head of the channel, multiple updates will run in sequence", }) } @@ -130,6 +136,7 @@ func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion availableVersions = append(availableVersions, v1alpha1.SpiceDBVersion{ Name: next, Channel: c.Name, + Attributes: []v1alpha1.SpiceDBVersionAttributes{v1alpha1.SpiceDBVersionAttributesNext}, Description: "direct update with no migrations, different channel", }) continue @@ -138,6 +145,7 @@ func (g *UpdateGraph) AvailableVersions(engine string, v v1alpha1.SpiceDBVersion availableVersions = append(availableVersions, v1alpha1.SpiceDBVersion{ Name: next, Channel: c.Name, + Attributes: []v1alpha1.SpiceDBVersionAttributes{v1alpha1.SpiceDBVersionAttributesNext, v1alpha1.SpiceDBVersionAttributesMigration}, Description: "update will run a migration, different channel", }) } @@ -158,7 +166,7 @@ func explodeImage(image string) (baseImage, tag, digest string) { return } -// ComputeTarget determines the target update version and state given an update +// ComputeTarget determines the target update currentVersion and state given an update // graph and the proper context. func (g *UpdateGraph) ComputeTarget(defaultBaseImage, image, version, channel, engine string, currentVersion *v1alpha1.SpiceDBVersion, rolling bool) (baseImage string, target *v1alpha1.SpiceDBVersion, state State, err error) { baseImage, tag, digest := explodeImage(image) @@ -176,7 +184,7 @@ func (g *UpdateGraph) ComputeTarget(defaultBaseImage, image, version, channel, e return } - // Fallback to the channel from the current version. + // Fallback to the channel from the current currentVersion. if channel == "" && currentVersion != nil { channel = currentVersion.Channel } @@ -199,7 +207,7 @@ func (g *UpdateGraph) ComputeTarget(defaultBaseImage, image, version, channel, e } } - // Default to the version we're working toward. + // Default to the currentVersion we're working toward. target = currentVersion var currentState State @@ -221,8 +229,8 @@ func (g *UpdateGraph) ComputeTarget(defaultBaseImage, image, version, channel, e return } - // If version is set, we only use the subset of the update graph that leads - // to that version. + // If currentVersion is set, we only use the subset of the update graph that leads + // to that currentVersion. if len(version) > 0 { updateSource, err = updateSource.Subgraph(version) if err != nil { @@ -235,13 +243,13 @@ func (g *UpdateGraph) ComputeTarget(defaultBaseImage, image, version, channel, e if currentVersion != nil && len(currentVersion.Name) > 0 { targetVersion = updateSource.NextVersion(currentVersion.Name) if len(targetVersion) == 0 { - // There's no next version, so use the current state. + // There's no next currentVersion, so use the current state. state = currentState target = currentVersion return } } else { - // There's no current version, so install head. + // There's no current currentVersion, so install head. // TODO(jzelinskie): find a way to make this less "magical" targetVersion = updateSource.LatestVersion("") } diff --git a/pkg/updates/file_test.go b/pkg/updates/file_test.go index a617603b..11cae6b3 100644 --- a/pkg/updates/file_test.go +++ b/pkg/updates/file_test.go @@ -41,20 +41,19 @@ func TestChannelForDatastore(t *testing.T) { func TestAvailableVersions(t *testing.T) { table := []struct { - name string - graph *UpdateGraph - engine string - version v1alpha1.SpiceDBVersion - expectedNames []string - expectedErr string + name string + graph *UpdateGraph + engine string + currentVersion v1alpha1.SpiceDBVersion + expected []v1alpha1.SpiceDBVersion + expectedErr string }{ { - name: "empty graph", - graph: &UpdateGraph{}, - engine: "postgres", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "postgres"}, - expectedNames: nil, - expectedErr: `no source found for channel "postgres"`, + name: "empty graph", + graph: &UpdateGraph{}, + engine: "postgres", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "postgres"}, + expectedErr: `no source found for channel "postgres"`, }, { name: "graph without matching channel", @@ -63,10 +62,9 @@ func TestAvailableVersions(t *testing.T) { Metadata: map[string]string{"datastore": "cockroachdb"}, Nodes: []State{{ID: "v1.0.0"}}, }}}, - engine: "postgres", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "postgres"}, - expectedNames: nil, - expectedErr: `no source found for channel "postgres"`, + engine: "postgres", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "postgres"}, + expectedErr: `no source found for channel "postgres"`, }, { name: "graph without edges", @@ -75,10 +73,9 @@ func TestAvailableVersions(t *testing.T) { Metadata: map[string]string{"datastore": "cockroachdb"}, Nodes: []State{{ID: "v1.0.1"}, {ID: "v1.0.0"}}, }}}, - engine: "cockroachdb", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, - expectedNames: nil, - expectedErr: "missing edges", + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, + expectedErr: "missing edges", }, { name: "graph without nodes", @@ -87,10 +84,9 @@ func TestAvailableVersions(t *testing.T) { Metadata: map[string]string{"datastore": "cockroachdb"}, Edges: EdgeSet{"v1.0.0": {"v1.0.1"}}, }}}, - engine: "cockroachdb", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, - expectedNames: nil, - expectedErr: "missing nodes", + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, + expectedErr: "missing nodes", }, { name: "simple patch update", @@ -100,9 +96,34 @@ func TestAvailableVersions(t *testing.T) { Edges: EdgeSet{"v1.0.0": {"v1.0.1"}}, Nodes: []State{{ID: "v1.0.1"}, {ID: "v1.0.0"}}, }}}, - engine: "cockroachdb", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, - expectedNames: []string{"v1.0.1"}, + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, + expected: []v1alpha1.SpiceDBVersion{{Name: "v1.0.1", Channel: "cockroachdb", Attributes: []v1alpha1.SpiceDBVersionAttributes{"next", "latest"}, Description: "direct update with no migrations, head of channel"}}, + }, + { + name: "a next safe update, a next update with a migration, and a latest update with many steps are all available", + graph: &UpdateGraph{Channels: []Channel{{ + Name: "cockroachdb", + Metadata: map[string]string{"datastore": "cockroachdb"}, + Edges: EdgeSet{ + "v1.0.0": {"v1.0.1", "v1.0.2"}, + "v1.0.1": {"v1.0.2"}, + "v1.0.2": {"v1.0.3"}, + }, + Nodes: []State{ + {ID: "v1.0.3", Migration: "b"}, + {ID: "v1.0.2", Migration: "a"}, + {ID: "v1.0.1"}, + {ID: "v1.0.0"}, + }, + }}}, + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.0", Channel: "cockroachdb"}, + expected: []v1alpha1.SpiceDBVersion{ + {Name: "v1.0.1", Channel: "cockroachdb", Attributes: []v1alpha1.SpiceDBVersionAttributes{"next"}, Description: "direct update with no migrations"}, + {Name: "v1.0.2", Channel: "cockroachdb", Attributes: []v1alpha1.SpiceDBVersionAttributes{"next", "migration"}, Description: "update will run a migration"}, + {Name: "v1.0.3", Channel: "cockroachdb", Attributes: []v1alpha1.SpiceDBVersionAttributes{"latest", "migration"}, Description: "head of the channel, multiple updates will run in sequence"}, + }, }, { name: "head returns nothing", @@ -112,9 +133,9 @@ func TestAvailableVersions(t *testing.T) { Edges: EdgeSet{"v1.0.0": {"v1.0.1"}}, Nodes: []State{{ID: "v1.0.1"}, {ID: "v1.0.0"}}, }}}, - engine: "cockroachdb", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.1", Channel: "cockroachdb"}, - expectedNames: []string{}, + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.1", Channel: "cockroachdb"}, + expected: []v1alpha1.SpiceDBVersion{}, }, { name: "ignores old versions", @@ -127,15 +148,15 @@ func TestAvailableVersions(t *testing.T) { }, Nodes: []State{{ID: "v1.1.0"}, {ID: "v1.0.1"}, {ID: "v1.0.0"}}, }}}, - engine: "cockroachdb", - version: v1alpha1.SpiceDBVersion{Name: "v1.0.1", Channel: "cockroachdb"}, - expectedNames: []string{"v1.1.0"}, + engine: "cockroachdb", + currentVersion: v1alpha1.SpiceDBVersion{Name: "v1.0.1", Channel: "cockroachdb"}, + expected: []v1alpha1.SpiceDBVersion{{Name: "v1.1.0", Channel: "cockroachdb", Attributes: []v1alpha1.SpiceDBVersionAttributes{"next", "latest"}, Description: "direct update with no migrations, head of channel"}}, }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { - versions, err := tt.graph.AvailableVersions(tt.engine, tt.version) + versions, err := tt.graph.AvailableVersions(tt.engine, tt.currentVersion) switch tt.expectedErr { case "": @@ -145,13 +166,7 @@ func TestAvailableVersions(t *testing.T) { require.Contains(t, err.Error(), tt.expectedErr) } - if tt.expectedNames != nil { - names := make([]string, 0) - for _, v := range versions { - names = append(names, v.Name) - } - require.Equal(t, tt.expectedNames, names) - } + require.EqualValues(t, tt.expected, versions) }) } } @@ -207,7 +222,7 @@ func TestComputeTarget(t *testing.T) { expectedState: State{ID: "v1.0.1"}, }, { - name: "fallback to current version channel", + name: "fallback to current currentVersion channel", graph: &UpdateGraph{Channels: []Channel{{ Name: "cockroachdb", Metadata: map[string]string{"datastore": "cockroachdb"}, @@ -264,7 +279,7 @@ func TestComputeTarget(t *testing.T) { expectedErr: "no current state", }, { - name: "rolling uses current version", + name: "rolling uses current currentVersion", graph: &UpdateGraph{Channels: []Channel{{ Name: "cockroachdb", Metadata: map[string]string{"datastore": "cockroachdb"}, @@ -280,7 +295,7 @@ func TestComputeTarget(t *testing.T) { expectedState: State{ID: "v1.0.0"}, }, { - name: "head returns same version", + name: "head returns same currentVersion", graph: &UpdateGraph{Channels: []Channel{{ Name: "cockroachdb", Metadata: map[string]string{"datastore": "cockroachdb"}, @@ -295,7 +310,7 @@ func TestComputeTarget(t *testing.T) { expectedState: State{ID: "v1.0.1"}, }, { - name: "no version returns head", + name: "no currentVersion returns head", graph: &UpdateGraph{Channels: []Channel{{ Name: "cockroachdb", Metadata: map[string]string{"datastore": "cockroachdb"}, diff --git a/pkg/updates/memory.go b/pkg/updates/memory.go index 4a36afaf..faf0d462 100644 --- a/pkg/updates/memory.go +++ b/pkg/updates/memory.go @@ -13,9 +13,9 @@ type EdgeSet map[string][]string type NodeSet map[string]int // MemorySource is an in-memory implementation of Source. -// It's an oracle to answer update questions for an installed version. +// It's an oracle to answer update questions for an installed currentVersion. type MemorySource struct { - // OrderedNodes is an ordered list of all nodes. Lower index == newer version. + // OrderedNodes is an ordered list of all nodes. Lower index == newer currentVersion. OrderedNodes []State // Nodes is a helper to lookup a node by id Nodes NodeSet diff --git a/pkg/updates/source.go b/pkg/updates/source.go index 292cc3d9..a502ddb5 100644 --- a/pkg/updates/source.go +++ b/pkg/updates/source.go @@ -1,16 +1,16 @@ package updates -// Source models a single stream of updates for an installed version. +// Source models a single stream of updates for an installed currentVersion. type Source interface { - // NextVersionWithoutMigrations returns the newest version that has an edge that + // NextVersionWithoutMigrations returns the newest currentVersion that has an edge that // does not require any migrations. NextVersionWithoutMigrations(from string) string - // Next returns the newest version that has an edge. - // This version might include migrations. + // NextVersion returns the newest currentVersion that has an edge. + // This currentVersion might include migrations. NextVersion(from string) string - // Latest returns the newest version that has some path through the + // LatestVersion returns the newest currentVersion that has some path through the // graph. // // If no path exists, returns the empty string.