From 0acaf882bceabca229aab061387153e598d9d573 Mon Sep 17 00:00:00 2001 From: Juan Manuel Parrilla Madrid Date: Thu, 19 Mar 2026 11:20:18 +0100 Subject: [PATCH] feat(cpo): add fetch-etcd-certs subcommand for HCPEtcdBackup Job Add a new `fetch-etcd-certs` subcommand to the control-plane-operator binary that fetches the `etcd-client-tls` Secret and `etcd-ca` ConfigMap from an HCP namespace and writes the TLS certificates to disk. This subcommand will run as InitContainer 1 in the HCPEtcdBackup Job, preparing the TLS material needed by `etcdctl snapshot save` in InitContainer 2. This is part of CNTRLPLANE-2678 (HCPEtcdBackup Controller). The full controller implementation (reconciler, Job construction, NetworkPolicy, retention) is not included in this commit. This change covers only the OCP payload component (CPO binary) which needs to be delivered ahead of the rest of the feature. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Juan Manuel Parrilla Madrid --- Dockerfile.dev | 3 +- control-plane-operator/main.go | 3 + etcd-backup/fetchcerts.go | 135 ++++++++++++++++++++++ etcd-backup/fetchcerts_test.go | 202 +++++++++++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 etcd-backup/fetchcerts.go create mode 100644 etcd-backup/fetchcerts_test.go diff --git a/Dockerfile.dev b/Dockerfile.dev index bc8323c8581a..91e8f0cbee8e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -21,7 +21,8 @@ RUN cd /usr/bin && \ ln -s control-plane-operator ignition-server && \ ln -s control-plane-operator konnectivity-socks5-proxy && \ ln -s control-plane-operator availability-prober && \ - ln -s control-plane-operator token-minter + ln -s control-plane-operator token-minter && \ + ln -s control-plane-operator fetch-etcd-certs ENTRYPOINT ["/usr/bin/hypershift"] diff --git a/control-plane-operator/main.go b/control-plane-operator/main.go index 1b5a3caec116..3c8df7b90a7a 100644 --- a/control-plane-operator/main.go +++ b/control-plane-operator/main.go @@ -101,6 +101,8 @@ func commandFor(name string) *cobra.Command { cmd = syncfgconfigmap.NewRunCommand() case "sync-global-pullsecret": cmd = syncglobalpullsecret.NewRunCommand() + case "fetch-etcd-certs": + cmd = etcdbackup.NewFetchCertsCommand() case "endpoint-resolver": cmd = endpointresolver.NewStartCommand() default: @@ -153,6 +155,7 @@ func defaultCommand() *cobra.Command { cmd.AddCommand(kubernetesdefaultproxy.NewStartCommand()) cmd.AddCommand(dnsresolver.NewCommand()) cmd.AddCommand(etcdbackup.NewStartCommand()) + cmd.AddCommand(etcdbackup.NewFetchCertsCommand()) cmd.AddCommand(kasbootstrap.NewRunCommand()) cmd.AddCommand(syncfgconfigmap.NewRunCommand()) cmd.AddCommand(syncglobalpullsecret.NewRunCommand()) diff --git a/etcd-backup/fetchcerts.go b/etcd-backup/fetchcerts.go new file mode 100644 index 000000000000..fe0cb4591fe1 --- /dev/null +++ b/etcd-backup/fetchcerts.go @@ -0,0 +1,135 @@ +package etcdbackup + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/pki" + "github.com/openshift/hypershift/support/certs" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + + "github.com/spf13/cobra" +) + +type fetchCertsOptions struct { + hcpNamespace string + outputDir string + etcdClientSecret string + etcdCAConfigMap string +} + +// NewFetchCertsCommand returns a cobra command that fetches etcd TLS certificates +// from an HCP namespace and writes them to disk for use by backup Jobs. +func NewFetchCertsCommand() *cobra.Command { + opts := fetchCertsOptions{ + outputDir: "/etc/etcd-certs", + etcdClientSecret: manifests.EtcdClientSecret("").Name, + etcdCAConfigMap: manifests.EtcdSignerCAConfigMap("").Name, + } + + cmd := &cobra.Command{ + Use: "fetch-etcd-certs", + Short: "Fetch etcd TLS certificates from an HCP namespace and write them to disk", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + return runFetchCerts(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.hcpNamespace, "hcp-namespace", "", "namespace of the HostedControlPlane containing etcd secrets") + cmd.Flags().StringVar(&opts.outputDir, "output-dir", opts.outputDir, "directory to write the TLS certificate files") + cmd.Flags().StringVar(&opts.etcdClientSecret, "etcd-client-secret", opts.etcdClientSecret, "name of the etcd client TLS Secret") + cmd.Flags().StringVar(&opts.etcdCAConfigMap, "etcd-ca-configmap", opts.etcdCAConfigMap, "name of the etcd CA ConfigMap") + + _ = cmd.MarkFlagRequired("hcp-namespace") + + return cmd +} + +func runFetchCerts(ctx context.Context, opts fetchCertsOptions) error { + k8sClient, err := newK8sClient() + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + return fetchAndWriteCerts(ctx, k8sClient, opts) +} + +func newK8sClient() (client.Client, error) { + cfg, err := config.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to get kubeconfig: %w", err) + } + + c, err := client.New(cfg, client.Options{}) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + return c, nil +} + +func fetchAndWriteCerts(ctx context.Context, k8sClient client.Client, opts fetchCertsOptions) error { + clientSecret := manifests.EtcdClientSecret(opts.hcpNamespace) + clientSecret.Name = opts.etcdClientSecret + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(clientSecret), clientSecret); err != nil { + return fmt.Errorf("failed to get etcd client TLS secret %s/%s: %w", opts.hcpNamespace, opts.etcdClientSecret, err) + } + + caConfigMap := manifests.EtcdSignerCAConfigMap(opts.hcpNamespace) + caConfigMap.Name = opts.etcdCAConfigMap + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(caConfigMap), caConfigMap); err != nil { + return fmt.Errorf("failed to get etcd CA configmap %s/%s: %w", opts.hcpNamespace, opts.etcdCAConfigMap, err) + } + + certData, ok := clientSecret.Data[pki.EtcdClientCrtKey] + if !ok { + return fmt.Errorf("etcd client secret %s/%s missing key %q", opts.hcpNamespace, opts.etcdClientSecret, pki.EtcdClientCrtKey) + } + + keyData, ok := clientSecret.Data[pki.EtcdClientKeyKey] + if !ok { + return fmt.Errorf("etcd client secret %s/%s missing key %q", opts.hcpNamespace, opts.etcdClientSecret, pki.EtcdClientKeyKey) + } + + caData, ok := caConfigMap.Data[certs.CASignerCertMapKey] + if !ok { + return fmt.Errorf("etcd CA configmap %s/%s missing key %q", opts.hcpNamespace, opts.etcdCAConfigMap, certs.CASignerCertMapKey) + } + + if err := os.MkdirAll(opts.outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", opts.outputDir, err) + } + + type certFile struct { + name string + data []byte + } + files := []certFile{ + {pki.EtcdClientCrtKey, certData}, + {pki.EtcdClientKeyKey, keyData}, + {certs.CASignerCertMapKey, []byte(caData)}, + } + + for _, f := range files { + name, data := f.name, f.data + path := filepath.Join(opts.outputDir, name) + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + log.Printf("wrote %s (%d bytes)", path, len(data)) + } + + return nil +} diff --git a/etcd-backup/fetchcerts_test.go b/etcd-backup/fetchcerts_test.go new file mode 100644 index 000000000000..418f2143af19 --- /dev/null +++ b/etcd-backup/fetchcerts_test.go @@ -0,0 +1,202 @@ +package etcdbackup + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/pki" + "github.com/openshift/hypershift/support/certs" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + crclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestFetchAndWriteCerts(t *testing.T) { + hcpNamespace := "hcp-test" + etcdClientSecretName := manifests.EtcdClientSecret("").Name + etcdCAConfigMapName := manifests.EtcdSignerCAConfigMap("").Name + + fullSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: etcdClientSecretName, + Namespace: hcpNamespace, + }, + Data: map[string][]byte{ + pki.EtcdClientCrtKey: []byte("fake-cert-data"), + pki.EtcdClientKeyKey: []byte("fake-key-data"), + }, + } + + fullCAConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: etcdCAConfigMapName, + Namespace: hcpNamespace, + }, + Data: map[string]string{ + certs.CASignerCertMapKey: "fake-ca-data", + }, + } + + tests := []struct { + name string + objects []crclient.Object + outputDir func(t *testing.T) string + expectErr bool + errSubstring string + expectedFiles map[string]string + }{ + { + name: "When all resources exist it should write all cert files", + objects: []crclient.Object{fullSecret, fullCAConfigMap}, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectedFiles: map[string]string{ + pki.EtcdClientCrtKey: "fake-cert-data", + pki.EtcdClientKeyKey: "fake-key-data", + certs.CASignerCertMapKey: "fake-ca-data", + }, + }, + { + name: "When output directory does not exist it should create it and write files", + objects: []crclient.Object{fullSecret, fullCAConfigMap}, + outputDir: func(t *testing.T) string { return filepath.Join(t.TempDir(), "nested", "certs") }, + expectedFiles: map[string]string{ + pki.EtcdClientCrtKey: "fake-cert-data", + pki.EtcdClientKeyKey: "fake-key-data", + certs.CASignerCertMapKey: "fake-ca-data", + }, + }, + { + name: "When etcd-client-tls secret is missing it should return an error", + objects: []crclient.Object{fullCAConfigMap}, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectErr: true, + errSubstring: "failed to get etcd client TLS secret", + }, + { + name: "When etcd-ca configmap is missing it should return an error", + objects: []crclient.Object{fullSecret}, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectErr: true, + errSubstring: "failed to get etcd CA configmap", + }, + { + name: "When etcd-client.crt is missing from the secret it should return an error", + objects: []crclient.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: etcdClientSecretName, + Namespace: hcpNamespace, + }, + Data: map[string][]byte{ + pki.EtcdClientKeyKey: []byte("fake-key-data"), + }, + }, + fullCAConfigMap, + }, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectErr: true, + errSubstring: "missing key", + }, + { + name: "When etcd-client.key is missing from the secret it should return an error", + objects: []crclient.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: etcdClientSecretName, + Namespace: hcpNamespace, + }, + Data: map[string][]byte{ + pki.EtcdClientCrtKey: []byte("fake-cert-data"), + }, + }, + fullCAConfigMap, + }, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectErr: true, + errSubstring: "missing key", + }, + { + name: "When ca.crt is missing from the configmap it should return an error", + objects: []crclient.Object{ + fullSecret, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: etcdCAConfigMapName, + Namespace: hcpNamespace, + }, + Data: map[string]string{}, + }, + }, + outputDir: func(t *testing.T) string { return t.TempDir() }, + expectErr: true, + errSubstring: "missing key", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + scheme := runtime.NewScheme() + g.Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.objects...). + Build() + + outputDir := tc.outputDir(t) + opts := fetchCertsOptions{ + hcpNamespace: hcpNamespace, + outputDir: outputDir, + etcdClientSecret: etcdClientSecretName, + etcdCAConfigMap: etcdCAConfigMapName, + } + + err := fetchAndWriteCerts(context.Background(), k8sClient, opts) + + if tc.expectErr { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.errSubstring)) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + for name, expectedContent := range tc.expectedFiles { + path := filepath.Join(outputDir, name) + data, err := os.ReadFile(path) + g.Expect(err).ToNot(HaveOccurred(), "failed to read %s", name) + g.Expect(string(data)).To(Equal(expectedContent)) + + info, err := os.Stat(path) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)), "file %s should have 0600 permissions", name) + } + }) + } +} + +func TestNewFetchCertsCommand(t *testing.T) { + g := NewWithT(t) + cmd := NewFetchCertsCommand() + + g.Expect(cmd.Use).To(Equal("fetch-etcd-certs")) + + for _, flag := range []string{"hcp-namespace", "output-dir", "etcd-client-secret", "etcd-ca-configmap"} { + g.Expect(cmd.Flags().Lookup(flag)).ToNot(BeNil(), "expected flag %q to exist", flag) + } + + // hcp-namespace should be required + err := cmd.ValidateRequiredFlags() + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("hcp-namespace")) +}