From 92a902e455ce38d90e1506ea4713c3f0cd16f4a2 Mon Sep 17 00:00:00 2001 From: vimystic <122659254+vimystic@users.noreply.github.com> Date: Sat, 14 Dec 2024 12:03:54 -0700 Subject: [PATCH] feat: Add spec.ordinals.start. (#448) --- .github/workflows/release.yaml | 19 ++- api/v1/cosmosfullnode_types.go | 14 ++ api/v1/zz_generated.deepcopy.go | 16 ++ .../cosmos.strange.love_cosmosfullnodes.yaml | 16 ++ internal/fullnode/build_pods.go | 2 +- internal/fullnode/build_pods_test.go | 137 +++++++++++++++++- internal/fullnode/configmap_builder.go | 7 +- internal/fullnode/configmap_builder_test.go | 58 +++++++- internal/fullnode/node_key_builder.go | 8 +- internal/fullnode/node_key_builder_test.go | 52 +++++++ internal/fullnode/peer_collector.go | 4 +- internal/fullnode/peer_collector_test.go | 121 ++++++++++++++++ internal/fullnode/pvc_builder.go | 33 +++-- internal/fullnode/pvc_builder_test.go | 62 ++++++++ internal/fullnode/pvc_control.go | 5 +- internal/fullnode/service_builder.go | 16 +- internal/fullnode/service_builder_test.go | 52 +++++++ 17 files changed, 561 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 91227714..772e428a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -58,11 +58,14 @@ jobs: build-args: VERSION=${{ steps.meta.outputs.version }} - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: '${{ fromJSON(steps.meta.outputs.json).tags[0] }}' - format: 'table' - exit-code: '1' - ignore-unfixed: true - vuln-type: 'os,library' - severity: 'CRITICAL,HIGH' + run: | + for i in {1..3}; do + if docker run --rm aquasec/trivy:latest image --exit-code 0 --severity CRITICAL,HIGH --ignore-unfixed ${{ fromJSON(steps.meta.outputs.json).tags[0] }}; then + break + elif [ $i -lt 3 ]; then + echo "Retrying in 60 seconds..." + sleep 60 + else + exit 1 + fi + done \ No newline at end of file diff --git a/api/v1/cosmosfullnode_types.go b/api/v1/cosmosfullnode_types.go index 68a5b7e3..0c7ecab0 100644 --- a/api/v1/cosmosfullnode_types.go +++ b/api/v1/cosmosfullnode_types.go @@ -32,11 +32,25 @@ const CosmosFullNodeController = "CosmosFullNode" // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +type Ordinals struct { + // start is the number representing the first replica's index. It may be used to number replicas from an alternate index (eg: 1-indexed) over the default 0-indexed names, + // or to orchestrate progressive movement of replicas from one CosmosFullnode spec to another. If set, replica indices will be in the range: + // [.spec.ordinals.start, .spec.ordinals.start + .spec.replicas). + // If unset, defaults to 0. Replica indices will be in the range: + // [0, .spec.replicas). + // +kubebuilder:validation:Minimum:=0 + Start int32 `json:"start,omitempty"` +} + // FullNodeSpec defines the desired state of CosmosFullNode type FullNodeSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file + // Ordinals controls the numbering of replica indices in a CosmosFullnode spec. + // The default ordinals behavior assigns a "0" index to the first replica and increments the index by one for each additional replica requested. + Ordinals Ordinals `json:"ordinals,omitempty"` + // Number of replicas to create. // Individual replicas have a consistent identity. // +kubebuilder:validation:Minimum:=0 diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index a2ea16cb..1b11a80f 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -288,6 +288,7 @@ func (in *FullNodeSnapshotStatus) DeepCopy() *FullNodeSnapshotStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FullNodeSpec) DeepCopyInto(out *FullNodeSpec) { *out = *in + out.Ordinals = in.Ordinals in.ChainSpec.DeepCopyInto(&out.ChainSpec) in.PodTemplate.DeepCopyInto(&out.PodTemplate) in.RolloutStrategy.DeepCopyInto(&out.RolloutStrategy) @@ -452,6 +453,21 @@ func (in *Metadata) DeepCopy() *Metadata { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Ordinals) DeepCopyInto(out *Ordinals) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Ordinals. +func (in *Ordinals) DeepCopy() *Ordinals { + if in == nil { + return nil + } + out := new(Ordinals) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PVCAutoScaleSpec) DeepCopyInto(out *PVCAutoScaleSpec) { *out = *in diff --git a/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml b/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml index df0f8b39..d20af7e6 100644 --- a/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml +++ b/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml @@ -555,6 +555,22 @@ spec: Example: cosmos-1 Used for debugging. type: object + ordinals: + description: |- + Ordinals controls the numbering of replica indices in a CosmosFullnode spec. + The default ordinals behavior assigns a "0" index to the first replica and increments the index by one for each additional replica requested. + properties: + start: + description: |- + start is the number representing the first replica's index. It may be used to number replicas from an alternate index (eg: 1-indexed) over the default 0-indexed names, + or to orchestrate progressive movement of replicas from one CosmosFullnode spec to another. If set, replica indices will be in the range: + [.spec.ordinals.start, .spec.ordinals.start + .spec.replicas). + If unset, defaults to 0. Replica indices will be in the range: + [0, .spec.replicas). + format: int32 + minimum: 0 + type: integer + type: object podTemplate: description: |- Template applied to all pods. diff --git a/internal/fullnode/build_pods.go b/internal/fullnode/build_pods.go index 8b63aec3..370ff3cd 100644 --- a/internal/fullnode/build_pods.go +++ b/internal/fullnode/build_pods.go @@ -18,7 +18,7 @@ func BuildPods(crd *cosmosv1.CosmosFullNode, cksums ConfigChecksums) ([]diff.Res pods []diff.Resource[*corev1.Pod] ) candidates := podCandidates(crd) - for i := int32(0); i < crd.Spec.Replicas; i++ { + for i := crd.Spec.Ordinals.Start; i < crd.Spec.Ordinals.Start+crd.Spec.Replicas; i++ { pod, err := builder.WithOrdinal(i).Build() if err != nil { return nil, err diff --git a/internal/fullnode/build_pods_test.go b/internal/fullnode/build_pods_test.go index bc93fcc9..a5f216d5 100644 --- a/internal/fullnode/build_pods_test.go +++ b/internal/fullnode/build_pods_test.go @@ -17,7 +17,127 @@ import ( func TestBuildPods(t *testing.T) { t.Parallel() - t.Run("happy path", func(t *testing.T) { + t.Run("happy path with starting ordinal", func(t *testing.T) { + crd := &cosmosv1.CosmosFullNode{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agoric", + Namespace: "test", + }, + Spec: cosmosv1.FullNodeSpec{ + Replicas: 5, + ChainSpec: cosmosv1.ChainSpec{Network: "devnet"}, + PodTemplate: cosmosv1.PodSpec{ + Image: "busybox:latest", + }, + InstanceOverrides: nil, + Ordinals: cosmosv1.Ordinals{ + Start: 2, + }, + }, + } + + cksums := make(ConfigChecksums) + for i := 0; i < int(crd.Spec.Replicas); i++ { + cksums[client.ObjectKey{Namespace: crd.Namespace, Name: fmt.Sprintf("agoric-%d", i+int(crd.Spec.Ordinals.Start))}] = strconv.Itoa(i + int(crd.Spec.Ordinals.Start)) + } + + pods, err := BuildPods(crd, cksums) + require.NoError(t, err) + require.Equal(t, 5, len(pods)) + + for i, r := range pods { + expectedOrdinal := crd.Spec.Ordinals.Start + int32(i) + require.Equal(t, int64(expectedOrdinal), r.Ordinal(), i) + require.NotEmpty(t, r.Revision(), i) + require.Equal(t, strconv.Itoa(int(expectedOrdinal)), r.Object().Annotations["cosmos.strange.love/config-checksum"]) + } + + want := lo.Map([]int{2, 3, 4, 5, 6}, func(i int, _ int) string { + return fmt.Sprintf("agoric-%d", i) + }) + got := lo.Map(pods, func(pod diff.Resource[*corev1.Pod], _ int) string { return pod.Object().Name }) + require.Equal(t, want, got) + + pod, err := NewPodBuilder(crd).WithOrdinal(crd.Spec.Ordinals.Start).Build() + require.NoError(t, err) + require.Equal(t, pod.Spec, pods[0].Object().Spec) + }) + + t.Run("instance overrides with starting ordinal", func(t *testing.T) { + const ( + image = "agoric:latest" + overrideImage = "some_image:custom" + overridePod = "agoric-7" + ) + crd := &cosmosv1.CosmosFullNode{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agoric", + }, + Spec: cosmosv1.FullNodeSpec{ + Replicas: 6, + PodTemplate: cosmosv1.PodSpec{ + Image: image, + }, + InstanceOverrides: map[string]cosmosv1.InstanceOverridesSpec{ + "agoric-4": {DisableStrategy: ptr(cosmosv1.DisablePod)}, + "agoric-6": {DisableStrategy: ptr(cosmosv1.DisableAll)}, + overridePod: {Image: overrideImage}, + }, + Ordinals: cosmosv1.Ordinals{ + Start: 2, + }, + }, + } + + pods, err := BuildPods(crd, nil) + require.NoError(t, err) + require.Equal(t, 4, len(pods)) + + want := lo.Map([]int{2, 3, 5, 7}, func(i int, _ int) string { + return fmt.Sprintf("agoric-%d", i) + }) + got := lo.Map(pods, func(pod diff.Resource[*corev1.Pod], _ int) string { return pod.Object().Name }) + require.Equal(t, want, got) + for _, pod := range pods { + image := pod.Object().Spec.Containers[0].Image + if pod.Object().Name == overridePod { + require.Equal(t, overrideImage, image) + } else { + require.Equal(t, image, image) + } + } + }) + + t.Run("scheduled volume snapshot pod candidate with starting ordinal", func(t *testing.T) { + crd := &cosmosv1.CosmosFullNode{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agoric", + }, + Spec: cosmosv1.FullNodeSpec{ + Replicas: 6, + Ordinals: cosmosv1.Ordinals{Start: 2}, + }, + Status: cosmosv1.FullNodeStatus{ + ScheduledSnapshotStatus: map[string]cosmosv1.FullNodeSnapshotStatus{ + "some.scheduled.snapshot.1": {PodCandidate: "agoric-3"}, + "some.scheduled.snapshot.2": {PodCandidate: "agoric-4"}, + "some.scheduled.snapshot.ignored": {PodCandidate: "agoric-99"}, + }, + }, + } + + pods, err := BuildPods(crd, nil) + require.NoError(t, err) + require.Equal(t, 4, len(pods)) + + want := lo.Map([]int{2, 5, 6, 7}, func(i int, _ int) string { + return fmt.Sprintf("agoric-%d", i) + }) + got := lo.Map(pods, func(pod diff.Resource[*corev1.Pod], _ int) string { return pod.Object().Name }) + require.Equal(t, want, got) + }) + + t.Run("happy path without starting ordinal", func(t *testing.T) { crd := &cosmosv1.CosmosFullNode{ ObjectMeta: metav1.ObjectMeta{ Name: "agoric", @@ -35,7 +155,7 @@ func TestBuildPods(t *testing.T) { cksums := make(ConfigChecksums) for i := 0; i < int(crd.Spec.Replicas); i++ { - cksums[client.ObjectKey{Namespace: crd.Namespace, Name: fmt.Sprintf("agoric-%d", i)}] = strconv.Itoa(i) + cksums[client.ObjectKey{Namespace: crd.Namespace, Name: fmt.Sprintf("agoric-%d", i+int(crd.Spec.Ordinals.Start))}] = strconv.Itoa(i + int(crd.Spec.Ordinals.Start)) } pods, err := BuildPods(crd, cksums) @@ -43,23 +163,24 @@ func TestBuildPods(t *testing.T) { require.Equal(t, 5, len(pods)) for i, r := range pods { - require.Equal(t, int64(i), r.Ordinal(), i) + expectedOrdinal := crd.Spec.Ordinals.Start + int32(i) + require.Equal(t, int64(expectedOrdinal), r.Ordinal(), i) require.NotEmpty(t, r.Revision(), i) - require.Equal(t, strconv.Itoa(i), r.Object().Annotations["cosmos.strange.love/config-checksum"]) + require.Equal(t, strconv.Itoa(int(expectedOrdinal)), r.Object().Annotations["cosmos.strange.love/config-checksum"]) } - want := lo.Map([]int{0, 1, 2, 3, 4}, func(_ int, i int) string { + want := lo.Map([]int{0, 1, 2, 3, 4}, func(i int, _ int) string { return fmt.Sprintf("agoric-%d", i) }) got := lo.Map(pods, func(pod diff.Resource[*corev1.Pod], _ int) string { return pod.Object().Name }) require.Equal(t, want, got) - pod, err := NewPodBuilder(crd).WithOrdinal(0).Build() + pod, err := NewPodBuilder(crd).WithOrdinal(crd.Spec.Ordinals.Start).Build() require.NoError(t, err) require.Equal(t, pod.Spec, pods[0].Object().Spec) }) - t.Run("instance overrides", func(t *testing.T) { + t.Run("instance overrides without starting ordinal", func(t *testing.T) { const ( image = "agoric:latest" overrideImage = "some_image:custom" @@ -101,7 +222,7 @@ func TestBuildPods(t *testing.T) { } }) - t.Run("scheduled volume snapshot pod candidate", func(t *testing.T) { + t.Run("scheduled volume snapshot pod candidate without starting ordinal", func(t *testing.T) { crd := &cosmosv1.CosmosFullNode{ ObjectMeta: metav1.ObjectMeta{ Name: "agoric", diff --git a/internal/fullnode/configmap_builder.go b/internal/fullnode/configmap_builder.go index af9bbb51..598dd8bd 100644 --- a/internal/fullnode/configmap_builder.go +++ b/internal/fullnode/configmap_builder.go @@ -26,12 +26,13 @@ const ( func BuildConfigMaps(crd *cosmosv1.CosmosFullNode, peers Peers) ([]diff.Resource[*corev1.ConfigMap], error) { var ( buf = bufPool.Get().(*bytes.Buffer) - cms = make([]diff.Resource[*corev1.ConfigMap], crd.Spec.Replicas) + cms = make([]diff.Resource[*corev1.ConfigMap], 0, crd.Spec.Replicas) ) defer bufPool.Put(buf) defer buf.Reset() + startOrdinal := crd.Spec.Ordinals.Start - for i := int32(0); i < crd.Spec.Replicas; i++ { + for i := startOrdinal; i < startOrdinal+crd.Spec.Replicas; i++ { data := make(map[string]string) instance := instanceName(crd, i) if err := addConfigToml(buf, data, crd, instance, peers); err != nil { @@ -75,7 +76,7 @@ func BuildConfigMaps(crd *cosmosv1.CosmosFullNode, peers Peers) ([]diff.Resource ) cm.Data = data kube.NormalizeMetadata(&cm.ObjectMeta) - cms[i] = diff.Adapt(&cm, i) + cms = append(cms, diff.Adapt(&cm, int(i-startOrdinal))) } return cms, nil diff --git a/internal/fullnode/configmap_builder_test.go b/internal/fullnode/configmap_builder_test.go index 56dac97b..0158e757 100644 --- a/internal/fullnode/configmap_builder_test.go +++ b/internal/fullnode/configmap_builder_test.go @@ -42,16 +42,17 @@ func TestBuildConfigMaps(t *testing.T) { crd.Namespace = "test" crd.Spec.PodTemplate.Image = "agoric:v6.0.0" crd.Spec.ChainSpec.Network = "testnet" + //Default starting ordinal is 0 cms, err := BuildConfigMaps(&crd, nil) require.NoError(t, err) - require.Equal(t, 3, len(cms)) + require.Equal(t, crd.Spec.Replicas, int32(len(cms))) - require.Equal(t, int64(0), cms[0].Ordinal()) + require.Equal(t, crd.Spec.Ordinals.Start, int32(cms[0].Ordinal())+crd.Spec.Ordinals.Start) require.NotEmpty(t, cms[0].Revision()) cm := cms[0].Object() - require.Equal(t, "agoric-0", cm.Name) + require.Equal(t, fmt.Sprintf("agoric-%d", crd.Spec.Ordinals.Start), cm.Name) require.Equal(t, "test", cm.Namespace) require.Nil(t, cm.Immutable) @@ -59,7 +60,7 @@ func TestBuildConfigMaps(t *testing.T) { "app.kubernetes.io/created-by": "cosmos-operator", "app.kubernetes.io/component": "CosmosFullNode", "app.kubernetes.io/name": "agoric", - "app.kubernetes.io/instance": "agoric-0", + "app.kubernetes.io/instance": fmt.Sprintf("%s-%d", crd.Name, crd.Spec.Ordinals.Start), "app.kubernetes.io/version": "v6.0.0", "cosmos.strange.love/network": "testnet", "cosmos.strange.love/type": "FullNode", @@ -69,7 +70,54 @@ func TestBuildConfigMaps(t *testing.T) { require.Equal(t, wantLabels, cm.Labels) cm = cms[1].Object() - require.Equal(t, "agoric-1", cm.Name) + require.Equal(t, fmt.Sprintf("%s-%d", crd.Name, crd.Spec.Ordinals.Start+1), cm.Name) + + require.NotEmpty(t, cms[0].Object().Data) + require.Equal(t, cms[0].Object().Data, cms[1].Object().Data) + + crd.Spec.Type = cosmosv1.FullNode + cms2, err := BuildConfigMaps(&crd, nil) + + require.NoError(t, err) + require.Equal(t, cms, cms2) + }) + + t.Run("happy path with non 0 starting ordinal", func(t *testing.T) { + crd := defaultCRD() + crd.Spec.Replicas = 3 + crd.Name = "agoric" + crd.Namespace = "test" + crd.Spec.PodTemplate.Image = "agoric:v6.0.0" + crd.Spec.ChainSpec.Network = "testnet" + crd.Spec.Ordinals.Start = 2 + + cms, err := BuildConfigMaps(&crd, nil) + require.NoError(t, err) + require.Equal(t, crd.Spec.Replicas, int32(len(cms))) + + require.Equal(t, crd.Spec.Ordinals.Start, int32(cms[0].Ordinal())+crd.Spec.Ordinals.Start) + require.NotEmpty(t, cms[0].Revision()) + + cm := cms[0].Object() + require.Equal(t, fmt.Sprintf("agoric-%d", crd.Spec.Ordinals.Start), cm.Name) + require.Equal(t, "test", cm.Namespace) + require.Nil(t, cm.Immutable) + + wantLabels := map[string]string{ + "app.kubernetes.io/created-by": "cosmos-operator", + "app.kubernetes.io/component": "CosmosFullNode", + "app.kubernetes.io/name": "agoric", + "app.kubernetes.io/instance": fmt.Sprintf("%s-%d", crd.Name, crd.Spec.Ordinals.Start), + "app.kubernetes.io/version": "v6.0.0", + "cosmos.strange.love/network": "testnet", + "cosmos.strange.love/type": "FullNode", + } + require.Empty(t, cm.Annotations) + + require.Equal(t, wantLabels, cm.Labels) + + cm = cms[1].Object() + require.Equal(t, fmt.Sprintf("%s-%d", crd.Name, crd.Spec.Ordinals.Start+1), cm.Name) require.NotEmpty(t, cms[0].Object().Data) require.Equal(t, cms[0].Object().Data, cms[1].Object().Data) diff --git a/internal/fullnode/node_key_builder.go b/internal/fullnode/node_key_builder.go index 3ed330b8..c5bfa285 100644 --- a/internal/fullnode/node_key_builder.go +++ b/internal/fullnode/node_key_builder.go @@ -20,8 +20,10 @@ const nodeKeyFile = "node_key.json" // If the secret already has a node key, it is reused. // Returns an error if a new node key cannot be serialized. (Should never happen.) func BuildNodeKeySecrets(existing []*corev1.Secret, crd *cosmosv1.CosmosFullNode) ([]diff.Resource[*corev1.Secret], error) { - secrets := make([]diff.Resource[*corev1.Secret], crd.Spec.Replicas) - for i := int32(0); i < crd.Spec.Replicas; i++ { + secrets := make([]diff.Resource[*corev1.Secret], 0, crd.Spec.Replicas) + startOrdinal := crd.Spec.Ordinals.Start + + for i := startOrdinal; i < startOrdinal+crd.Spec.Replicas; i++ { var s corev1.Secret s.Name = nodeKeySecretName(crd, i) s.Namespace = crd.Namespace @@ -52,7 +54,7 @@ func BuildNodeKeySecrets(existing []*corev1.Secret, crd *cosmosv1.CosmosFullNode } } - secrets[i] = diff.Adapt(&secret, i) + secrets = append(secrets, diff.Adapt(&secret, int(i-startOrdinal))) } return secrets, nil } diff --git a/internal/fullnode/node_key_builder_test.go b/internal/fullnode/node_key_builder_test.go index 6aad78f5..65019c4d 100644 --- a/internal/fullnode/node_key_builder_test.go +++ b/internal/fullnode/node_key_builder_test.go @@ -63,6 +63,58 @@ func TestBuildNodeKeySecrets(t *testing.T) { } }) + t.Run("happy path with non 0 starting ordinal", func(t *testing.T) { + var crd cosmosv1.CosmosFullNode + crd.Namespace = "test-namespace" + crd.Name = "juno" + crd.Spec.Replicas = 3 + crd.Spec.ChainSpec.Network = "mainnet" + crd.Spec.PodTemplate.Image = "ghcr.io/juno:v1.2.3" + // Start ordinal is 0 by default + crd.Spec.Ordinals.Start = 2 + + secrets, err := BuildNodeKeySecrets(nil, &crd) + require.NoError(t, err) + require.Equal(t, crd.Spec.Replicas, int32(len(secrets))) + + for i, s := range secrets { + ordinal := crd.Spec.Ordinals.Start + int32(i) + require.Equal(t, crd.Spec.Ordinals.Start, crd.Spec.Ordinals.Start) + require.NotEmpty(t, s.Revision()) + + got := s.Object() + require.Equal(t, crd.Namespace, got.Namespace) + require.Equal(t, fmt.Sprintf("juno-node-key-%d", ordinal), got.Name) + require.Equal(t, "Secret", got.Kind) + require.Equal(t, "v1", got.APIVersion) + + wantLabels := map[string]string{ + "app.kubernetes.io/created-by": "cosmos-operator", + "app.kubernetes.io/component": "CosmosFullNode", + "app.kubernetes.io/name": "juno", + "app.kubernetes.io/instance": fmt.Sprintf("%s-%d", crd.Name, ordinal), + "app.kubernetes.io/version": "v1.2.3", + "cosmos.strange.love/network": "mainnet", + "cosmos.strange.love/type": "FullNode", + } + require.Equal(t, wantLabels, got.Labels) + + require.Empty(t, got.Annotations) + + require.True(t, *got.Immutable) + require.Equal(t, corev1.SecretTypeOpaque, got.Type) + + nodeKey := got.Data["node_key.json"] + require.NotEmpty(t, nodeKey) + + var gotJSON map[string]map[string]string + err = json.Unmarshal(nodeKey, &gotJSON) + require.NoError(t, err) + require.Equal(t, gotJSON["priv_key"]["type"], "tendermint/PrivKeyEd25519") + require.NotEmpty(t, gotJSON["priv_key"]["value"]) + } + }) + t.Run("with existing", func(t *testing.T) { const namespace = "test-namespace" var crd cosmosv1.CosmosFullNode diff --git a/internal/fullnode/peer_collector.go b/internal/fullnode/peer_collector.go index 37c27c3a..57e32ee2 100644 --- a/internal/fullnode/peer_collector.go +++ b/internal/fullnode/peer_collector.go @@ -105,12 +105,14 @@ func NewPeerCollector(client Getter) *PeerCollector { // Collect peer information given the crd. func (c PeerCollector) Collect(ctx context.Context, crd *cosmosv1.CosmosFullNode) (Peers, kube.ReconcileError) { peers := make(Peers) + startOrdinal := crd.Spec.Ordinals.Start clusterDomain := "cluster.local" if crd.Spec.Service.ClusterDomain != nil { clusterDomain = *crd.Spec.Service.ClusterDomain } - for i := int32(0); i < crd.Spec.Replicas; i++ { + + for i := startOrdinal; i < startOrdinal+crd.Spec.Replicas; i++ { secretName := nodeKeySecretName(crd, i) var secret corev1.Secret // Hoping the caching layer kubebuilder prevents API errors or rate limits. Simplifies logic to use a Get here diff --git a/internal/fullnode/peer_collector_test.go b/internal/fullnode/peer_collector_test.go index 8a379dc0..73b19dee 100644 --- a/internal/fullnode/peer_collector_test.go +++ b/internal/fullnode/peer_collector_test.go @@ -3,6 +3,7 @@ package fullnode import ( "context" "errors" + "fmt" "testing" cosmosv1 "github.com/strangelove-ventures/cosmos-operator/api/v1" @@ -148,6 +149,126 @@ func TestPeerCollector_Collect(t *testing.T) { require.ElementsMatch(t, want, peers.AllExternal()) }) + t.Run("happy path with non 0 starting ordinal- private addresses", func(t *testing.T) { + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 2 + crd.Spec.Ordinals.Start = 2 + + res, err := BuildNodeKeySecrets(nil, &crd) + require.NoError(t, err) + require.Equal(t, crd.Spec.Replicas, int32(len(res))) + secret := res[0].Object() + secret.Data[nodeKeyFile] = []byte(nodeKey) + + var ( + getCount int + objKeys []client.ObjectKey + ) + getter := mockGetter(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + objKeys = append(objKeys, key) + getCount++ + switch ref := obj.(type) { + case *corev1.Secret: + *ref = *secret + case *corev1.Service: + *ref = corev1.Service{} + } + return nil + }) + + collector := NewPeerCollector(getter) + peers, err := collector.Collect(ctx, &crd) + require.NoError(t, err) + require.Len(t, peers, 2) + + require.Equal(t, 4, getCount) // 2 secrets + 2 services + + wantKeys := []client.ObjectKey{ + {Name: fmt.Sprintf("dydx-node-key-%d", crd.Spec.Ordinals.Start), Namespace: namespace}, + {Name: fmt.Sprintf("dydx-p2p-%d", crd.Spec.Ordinals.Start), Namespace: namespace}, + {Name: fmt.Sprintf("dydx-node-key-%d", crd.Spec.Ordinals.Start+1), Namespace: namespace}, + {Name: fmt.Sprintf("dydx-p2p-%d", crd.Spec.Ordinals.Start+1), Namespace: namespace}, + } + require.Equal(t, wantKeys, objKeys) + + got := peers[client.ObjectKey{Name: fmt.Sprintf("dydx-%d", crd.Spec.Ordinals.Start), Namespace: namespace}] + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892", got.NodeID) + require.Equal(t, fmt.Sprintf("dydx-p2p-%d.strangelove.svc.cluster.local:26656", crd.Spec.Ordinals.Start), got.PrivateAddress) + require.Equal(t, fmt.Sprintf("1e23ce0b20ae2377925537cc71d1529d723bb892@dydx-p2p-%d.strangelove.svc.cluster.local:26656", crd.Spec.Ordinals.Start), got.PrivatePeer()) + require.Empty(t, got.ExternalAddress) + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892@0.0.0.0:26656", got.ExternalPeer()) + + got = peers[client.ObjectKey{Name: fmt.Sprintf("dydx-%d", crd.Spec.Ordinals.Start+1), Namespace: namespace}] + require.NotEmpty(t, got.NodeID) + require.Equal(t, fmt.Sprintf("dydx-p2p-%d.strangelove.svc.cluster.local:26656", crd.Spec.Ordinals.Start+1), got.PrivateAddress) + require.Empty(t, got.ExternalAddress) + + require.False(t, peers.HasIncompleteExternalAddress()) + }) + + t.Run("happy path with non 0 starting ordinal - external addresses", func(t *testing.T) { + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 3 + crd.Spec.Ordinals.Start = 0 + + res, err := BuildNodeKeySecrets(nil, &crd) + require.NoError(t, err) + require.Equal(t, crd.Spec.Replicas, int32(len(res))) + secret := res[0].Object() + secret.Data[nodeKeyFile] = []byte(nodeKey) + + getter := mockGetter(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + switch ref := obj.(type) { + case *corev1.Secret: + *ref = *secret + case *corev1.Service: + var svc corev1.Service + switch key.Name { + case fmt.Sprintf("dydx-p2p-%d", crd.Spec.Ordinals.Start): + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + case fmt.Sprintf("dydx-p2p-%d", crd.Spec.Ordinals.Start+1): + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "1.2.3.4"}} + case fmt.Sprintf("dydx-p2p-%d", crd.Spec.Ordinals.Start+2): + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{Hostname: "host.example.com"}} + } + *ref = svc + } + return nil + }) + + collector := NewPeerCollector(getter) + peers, err := collector.Collect(ctx, &crd) + require.NoError(t, err) + require.Len(t, peers, 3) + + got := peers[client.ObjectKey{Name: fmt.Sprintf("dydx-%d", crd.Spec.Ordinals.Start), Namespace: namespace}] + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892", got.NodeID) + require.Empty(t, got.ExternalAddress) + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892@0.0.0.0:26656", got.ExternalPeer()) + + got = peers[client.ObjectKey{Name: fmt.Sprintf("dydx-%d", crd.Spec.Ordinals.Start+1), Namespace: namespace}] + require.Equal(t, "1.2.3.4:26656", got.ExternalAddress) + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892@1.2.3.4:26656", got.ExternalPeer()) + + got = peers[client.ObjectKey{Name: fmt.Sprintf("dydx-%d", crd.Spec.Ordinals.Start+2), Namespace: namespace}] + require.Equal(t, "host.example.com:26656", got.ExternalAddress) + require.Equal(t, "1e23ce0b20ae2377925537cc71d1529d723bb892@host.example.com:26656", got.ExternalPeer()) + + require.True(t, peers.HasIncompleteExternalAddress()) + want := []string{ + "1e23ce0b20ae2377925537cc71d1529d723bb892@0.0.0.0:26656", + "1e23ce0b20ae2377925537cc71d1529d723bb892@1.2.3.4:26656", + "1e23ce0b20ae2377925537cc71d1529d723bb892@host.example.com:26656", + } + require.ElementsMatch(t, want, peers.AllExternal()) + }) + t.Run("zero replicas", func(t *testing.T) { var crd cosmosv1.CosmosFullNode crd.Spec.Replicas = 0 diff --git a/internal/fullnode/pvc_builder.go b/internal/fullnode/pvc_builder.go index b895872a..588586d5 100644 --- a/internal/fullnode/pvc_builder.go +++ b/internal/fullnode/pvc_builder.go @@ -39,7 +39,7 @@ func BuildPVCs( } var pvcs []diff.Resource[*corev1.PersistentVolumeClaim] - for i := int32(0); i < crd.Spec.Replicas; i++ { + for i := crd.Spec.Ordinals.Start; i < crd.Spec.Ordinals.Start+crd.Spec.Replicas; i++ { if pvcDisabled(crd, i) { continue } @@ -47,7 +47,8 @@ func BuildPVCs( pvc := base.DeepCopy() name := pvcName(crd, i) pvc.Name = name - pvc.Labels[kube.InstanceLabel] = instanceName(crd, i) + podName := instanceName(crd, i) + pvc.Labels[kube.InstanceLabel] = podName var dataSource *corev1.TypedLocalObjectReference var existingSize resource.Quantity @@ -65,7 +66,7 @@ func BuildPVCs( } tpl := crd.Spec.VolumeClaimTemplate - if override, ok := crd.Spec.InstanceOverrides[instanceName(crd, i)]; ok { + if override, ok := crd.Spec.InstanceOverrides[podName]; ok { if overrideTpl := override.VolumeClaimTemplate; overrideTpl != nil { tpl = *overrideTpl } @@ -73,7 +74,7 @@ func BuildPVCs( pvc.Spec = corev1.PersistentVolumeClaimSpec{ AccessModes: sliceOrDefault(tpl.AccessModes, defaultAccessModes), - Resources: pvcResources(crd, name, dataSources[i], existingSize), + Resources: pvcResources(crd, name, dataSources[i], existingSize, tpl.Resources), StorageClassName: ptr(tpl.StorageClassName), VolumeMode: valOrDefault(tpl.VolumeMode, ptr(corev1.PersistentVolumeFilesystem)), } @@ -88,24 +89,14 @@ func BuildPVCs( return pvcs } -func pvcDisabled(crd *cosmosv1.CosmosFullNode, ordinal int32) bool { - name := instanceName(crd, ordinal) - disable := crd.Spec.InstanceOverrides[name].DisableStrategy - return disable != nil && *disable == cosmosv1.DisableAll -} - -func pvcName(crd *cosmosv1.CosmosFullNode, ordinal int32) string { - name := fmt.Sprintf("pvc-%s-%d", appName(crd), ordinal) - return kube.ToName(name) -} - func pvcResources( crd *cosmosv1.CosmosFullNode, name string, dataSource *dataSource, existingSize resource.Quantity, + tplResources corev1.ResourceRequirements, ) corev1.ResourceRequirements { - var reqs = crd.Spec.VolumeClaimTemplate.Resources.DeepCopy() + reqs := tplResources.DeepCopy() if dataSource != nil { reqs.Requests[corev1.ResourceStorage] = dataSource.size @@ -129,3 +120,13 @@ func pvcResources( return *reqs } +func pvcDisabled(crd *cosmosv1.CosmosFullNode, ordinal int32) bool { + name := instanceName(crd, ordinal) + disable := crd.Spec.InstanceOverrides[name].DisableStrategy + return disable != nil && *disable == cosmosv1.DisableAll +} + +func pvcName(crd *cosmosv1.CosmosFullNode, ordinal int32) string { + name := fmt.Sprintf("pvc-%s-%d", appName(crd), ordinal) + return kube.ToName(name) +} diff --git a/internal/fullnode/pvc_builder_test.go b/internal/fullnode/pvc_builder_test.go index dca1390b..b85950f9 100644 --- a/internal/fullnode/pvc_builder_test.go +++ b/internal/fullnode/pvc_builder_test.go @@ -72,6 +72,68 @@ func TestBuildPVCs(t *testing.T) { } }) + t.Run("happy path with non 0 starting ordinal", func(t *testing.T) { + crd := defaultCRD() + crd.Name = "juno" + crd.Spec.Replicas = 3 + crd.Spec.VolumeClaimTemplate.StorageClassName = "test-storage-class" + crd.Spec.Ordinals.Start = 2 + + crd.Spec.InstanceOverrides = map[string]cosmosv1.InstanceOverridesSpec{ + fmt.Sprintf("juno-%d", crd.Spec.Ordinals.Start): {}, + } + + initial := BuildPVCs(&crd, map[int32]*dataSource{}, nil) + require.Equal(t, crd.Spec.Replicas, int32(len(initial))) + for _, r := range initial { + require.Equal(t, crd.Spec.Ordinals.Start, crd.Spec.Ordinals.Start) + require.NotEmpty(t, r.Revision()) + } + + initialPVCs := lo.Map(initial, func(r diff.Resource[*corev1.PersistentVolumeClaim], _ int) *corev1.PersistentVolumeClaim { + return r.Object() + }) + + pvcs := lo.Map(BuildPVCs(&crd, map[int32]*dataSource{}, initialPVCs), func(r diff.Resource[*corev1.PersistentVolumeClaim], _ int) *corev1.PersistentVolumeClaim { + return r.Object() + }) + + require.Equal(t, crd.Spec.Replicas, int32(len(pvcs))) + + wantNames := make([]string, crd.Spec.Replicas) + for i := range wantNames { + wantNames[i] = fmt.Sprintf("pvc-juno-%d", crd.Spec.Ordinals.Start+int32(i)) + } + + gotNames := lo.Map(pvcs, func(pvc *corev1.PersistentVolumeClaim, _ int) string { return pvc.Name }) + require.Equal(t, wantNames, gotNames) + + for i, got := range pvcs { + ordinal := crd.Spec.Ordinals.Start + int32(i) + require.Equal(t, crd.Namespace, got.Namespace) + require.Equal(t, "PersistentVolumeClaim", got.Kind) + require.Equal(t, "v1", got.APIVersion) + + wantLabels := map[string]string{ + "app.kubernetes.io/created-by": "cosmos-operator", + "app.kubernetes.io/component": "CosmosFullNode", + "app.kubernetes.io/name": "juno", + "app.kubernetes.io/instance": fmt.Sprintf("juno-%d", ordinal), + "app.kubernetes.io/version": "v1.2.3", + "cosmos.strange.love/network": "mainnet", + "cosmos.strange.love/type": "FullNode", + } + require.Equal(t, wantLabels, got.Labels) + + require.Len(t, got.Spec.AccessModes, 1) + require.Equal(t, corev1.ReadWriteOnce, got.Spec.AccessModes[0]) + + require.Equal(t, crd.Spec.VolumeClaimTemplate.Resources, got.Spec.Resources) + require.Equal(t, "test-storage-class", *got.Spec.StorageClassName) + require.Equal(t, corev1.PersistentVolumeFilesystem, *got.Spec.VolumeMode) + } + }) + t.Run("advanced configuration", func(t *testing.T) { crd := defaultCRD() crd.Spec.Replicas = 1 diff --git a/internal/fullnode/pvc_control.go b/internal/fullnode/pvc_control.go index 6786f377..5d87b693 100644 --- a/internal/fullnode/pvc_control.go +++ b/internal/fullnode/pvc_control.go @@ -51,7 +51,7 @@ func (control PVCControl) Reconcile(ctx context.Context, reporter kube.Reporter, dataSources := make(map[int32]*dataSource) if len(currentPVCs) < int(crd.Spec.Replicas) { - for i := int32(0); i < crd.Spec.Replicas; i++ { + for i := crd.Spec.Ordinals.Start; i < crd.Spec.Ordinals.Start+crd.Spec.Replicas; i++ { name := pvcName(crd, i) found := false for _, pvc := range currentPVCs { @@ -160,7 +160,8 @@ type dataSource struct { } func (control PVCControl) findDataSource(ctx context.Context, reporter kube.Reporter, crd *cosmosv1.CosmosFullNode, ordinal int32) *dataSource { - if override, ok := crd.Spec.InstanceOverrides[instanceName(crd, ordinal)]; ok { + podName := instanceName(crd, ordinal) + if override, ok := crd.Spec.InstanceOverrides[podName]; ok { if overrideTpl := override.VolumeClaimTemplate; overrideTpl != nil { return control.findDataSourceWithPvcSpec(ctx, reporter, crd, *overrideTpl, ordinal) } diff --git a/internal/fullnode/service_builder.go b/internal/fullnode/service_builder.go index 2831a12a..894ba176 100644 --- a/internal/fullnode/service_builder.go +++ b/internal/fullnode/service_builder.go @@ -31,21 +31,18 @@ func BuildServices(crd *cosmosv1.CosmosFullNode) []diff.Resource[*corev1.Service } maxExternal := lo.Clamp(max, 0, crd.Spec.Replicas) p2ps := make([]diff.Resource[*corev1.Service], crd.Spec.Replicas) - for i := int32(0); i < crd.Spec.Replicas; i++ { - ordinal := i + ordinal := crd.Spec.Ordinals.Start + i var svc corev1.Service svc.Name = p2pServiceName(crd, ordinal) svc.Namespace = crd.Namespace svc.Kind = "Service" svc.APIVersion = "v1" - svc.Labels = defaultLabels(crd, kube.InstanceLabel, instanceName(crd, ordinal), kube.ComponentLabel, "p2p", ) svc.Annotations = map[string]string{} - svc.Spec.Ports = []corev1.ServicePort{ { Name: "p2p", @@ -55,7 +52,6 @@ func BuildServices(crd *cosmosv1.CosmosFullNode) []diff.Resource[*corev1.Service }, } svc.Spec.Selector = map[string]string{kube.InstanceLabel: instanceName(crd, ordinal)} - if i < maxExternal { preserveMergeInto(svc.Labels, crd.Spec.Service.P2PTemplate.Metadata.Labels) preserveMergeInto(svc.Annotations, crd.Spec.Service.P2PTemplate.Metadata.Annotations) @@ -65,12 +61,9 @@ func BuildServices(crd *cosmosv1.CosmosFullNode) []diff.Resource[*corev1.Service svc.Spec.Type = corev1.ServiceTypeClusterIP svc.Spec.ClusterIP = *valOrDefault(crd.Spec.Service.P2PTemplate.ClusterIP, ptr("")) } - - p2ps[i] = diff.Adapt(&svc, i) + p2ps[i] = diff.Adapt(&svc, int(i)) } - rpc := rpcService(crd) - return append(p2ps, diff.Adapt(rpc, len(p2ps))) } @@ -84,7 +77,6 @@ func rpcService(crd *cosmosv1.CosmosFullNode) *corev1.Service { kube.ComponentLabel, "rpc", ) svc.Annotations = map[string]string{} - svc.Spec.Ports = []corev1.ServicePort{ { Name: "api", @@ -117,15 +109,12 @@ func rpcService(crd *cosmosv1.CosmosFullNode) *corev1.Service { TargetPort: intstr.FromString("grpc-web"), }, } - svc.Spec.Selector = map[string]string{kube.NameLabel: appName(crd)} svc.Spec.Type = corev1.ServiceTypeClusterIP - rpcSpec := crd.Spec.Service.RPCTemplate preserveMergeInto(svc.Labels, rpcSpec.Metadata.Labels) preserveMergeInto(svc.Annotations, rpcSpec.Metadata.Annotations) kube.NormalizeMetadata(&svc.ObjectMeta) - if v := rpcSpec.ExternalTrafficPolicy; v != nil { svc.Spec.ExternalTrafficPolicy = *v } @@ -135,7 +124,6 @@ func rpcService(crd *cosmosv1.CosmosFullNode) *corev1.Service { if v := rpcSpec.ClusterIP; v != nil { svc.Spec.ClusterIP = *v } - return &svc } diff --git a/internal/fullnode/service_builder_test.go b/internal/fullnode/service_builder_test.go index 246af0fc..571b8e66 100644 --- a/internal/fullnode/service_builder_test.go +++ b/internal/fullnode/service_builder_test.go @@ -68,6 +68,58 @@ func TestBuildServices(t *testing.T) { } }) + t.Run("p2p services", func(t *testing.T) { + crd := defaultCRD() + crd.Spec.Replicas = 3 + crd.Name = "terra" + crd.Namespace = "test" + crd.Spec.ChainSpec.Network = "testnet" + crd.Spec.PodTemplate.Image = "terra:v6.0.0" + crd.Spec.Ordinals.Start = 2 + + svcs := BuildServices(&crd) + + require.Equal(t, 4, len(svcs)) // 3 p2p services + 1 rpc service + + for i := 0; i < int(crd.Spec.Replicas); i++ { + ordinal := crd.Spec.Ordinals.Start + int32(i) + p2p := svcs[i].Object() + require.Equal(t, fmt.Sprintf("terra-p2p-%d", ordinal), p2p.Name) + require.Equal(t, "test", p2p.Namespace) + + wantLabels := map[string]string{ + "app.kubernetes.io/created-by": "cosmos-operator", + "app.kubernetes.io/name": "terra", + "app.kubernetes.io/component": "p2p", + "app.kubernetes.io/version": "v6.0.0", + "app.kubernetes.io/instance": fmt.Sprintf("terra-%d", ordinal), + "cosmos.strange.love/network": "testnet", + "cosmos.strange.love/type": "FullNode", + } + require.Equal(t, wantLabels, p2p.Labels) + + wantSpec := corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "p2p", + Protocol: corev1.ProtocolTCP, + Port: 26656, + TargetPort: intstr.FromString("p2p"), + }, + }, + Selector: map[string]string{"app.kubernetes.io/instance": fmt.Sprintf("terra-%d", ordinal)}, + Type: corev1.ServiceTypeClusterIP, + } + // By default, expose the first p2p service publicly. + if i == 0 { + wantSpec.Type = corev1.ServiceTypeLoadBalancer + wantSpec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyTypeLocal + } + + require.Equal(t, wantSpec, p2p.Spec) + } + }) + t.Run("p2p max external addresses", func(t *testing.T) { crd := defaultCRD() crd.Spec.Replicas = 3