diff --git a/internal/controller/ctlog/actions/server_config.go b/internal/controller/ctlog/actions/server_config.go index 5c0fb35d4..304044d81 100644 --- a/internal/controller/ctlog/actions/server_config.go +++ b/internal/controller/ctlog/actions/server_config.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "slices" + "strings" rhtasv1alpha1 "github.com/securesign/operator/api/v1alpha1" "github.com/securesign/operator/internal/action" @@ -17,6 +18,7 @@ import ( "github.com/securesign/operator/internal/utils/kubernetes/ensure" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" labels2 "k8s.io/apimachinery/pkg/labels" @@ -27,6 +29,14 @@ const ( serverConfigResourceName = "ctlog-server-config" ) +// Annotations used to track the data sources for server config secret +var serverConfigAnnotations = []string{ + labels.LabelNamespace + "/treeID", + labels.LabelNamespace + "/trillianUrl", + labels.LabelNamespace + "/rootCertificates", + labels.LabelNamespace + "/privateKeyRef", +} + func NewServerConfigAction() action.Action[*rhtasv1alpha1.CTlog] { return &serverConfig{} } @@ -41,19 +51,8 @@ func (i serverConfig) Name() string { func (i serverConfig) CanHandle(_ context.Context, instance *rhtasv1alpha1.CTlog) bool { c := meta.FindStatusCondition(instance.Status.Conditions, ConfigCondition) - - switch { - case c == nil: - return false - case !meta.IsStatusConditionTrue(instance.Status.Conditions, ConfigCondition): - return true - case instance.Status.ServerConfigRef == nil: - return true - case instance.Spec.ServerConfigRef != nil: - return !equality.Semantic.DeepEqual(instance.Spec.ServerConfigRef, instance.Status.ServerConfigRef) - default: - return instance.Generation != c.ObservedGeneration - } + // Always run Handle() to validate the config secret exists and is valid + return c != nil } func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) *action.Result { @@ -62,8 +61,31 @@ func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) ) if instance.Spec.ServerConfigRef != nil { + // Validate that the custom secret is accessible + secret, err := kubernetes.GetSecret(i.Client, instance.Namespace, instance.Spec.ServerConfigRef.Name) + if err != nil { + return i.Error(ctx, fmt.Errorf("error accessing custom server config secret: %w", err), instance, + metav1.Condition{ + Type: ConfigCondition, + Status: metav1.ConditionFalse, + Reason: constants.Failure, + Message: fmt.Sprintf("Error accessing custom server config secret: %s", instance.Spec.ServerConfigRef.Name), + ObservedGeneration: instance.Generation, + }) + } + if secret.Data == nil || secret.Data[ctlogUtils.ConfigKey] == nil { + return i.Error(ctx, fmt.Errorf("custom server config secret is invalid"), instance, + metav1.Condition{ + Type: ConfigCondition, + Status: metav1.ConditionFalse, + Reason: constants.Failure, + Message: fmt.Sprintf("Custom server config secret is missing '%s' key: %s", ctlogUtils.ConfigKey, instance.Spec.ServerConfigRef.Name), + ObservedGeneration: instance.Generation, + }) + } + instance.Status.ServerConfigRef = instance.Spec.ServerConfigRef - i.Recorder.Event(instance, corev1.EventTypeNormal, "CTLogConfigUpdated", "CTLog config updated") + i.Recorder.Eventf(instance, corev1.EventTypeNormal, "CTLogConfigUpdated", "CTLog config updated: %s", instance.Spec.ServerConfigRef.Name) meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConfigCondition, Status: metav1.ConditionTrue, @@ -74,6 +96,7 @@ func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) return i.StatusUpdate(ctx, instance) } + // Validate prerequisites and normalize Trillian address before validation switch { case instance.Status.TreeID == nil: return i.Error(ctx, fmt.Errorf("%s: %v", i.Name(), ctlogUtils.ErrTreeNotSpecified), instance) @@ -87,6 +110,31 @@ func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) trillianUrl := fmt.Sprintf("%s:%d", instance.Spec.Trillian.Address, *instance.Spec.Trillian.Port) + c := meta.FindStatusCondition(instance.Status.Conditions, ConfigCondition) + isSpecChange := c != nil && c.ObservedGeneration != instance.Generation + + // Validate existing secret before attempting recreation (only for hot updates, not spec changes) + if !isSpecChange && instance.Status.ServerConfigRef != nil && instance.Status.ServerConfigRef.Name != "" { + valid, err := i.validateExistingSecret(instance, trillianUrl) + if err != nil { + // API error other than NotFound - fail reconciliation + return i.Error(ctx, fmt.Errorf("error validating server config secret: %w", err), instance, + metav1.Condition{ + Type: ConfigCondition, + Status: metav1.ConditionFalse, + Reason: constants.Failure, + Message: fmt.Sprintf("Error accessing config secret: %s", instance.Status.ServerConfigRef.Name), + ObservedGeneration: instance.Generation, + }) + } + if valid { + return i.Continue() + } + // Secret needs recreation - log and continue + i.Logger.Info("Server config secret needs recreation", "secret", instance.Status.ServerConfigRef.Name) + i.Recorder.Eventf(instance, corev1.EventTypeWarning, "CTLogConfigRecreate", "Config secret will be recreated: %s", instance.Status.ServerConfigRef.Name) + } + configLabels := labels.ForResource(ComponentName, DeploymentName, instance.Name, serverConfigResourceName) rootCerts, err := i.handleRootCertificates(instance) @@ -133,10 +181,13 @@ func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) }, } + configAnnotations := i.configMatchingAnnotations(instance, trillianUrl) + if _, err = kubernetes.CreateOrUpdate(ctx, i.Client, newConfig, ensure.ControllerReference[*corev1.Secret](instance, i.Client), ensure.Labels[*corev1.Secret](slices.Collect(maps.Keys(configLabels)), configLabels), + ensure.Annotations[*corev1.Secret](serverConfigAnnotations, configAnnotations), kubernetes.EnsureSecretData(true, cfg), ); err != nil { return i.Error(ctx, fmt.Errorf("could not create Server config: %w", err), instance, @@ -151,7 +202,8 @@ func (i serverConfig) Handle(ctx context.Context, instance *rhtasv1alpha1.CTlog) instance.Status.ServerConfigRef = &rhtasv1alpha1.LocalObjectReference{Name: newConfig.Name} - i.Recorder.Eventf(instance, corev1.EventTypeNormal, "CTLogConfigCreated", "Secret with ctlog configuration created: %s", newConfig.Name) + i.Logger.Info("Server config secret created", "secret", newConfig.Name) + i.Recorder.Eventf(instance, corev1.EventTypeNormal, "CTLogConfigCreated", "Config secret created successfully: %s", newConfig.Name) meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConfigCondition, Status: metav1.ConditionTrue, @@ -186,11 +238,11 @@ func (i serverConfig) cleanup(ctx context.Context, instance *rhtasv1alpha1.CTlog err = i.Client.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: partialConfig.Name, Namespace: partialConfig.Namespace}}) if err != nil { i.Logger.Error(err, "unable to delete secret", "namespace", instance.Namespace, "name", partialConfig.Name) - i.Recorder.Eventf(instance, corev1.EventTypeWarning, "CTLogConfigDeleted", "Unable to delete secret: %s", partialConfig.Name) + i.Recorder.Eventf(instance, corev1.EventTypeWarning, "CTLogConfigCleanupFailed", "Unable to delete old config secret: %s", partialConfig.Name) continue } i.Logger.Info("Remove invalid Secret with ctlog configuration", "Name", partialConfig.Name) - i.Recorder.Eventf(instance, corev1.EventTypeNormal, "CTLogConfigDeleted", "Secret with ctlog configuration deleted: %s", partialConfig.Name) + i.Recorder.Eventf(instance, corev1.EventTypeNormal, "CTLogConfigCleanedUp", "Old config secret deleted successfully: %s", partialConfig.Name) } } @@ -231,3 +283,57 @@ func (i serverConfig) handleRootCertificates(instance *rhtasv1alpha1.CTlog) ([]c return certs, nil } + +// validateExistingSecret checks if the existing server config secret is valid. +// Returns: +// - (true, nil) if the secret is valid and no recreation is needed +// - (false, nil) if the secret needs recreation (NotFound or validation failed) +// - (false, error) if there was an API error (other than NotFound) - reconciliation should fail +func (i serverConfig) validateExistingSecret(instance *rhtasv1alpha1.CTlog, trillianUrl string) (bool, error) { + secret, err := kubernetes.GetSecret(i.Client, instance.Namespace, instance.Status.ServerConfigRef.Name) + if err != nil { + if apierrors.IsNotFound(err) { + // Secret doesn't exist - needs recreation + return false, nil + } + // Other API error (Forbidden, etc.) - fail reconciliation + return false, err + } + + // Check if the secret was generated from the same data sources using annotations + expectedAnnotations := i.configMatchingAnnotations(instance, trillianUrl) + if !equality.Semantic.DeepDerivative(expectedAnnotations, secret.GetAnnotations()) { + return false, nil + } + + // Secret is valid + return true, nil +} + +// configMatchingAnnotations generates annotations that identify the data sources +// used to generate the server config secret. +func (i serverConfig) configMatchingAnnotations(instance *rhtasv1alpha1.CTlog, trillianUrl string) map[string]string { + // Build a string representation of root certificate references + rootCertRefs := make([]string, 0, len(instance.Status.RootCertificates)) + for _, ref := range instance.Status.RootCertificates { + rootCertRefs = append(rootCertRefs, fmt.Sprintf("%s/%s", ref.Name, ref.Key)) + } + + annotations := map[string]string{ + labels.LabelNamespace + "/trillianUrl": trillianUrl, + } + + if instance.Status.TreeID != nil { + annotations[labels.LabelNamespace+"/treeID"] = fmt.Sprintf("%d", *instance.Status.TreeID) + } + + if len(rootCertRefs) > 0 { + annotations[labels.LabelNamespace+"/rootCertificates"] = strings.Join(rootCertRefs, ",") + } + + if instance.Status.PrivateKeyRef != nil { + annotations[labels.LabelNamespace+"/privateKeyRef"] = fmt.Sprintf("%s/%s", instance.Status.PrivateKeyRef.Name, instance.Status.PrivateKeyRef.Key) + } + + return annotations +} diff --git a/internal/controller/ctlog/actions/server_config_test.go b/internal/controller/ctlog/actions/server_config_test.go index 11d5e7af7..9051a6895 100644 --- a/internal/controller/ctlog/actions/server_config_test.go +++ b/internal/controller/ctlog/actions/server_config_test.go @@ -57,9 +57,11 @@ func TestServerConfig_CanHandle(t *testing.T) { { name: "ConditionTrue: spec.serverConfigRef is nil and status.serverConfigRef is not nil", status: metav1.ConditionTrue, - canHandle: false, + canHandle: true, serverConfigRef: nil, statusServerConfigRef: &rhtasv1alpha1.LocalObjectReference{Name: "config"}, + observedGeneration: 1, + generation: 1, }, { name: "ConditionTrue: spec.serverConfigRef is nil and status.serverConfigRef is nil", @@ -78,14 +80,14 @@ func TestServerConfig_CanHandle(t *testing.T) { { name: "ConditionTrue: spec.serverConfigRef == status.serverConfigRef", status: metav1.ConditionTrue, - canHandle: false, + canHandle: true, // Always true for periodic validation serverConfigRef: &rhtasv1alpha1.LocalObjectReference{Name: "config"}, statusServerConfigRef: &rhtasv1alpha1.LocalObjectReference{Name: "config"}, }, { name: "ConditionTrue: observedGeneration == generation", status: metav1.ConditionTrue, - canHandle: false, + canHandle: true, statusServerConfigRef: &rhtasv1alpha1.LocalObjectReference{Name: "config"}, observedGeneration: 1, generation: 1, @@ -182,6 +184,17 @@ func TestServerConfig_Handle(t *testing.T) { status: rhtasv1alpha1.CTlogStatus{ ServerConfigRef: nil, }, + objects: []client.Object{ + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config", + Namespace: "default", + }, + Data: map[string][]byte{ + ctlogUtils.ConfigKey: []byte("test-config"), + }, + }, + }, }, want: want{ result: testAction.StatusUpdate(), @@ -238,6 +251,17 @@ func TestServerConfig_Handle(t *testing.T) { status: rhtasv1alpha1.CTlogStatus{ ServerConfigRef: &rhtasv1alpha1.LocalObjectReference{Name: "old_config"}, }, + objects: []client.Object{ + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new_config", + Namespace: "default", + }, + Data: map[string][]byte{ + ctlogUtils.ConfigKey: []byte("new-test-config"), + }, + }, + }, }, want: want{ result: testAction.StatusUpdate(), diff --git a/internal/controller/ctlog/utils/ctlog_config.go b/internal/controller/ctlog/utils/ctlog_config.go index 998729a82..f57a6fcb7 100644 --- a/internal/controller/ctlog/utils/ctlog_config.go +++ b/internal/controller/ctlog/utils/ctlog_config.go @@ -200,3 +200,50 @@ func CreateCtlogConfig(trillianUrl string, treeID int64, rootCerts []RootCertifi } return data, nil } + +// IsSecretDataValid validates that a CTLog config secret contains valid configuration +// with the correct Trillian backend address. +// +// This function parses the protobuf text configuration and validates: +// 1. The configuration can be unmarshalled into the expected structure +// 2. At least one backend exists +// 3. The backend's BackendSpec matches the expected Trillian address +// +// Parameters: +// - secretData: The Data field from a Kubernetes Secret containing CTLog configuration +// - expectedTrillianAddr: The Trillian address to validate against (e.g., "trillian-logserver.namespace.svc:8091") +// +// Returns true if the secret contains valid configuration with the correct Trillian address, +// false otherwise. Used by the operator for self-healing to detect missing or invalid +// configuration secrets that need to be recreated. +func IsSecretDataValid(secretData map[string][]byte, expectedTrillianAddr string) bool { + if secretData == nil { + return false + } + + configData, ok := secretData[ConfigKey] + if !ok || len(configData) == 0 { + return false + } + + // Parse the protobuf text format configuration + var multiConfig configpb.LogMultiConfig + if err := prototext.Unmarshal(configData, &multiConfig); err != nil { + // Failed to parse - invalid configuration + return false + } + + // Validate that at least one backend exists + if multiConfig.Backends == nil || multiConfig.Backends.Backend == nil || len(multiConfig.Backends.Backend) == 0 { + return false + } + + // Check if any backend matches the expected Trillian address + for _, backend := range multiConfig.Backends.Backend { + if backend.BackendSpec == expectedTrillianAddr { + return true + } + } + + return false +} diff --git a/test/e2e/ctlog_recovery_test.go b/test/e2e/ctlog_recovery_test.go new file mode 100644 index 000000000..8ae261613 --- /dev/null +++ b/test/e2e/ctlog_recovery_test.go @@ -0,0 +1,247 @@ +//go:build integration + +package e2e + +import ( + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/securesign/operator/api/v1alpha1" + "github.com/securesign/operator/internal/constants" + ctlogActions "github.com/securesign/operator/internal/controller/ctlog/actions" + ctlogUtils "github.com/securesign/operator/internal/controller/ctlog/utils" + "github.com/securesign/operator/test/e2e/support" + "github.com/securesign/operator/test/e2e/support/condition" + "github.com/securesign/operator/test/e2e/support/steps" + "github.com/securesign/operator/test/e2e/support/tas/ctlog" + "github.com/securesign/operator/test/e2e/support/tas/trillian" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +var _ = Describe("CTlog recovery and validation", Ordered, func() { + cli, _ := support.CreateClient() + + var namespace *v1.Namespace + var trillianCR *v1alpha1.Trillian + var ctlogCR *v1alpha1.CTlog + var originalSecretName string + var correctTrillianAddr string + + BeforeAll(steps.CreateNamespace(cli, func(new *v1.Namespace) { + namespace = new + })) + + BeforeAll(func(ctx SpecContext) { + trillianCR = &v1alpha1.Trillian{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-trillian", + Namespace: namespace.Name, + }, + Spec: v1alpha1.TrillianSpec{ + Db: v1alpha1.TrillianDB{Create: ptr.To(true)}, + }, + } + Expect(cli.Create(ctx, trillianCR)).To(Succeed()) + + By("Waiting for Trillian to be ready") + trillian.Verify(ctx, cli, namespace.Name, trillianCR.Name, true) + }) + + BeforeAll(func(ctx SpecContext) { + By("Setting up CTLog prerequisites") + + keysSecret := ctlog.CreateSecret(namespace.Name, "test-ctlog-keys") + Expect(cli.Create(ctx, keysSecret)).To(Succeed()) + + _, _, rootCert, err := support.CreateCertificates(false) + Expect(err).NotTo(HaveOccurred()) + rootCertSecret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-cert", + Namespace: namespace.Name, + }, + Data: map[string][]byte{ + "cert": rootCert, + }, + } + Expect(cli.Create(ctx, rootCertSecret)).To(Succeed()) + + correctTrillianAddr = fmt.Sprintf("trillian-logserver.%s.svc.cluster.local:8091", namespace.Name) + + By("Creating CTLog instance") + ctlogCR = &v1alpha1.CTlog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: namespace.Name, + }, + Spec: v1alpha1.CTlogSpec{ + Trillian: v1alpha1.TrillianService{ + Address: fmt.Sprintf("trillian-logserver.%s.svc.cluster.local", namespace.Name), + Port: ptr.To(int32(8091)), + }, + PrivateKeyRef: &v1alpha1.SecretKeySelector{ + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: "test-ctlog-keys", + }, + Key: "private", + }, + PublicKeyRef: &v1alpha1.SecretKeySelector{ + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: "test-ctlog-keys", + }, + Key: "public", + }, + RootCertificates: []v1alpha1.SecretKeySelector{ + { + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: "test-root-cert", + }, + Key: "cert", + }, + }, + }, + } + Expect(cli.Create(ctx, ctlogCR)).To(Succeed()) + + By("Waiting for initial CTLog deployment") + ctlog.Verify(ctx, cli, namespace.Name, ctlogCR.Name) + }) + + Describe("CTLog self-healing when config secret is missing or invalid", func() { + + It("should have a config secret with correct Trillian address", func(ctx SpecContext) { + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + Expect(c).NotTo(BeNil()) + Expect(c.Status.ServerConfigRef).NotTo(BeNil()) + Expect(c.Status.ServerConfigRef.Name).NotTo(BeEmpty()) + + originalSecretName = c.Status.ServerConfigRef.Name + + // Verify secret exists with correct Trillian configuration + secret, err := ctlog.GetConfigSecret(ctx, cli, namespace.Name, originalSecretName) + Expect(err).NotTo(HaveOccurred()) + Expect(secret).NotTo(BeNil()) + Expect(secret.Data).To(HaveKey(ctlogUtils.ConfigKey)) + + configContent := ctlog.GetTrillianAddressFromSecret(secret) + Expect(configContent).To(ContainSubstring(correctTrillianAddr), + "Config should contain correct Trillian address") + }) + + It("should simulate cluster recreation by deleting the config secret", func(ctx SpecContext) { + By("Deleting config secret to simulate disaster scenario") + err := ctlog.DeleteConfigSecret(ctx, cli, namespace.Name, originalSecretName) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying secret deletion") + Eventually(func() bool { + _, err := ctlog.GetConfigSecret(ctx, cli, namespace.Name, originalSecretName) + return errors.IsNotFound(err) + }).WithTimeout(30*time.Second).Should(BeTrue(), "Secret should be deleted") + }) + + It("should trigger reconciliation by updating CTLog annotation", func(ctx SpecContext) { + Eventually(func(g Gomega) error { + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + g.Expect(c).NotTo(BeNil()) + if c.Annotations == nil { + c.Annotations = make(map[string]string) + } + c.Annotations["test.trigger/reconcile"] = time.Now().Format(time.RFC3339) + return cli.Update(ctx, c) + }).WithTimeout(30 * time.Second).Should(Succeed()) + }) + + It("should automatically detect and recreate the missing config secret", func(ctx SpecContext) { + By("Waiting for operator to detect and fix the missing config") + + // The operator should detect the missing secret and recreate it + // with the correct Trillian configuration from the CTLog spec + Eventually(func(g Gomega) bool { + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + g.Expect(c).NotTo(BeNil()) + + if c.Status.ServerConfigRef != nil { + secret, err := ctlog.GetConfigSecret(ctx, cli, namespace.Name, c.Status.ServerConfigRef.Name) + if err == nil && secret != nil { + configContent := ctlog.GetTrillianAddressFromSecret(secret) + if strings.Contains(configContent, correctTrillianAddr) { + return true + } + } + } + return false + }).WithPolling(5*time.Second).Should(BeTrue(), + "Operator should detect missing/invalid config and recreate it with correct Trillian address") + }) + + It("should have a valid config secret after recreation", func(ctx SpecContext) { + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + Expect(c).NotTo(BeNil()) + Expect(c.Status.ServerConfigRef).NotTo(BeNil()) + + newSecretName := c.Status.ServerConfigRef.Name + + secret, err := ctlog.GetConfigSecret(ctx, cli, namespace.Name, newSecretName) + Expect(err).NotTo(HaveOccurred()) + Expect(secret).NotTo(BeNil()) + Expect(secret.Data).To(HaveKey(ctlogUtils.ConfigKey)) + + // Verify config contains correct Trillian address + configData := secret.Data[ctlogUtils.ConfigKey] + expectedTrillianAddr := fmt.Sprintf("trillian-logserver.%s.svc.cluster.local:8091", namespace.Name) + Expect(string(configData)).To(ContainSubstring(expectedTrillianAddr), + "Config should contain correct Trillian address") + }) + + It("should have CTLog deployment running with the new config", func(ctx SpecContext) { + Eventually(condition.DeploymentIsRunning). + WithContext(ctx). + WithArguments(cli, namespace.Name, ctlogActions.ComponentName). + Should(BeTrue(), "CTLog deployment should be running after config recreation") + }) + + It("should have CTLog pod running without being stuck waiting for secret", func(ctx SpecContext) { + Eventually(func(g Gomega) bool { + pod := ctlog.GetServerPod(ctx, cli, namespace.Name) + g.Expect(pod).NotTo(BeNil()) + return pod.Status.Phase == v1.PodRunning + }).Should(BeTrue(), + "CTLog pod should reach Running phase, proving it's not stuck waiting for a missing secret") + }) + + It("should report Ready status after successful recovery", func(ctx SpecContext) { + Eventually(func(g Gomega) string { + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + g.Expect(c).NotTo(BeNil()) + readyCond := meta.FindStatusCondition(c.Status.Conditions, constants.Ready) + if readyCond == nil { + return "" + } + return readyCond.Reason + }).Should(Equal(constants.Ready), + "CTLog should transition to Ready status after config secret recreation") + }) + + It("should preserve TreeID after secret recreation", func(ctx SpecContext) { + // TreeID is preserved because it's stored in the CR status, + // and the CR itself was not deleted during this recovery scenario + c := ctlog.Get(ctx, cli, namespace.Name, ctlogCR.Name) + Expect(c).NotTo(BeNil()) + Expect(c.Status.TreeID).NotTo(BeNil(), "TreeID should remain stable across secret recreation") + }) + + AfterAll(func(ctx SpecContext) { + if ctlogCR != nil { + _ = cli.Delete(ctx, ctlogCR) + } + }) + }) +}) diff --git a/test/e2e/support/tas/ctlog/ctlog.go b/test/e2e/support/tas/ctlog/ctlog.go index 53673f4e2..8c131d6c2 100644 --- a/test/e2e/support/tas/ctlog/ctlog.go +++ b/test/e2e/support/tas/ctlog/ctlog.go @@ -66,3 +66,40 @@ func CreateSecret(ns string, name string) *v1.Secret { }, } } + +// GetConfigSecret retrieves the ctlog-config secret by name +func GetConfigSecret(ctx context.Context, cli client.Client, namespace string, secretName string) (*v1.Secret, error) { + secret := &v1.Secret{} + err := cli.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: secretName, + }, secret) + return secret, err +} + +// DeleteConfigSecret deletes a config secret +func DeleteConfigSecret(ctx context.Context, cli client.Client, namespace string, secretName string) error { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + } + return cli.Delete(ctx, secret) +} + +// GetTrillianAddressFromSecret extracts Trillian address from config secret +func GetTrillianAddressFromSecret(secret *v1.Secret) string { + if secret == nil { + return "" + } + configData, ok := secret.Data["config"] + if !ok { + return "" + } + // Simple extraction - look for backend_spec pattern + // In protobuf text format: backend_spec: "address:port" + config := string(configData) + // Return config for substring matching in tests + return config +} diff --git a/trigger-konflux-builds.txt b/trigger-konflux-builds.txt index a6a4267d4..908b72b90 100644 --- a/trigger-konflux-builds.txt +++ b/trigger-konflux-builds.txt @@ -1 +1 @@ -2025-09-23,21-51-00 +2025-11-03,16-00-00