diff --git a/cli/cmd/install_openbao.go b/cli/cmd/install_openbao.go index 705ae0ce..aafef671 100644 --- a/cli/cmd/install_openbao.go +++ b/cli/cmd/install_openbao.go @@ -4,12 +4,17 @@ package cmd import ( + "bufio" "context" + "errors" "fmt" + "io" + "log" "os" "os/exec" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -29,12 +34,15 @@ type InstallOpenBaoCmd struct { // InstallOpenBaoOpts holds the CLI flags for the OpenBao installer. type InstallOpenBaoOpts struct { *GlobalOptions + Namespace string SecretsEngineName string BaoUsername string DRBackupPath string Replicas int StorageSize string Timeout time.Duration + AgeKeyFile string + Yes bool } func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { @@ -48,12 +56,16 @@ func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { } fallbackDir := filepath.Join(configDir, "sops", "age") - recipient, keyPath, err := installer.ResolveAgeKey(fallbackDir) + // Pass --age-key-file explicitly so ResolveAgeKey prefers it without + // mutating the process environment. When empty, the normal + // auto-discovery chain (env vars, default location, generation) applies. + recipient, keyPath, err := installer.ResolveAgeKey(c.Opts.AgeKeyFile, fallbackDir) if err != nil { return fmt.Errorf("resolving age key: %w", err) } cfg := installer.OpenBaoInstallerConfig{ + Namespace: c.Opts.Namespace, SecretsEngineName: c.Opts.SecretsEngineName, Username: c.Opts.BaoUsername, DRBackupPath: c.Opts.DRBackupPath, @@ -69,6 +81,31 @@ func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { return fmt.Errorf("initializing openbao installer: %w", err) } + inst.ConfirmFunc = func() error { + if c.Opts.Yes { + return nil + } + + log.Printf("\nWARNING: No DR backup found at: %s", c.Opts.DRBackupPath) + log.Println("This will perform a FRESH OpenBao initialization:") + log.Println(" - Existing Vault CR will be deleted") + log.Println(" - All OpenBao pods will be terminated") + log.Println(" - Persistent volume claims (data) will be deleted") + log.Println(" - Existing unseal keys will be removed") + log.Println("If you intended to restore from a backup, verify --dr-backup-path is correct.") + log.Print("Type 'yes' to continue: ") + + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if strings.TrimSpace(strings.ToLower(input)) != "yes" { + return fmt.Errorf("installation cancelled: confirmation not given (type 'yes' or pass --yes to proceed)") + } + return nil + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -100,12 +137,15 @@ func AddInstallOpenBaoCmd(install *cobra.Command, opts *GlobalOptions) { }, Opts: &InstallOpenBaoOpts{GlobalOptions: opts}, } + openbao.cmd.Flags().StringVarP(&openbao.Opts.Namespace, "namespace", "n", installer.DefaultOpenBaoNamespace, "Kubernetes namespace for OpenBao deployment") openbao.cmd.Flags().StringVar(&openbao.Opts.SecretsEngineName, "secrets-engine", "cs-secrets-engine", "Name of the KV-v2 secrets engine to provision") openbao.cmd.Flags().StringVar(&openbao.Opts.BaoUsername, "bao-user", "admin", "Username for the userpass auth method (ignored on restore, uses DR backup value)") openbao.cmd.Flags().StringVar(&openbao.Opts.DRBackupPath, "dr-backup-path", "", "Path for SOPS-encrypted DR backup file (required)") openbao.cmd.Flags().IntVar(&openbao.Opts.Replicas, "replicas", 1, "Number of OpenBao replicas (1 for single-node, odd number >= 3 for HA)") openbao.cmd.Flags().StringVar(&openbao.Opts.StorageSize, "storage-size", "10Gi", "PVC storage size for each OpenBao replica") openbao.cmd.Flags().DurationVar(&openbao.Opts.Timeout, "timeout", 5*time.Minute, "Timeout for waiting on initialization") + openbao.cmd.Flags().StringVarP(&openbao.Opts.AgeKeyFile, "age-key-file", "k", "", "Path to age private key file for SOPS encryption/decryption (auto-detected if not set)") + openbao.cmd.Flags().BoolVarP(&openbao.Opts.Yes, "yes", "y", false, "Auto-approve re-initialization of an existing deployment when no DR backup is found") util.MarkFlagRequired(openbao.cmd, "dr-backup-path") diff --git a/docs/oms_install_openbao.md b/docs/oms_install_openbao.md index 78ec9936..fd3bb63a 100644 --- a/docs/oms_install_openbao.md +++ b/docs/oms_install_openbao.md @@ -37,13 +37,16 @@ $ oms install openbao --dr-backup-path ./backups/cluster-1.enc.json --timeout 10 ### Options ``` + -k, --age-key-file string Path to age private key file for SOPS encryption/decryption (auto-detected if not set) --bao-user string Username for the userpass auth method (ignored on restore, uses DR backup value) (default "admin") --dr-backup-path string Path for SOPS-encrypted DR backup file (required) -h, --help help for openbao + -n, --namespace string Kubernetes namespace for OpenBao deployment (default "vault") --replicas int Number of OpenBao replicas (1 for single-node, odd number >= 3 for HA) (default 1) --secrets-engine string Name of the KV-v2 secrets engine to provision (default "cs-secrets-engine") --storage-size string PVC storage size for each OpenBao replica (default "10Gi") --timeout duration Timeout for waiting on initialization (default 5m0s) + -y, --yes Auto-approve re-initialization of an existing deployment when no DR backup is found ``` ### SEE ALSO diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index b1bbcd49..f2a706bf 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -477,7 +477,7 @@ func (b *LocalBootstrapper) EnsureSecrets() error { } func (b *LocalBootstrapper) ResolveAgeKey() error { - recipient, keyPath, err := installer.ResolveAgeKey(filepath.Dir(b.Env.SecretsFilePath)) + recipient, keyPath, err := installer.ResolveAgeKey("", filepath.Dir(b.Env.SecretsFilePath)) if err != nil { return fmt.Errorf("failed to resolve age key: %w", err) } diff --git a/internal/installer/export_test.go b/internal/installer/export_test.go new file mode 100644 index 00000000..e24a9168 --- /dev/null +++ b/internal/installer/export_test.go @@ -0,0 +1,53 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + + corev1 "k8s.io/api/core/v1" +) + +// Test helpers — exported only during `go test` so external test packages +// (package installer_test) can access unexported fields and methods. + +func (o *OpenBaoInstaller) SetCtx(ctx context.Context) { + o.ctx = ctx +} + +func (o *OpenBaoInstaller) SetUnsealSecret(secret *corev1.Secret) { + o.unsealSecret = secret +} + +func (o *OpenBaoInstaller) SetPassword(password string) { + o.password = password +} + +func (o *OpenBaoInstaller) GetDRBackupExists() bool { + return o.drBackupExists +} + +func (o *OpenBaoInstaller) GetUnsealSecret() *corev1.Secret { + return o.unsealSecret +} + +func (o *OpenBaoInstaller) HasExistingDeployment() (bool, error) { + return o.hasExistingDeployment() +} + +func (o *OpenBaoInstaller) SetBackupUnsealKeys(keys map[string][]byte) { + o.backupUnsealKeys = keys +} + +func (o *OpenBaoInstaller) EnsureUnsealSecret() error { + return o.ensureUnsealSecret(o.Clientset.CoreV1().Secrets(o.Config.Namespace)) +} + +func (o *OpenBaoInstaller) ReleaseExistsInTargetNamespace(releaseName string) (bool, error) { + return o.releaseExistsInTargetNamespace(releaseName) +} + +func (o *OpenBaoInstaller) OperatorInstalledClusterWide() (bool, error) { + return o.operatorInstalledClusterWide() +} diff --git a/internal/installer/manifests/openbao/vault-cr.yaml b/internal/installer/manifests/openbao/vault-cr.yaml index a9390c15..f80c5c88 100644 --- a/internal/installer/manifests/openbao/vault-cr.yaml +++ b/internal/installer/manifests/openbao/vault-cr.yaml @@ -77,12 +77,12 @@ spec: fieldRef: fieldPath: metadata.name - name: BAO_CLUSTER_ADDR - value: "http://$(POD_NAME).{{ .Namespace }}.svc.cluster.local:8201" + value: "http://$(POD_NAME).openbao.{{ .Namespace }}.svc.cluster.local:8201" - name: BAO_API_ADDR - value: "http://$(POD_NAME).{{ .Namespace }}.svc.cluster.local:8200" + value: "http://$(POD_NAME).openbao.{{ .Namespace }}.svc.cluster.local:8200" unsealConfig: options: - preFlightChecks: false + preFlightChecks: true storeRootToken: false kubernetes: secretNamespace: {{ .Namespace }} diff --git a/internal/installer/openbao.go b/internal/installer/openbao.go index 1f853b0f..a1a6d2e3 100644 --- a/internal/installer/openbao.go +++ b/internal/installer/openbao.go @@ -13,6 +13,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "text/template" "time" @@ -24,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" ) //go:embed manifests/openbao/vault-cr.yaml @@ -31,7 +33,8 @@ var vaultCRTemplate []byte const ( openBaoUnsealSecretName = "openbao-unseal-keys" - openBaoNamespace = "vault" + openBaoHeadlessService = "openbao" + DefaultOpenBaoNamespace = "vault" openBaoImage = "quay.io/openbao/openbao:2.1.0" bankVaultsImage = "ghcr.io/bank-vaults/bank-vaults:v1.31.3" bankVaultsChartRepo = "oci://ghcr.io/bank-vaults/helm-charts" @@ -40,10 +43,19 @@ const ( defaultPasswordLength = 32 pollInterval = 5 * time.Second maxPollInterval = 30 * time.Second + + // vaultCRLabelKey/Value identify resources (pods, PVCs) managed by the + // bank-vaults operator for our "openbao" Vault CR. + vaultCRLabelKey = "vault_cr" + vaultCRLabelValue = "openbao" ) +// vaultCRLabelSelector selects all resources belonging to the "openbao" Vault CR. +var vaultCRLabelSelector = labels.SelectorFromSet(labels.Set{vaultCRLabelKey: vaultCRLabelValue}).String() + // OpenBaoInstallerConfig holds all configurable parameters for the OpenBao bootstrap. type OpenBaoInstallerConfig struct { + Namespace string SecretsEngineName string Username string DRBackupPath string @@ -63,16 +75,28 @@ type OpenBaoInstaller struct { Logger *bootstrap.StepLogger Config OpenBaoInstallerConfig + // ConfirmFunc is called when the destructive fresh-install path is about + // to proceed (no DR backup found). If it returns an error the install is + // aborted. When nil the install proceeds without confirmation. + ConfirmFunc func() error + // Intermediate state populated during the install pipeline - ctx context.Context - password string - drBackupExists bool - unsealSecret *corev1.Secret + ctx context.Context + password string + drBackupExists bool + unsealSecret *corev1.Secret + backupUnsealKeys map[string][]byte // unseal keys from DR backup, used during WaitForInitialization } // NewOpenBaoInstaller constructs an OpenBaoInstaller with real Kubernetes and Helm clients. func NewOpenBaoInstaller(cfg OpenBaoInstallerConfig) (*OpenBaoInstaller, error) { - helm, err := NewHelmClient(openBaoNamespace) + // Apply namespace default here so the Helm client is always initialised with + // the correct namespace, even when --namespace was not supplied by the caller. + if cfg.Namespace == "" { + cfg.Namespace = DefaultOpenBaoNamespace + } + + helm, err := NewHelmClient(cfg.Namespace) if err != nil { return nil, fmt.Errorf("creating helm client: %w", err) } @@ -94,6 +118,9 @@ func NewOpenBaoInstaller(cfg OpenBaoInstallerConfig) (*OpenBaoInstaller, error) const defaultTimeout = 5 * time.Minute func (o *OpenBaoInstaller) validateConfig() error { + if o.Config.Namespace == "" { + o.Config.Namespace = DefaultOpenBaoNamespace + } r := o.Config.Replicas if r < 1 { return fmt.Errorf("--replicas must be >= 1, got %d", r) @@ -121,6 +148,21 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { return fmt.Errorf("pre-flight DR check failed: %w", err) } + // Only warn when an existing deployment is detected but no DR backup was + // found — the user likely supplied the wrong backup path. A genuine first + // install (no existing deployment) proceeds without prompting. + if !o.drBackupExists && o.ConfirmFunc != nil { + exists, checkErr := o.hasExistingDeployment() + if checkErr != nil { + return fmt.Errorf("checking for existing deployment: %w", checkErr) + } + if exists { + if err := o.ConfirmFunc(); err != nil { + return err + } + } + } + // Only generate a new password for fresh installs; on DR restore the // password was already extracted from the backup in PreFlightDRCheck. if !o.drBackupExists { @@ -130,6 +172,17 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { } } + // Ensure the namespace exists before anything that writes into it (the Helm + // release metadata, the Vault CR, cleanup of stale resources). This is the + // single namespace-creation path — DeployBankVaultsOperator deploys with + // CreateNamespace:false and relies on the namespace already being present. + err = o.Logger.Step("Ensuring namespace exists", func() error { + return o.ensureNamespace(o.ctx) + }) + if err != nil { + return fmt.Errorf("failed to ensure namespace: %w", err) + } + err = o.Logger.Step("Deploying Bank-Vaults Operator", o.DeployBankVaultsOperator) if err != nil { return fmt.Errorf("failed to deploy Bank-Vaults Operator: %w", err) @@ -138,12 +191,11 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { // If a previous install left behind an unseal-keys Secret (e.g. Raft storage // was wiped or the cluster was rebuilt), those keys belong to the old master // key and will cause bank-vaults to permanently fail unsealing the new instance. - // We delete the Vault CR first and wait for pods to exit, otherwise the old - // sidecar's retry loop re-creates the secret after we remove it. + // We clean the full prior install state: Vault CR, pods, PVCs, and the secret. if !o.drBackupExists { - err = o.Logger.Step("Removing stale unseal keys", o.DeleteStaleUnsealKeys) + err = o.Logger.Step("Cleaning stale install state", o.CleanStaleInstallState) if err != nil { - return fmt.Errorf("failed to remove stale unseal keys: %w", err) + return fmt.Errorf("failed to clean stale install state: %w", err) } } @@ -157,6 +209,11 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { return fmt.Errorf("failed waiting for initialization: %w", err) } + err = o.Logger.Step("Waiting for all OpenBao pods to be ready", o.WaitForPodsReady) + if err != nil { + return fmt.Errorf("failed waiting for pods to be ready: %w", err) + } + err = o.Logger.Step("Extracting and encrypting DR backup", o.ExtractAndEncrypt) if err != nil { return fmt.Errorf("failed to extract and encrypt DR backup: %w", err) @@ -167,9 +224,10 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { } // PreFlightDRCheck checks if a SOPS-encrypted DR backup exists. -// If it does, the backup is decrypted and the unseal keys Secret is pre-applied -// so that Bank-Vaults can unseal an existing OpenBao instance. -// Sets o.drBackupExists to true if a DR backup was found and restored. +// If it does, the backup is decrypted and the unseal keys are stored in memory +// (backupUnsealKeys) for later use by WaitForInitialization, which handles +// creating/updating the Kubernetes Secret with retry logic. +// Sets o.drBackupExists to true if a DR backup was found and processed. func (o *OpenBaoInstaller) PreFlightDRCheck() error { if o.Config.DRBackupPath == "" { return fmt.Errorf("DRBackupPath must be set") @@ -184,7 +242,7 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { return fmt.Errorf("checking DR backup file %s: %w", o.Config.DRBackupPath, err) } - o.Logger.Logf("Found existing DR backup at %s — restoring unseal keys", o.Config.DRBackupPath) + o.Logger.Logf("Found existing DR backup at %s", o.Config.DRBackupPath) decrypted, err := DecryptFileWithSOPS(o.Config.DRBackupPath, o.Config.AgeKeyPath) if err != nil { @@ -196,39 +254,13 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { return fmt.Errorf("parsing DR backup: %w", err) } - secretData := make(map[string][]byte) + // Store backup unseal keys for later use in WaitForInitialization. + // We do NOT write them to Kubernetes yet — the operator may delete or + // recreate the secret during Vault CR reconciliation, so we defer + // secret creation to the initialization wait loop where we can retry. + o.backupUnsealKeys = make(map[string][]byte) for k, v := range backup.UnsealKeys { - secretData[k] = []byte(v) - } - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: openBaoUnsealSecretName, - Namespace: openBaoNamespace, - }, - Data: secretData, - } - - if err := o.ensureNamespace(o.ctx); err != nil { - return err - } - - secretsClient := o.Clientset.CoreV1().Secrets(openBaoNamespace) - existing, err := secretsClient.Get(o.ctx, openBaoUnsealSecretName, metav1.GetOptions{}) - if err != nil { - if !k8serrors.IsNotFound(err) { - return fmt.Errorf("checking for existing secret: %w", err) - } - _, err = secretsClient.Create(o.ctx, secret, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("creating unseal secret from DR backup: %w", err) - } - } else { - secret.ResourceVersion = existing.ResourceVersion - _, err = secretsClient.Update(o.ctx, secret, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("updating unseal secret from DR backup: %w", err) - } + o.backupUnsealKeys[k] = []byte(v) } // Reuse the password and username from the DR backup so the Vault CR is @@ -239,7 +271,6 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { o.password = backup.Password o.Config.Username = backup.Username - o.Logger.Logf("Unseal keys restored from DR backup successfully") o.drBackupExists = true return nil } @@ -252,18 +283,79 @@ func (o *OpenBaoInstaller) GeneratePassword() error { } // DeployBankVaultsOperator installs or upgrades the Bank-Vaults Operator Helm chart. -// This is idempotent via UpgradeChart with InstallIfNotExist. +// +// The operator is cluster-scoped (it creates ClusterRoles, ClusterRoleBindings) +// and watches Vault CRs across all namespaces. If the operator is already +// installed in a different namespace, we skip re-deployment — one instance +// is sufficient for the entire cluster. func (o *OpenBaoInstaller) DeployBankVaultsOperator() error { cfg := ChartConfig{ - ReleaseName: "vault-operator", - ChartName: bankVaultsChartRepo + "/" + bankVaultsChartName, - Version: bankVaultsChartVersion, - Namespace: openBaoNamespace, - CreateNamespace: true, + ReleaseName: "vault-operator", + ChartName: bankVaultsChartRepo + "/" + bankVaultsChartName, + Version: bankVaultsChartVersion, + Namespace: o.Config.Namespace, + // Namespace creation is handled exclusively by ensureNamespace, which + // runs earlier in the install pipeline — keep a single creation path. + CreateNamespace: false, Values: map[string]interface{}{}, } - return o.Helm.UpgradeChart(o.ctx, cfg, UpgradeChartOptions{InstallIfNotExist: true}) + // Upgrade in place when a release already exists in the target namespace. + exists, err := o.releaseExistsInTargetNamespace(cfg.ReleaseName) + if err != nil { + return err + } + if exists { + return o.Helm.UpgradeChart(o.ctx, cfg, UpgradeChartOptions{}) + } + + // Release not found in target namespace. Skip when the operator is already + // deployed cluster-wide (in another namespace) — one instance suffices. + clusterWide, err := o.operatorInstalledClusterWide() + if err != nil { + return err + } + if clusterWide { + o.Logger.Logf("Bank-Vaults Operator already installed in the cluster, skipping deployment") + return nil + } + + // Operator does not exist — perform fresh install. + return o.Helm.InstallChart(o.ctx, cfg, InstallChartOptions{}) +} + +// releaseExistsInTargetNamespace reports whether the named Helm release exists +// in the configured namespace. If the namespace does not exist yet there can be +// no release in it, so it returns false without querying Helm (which would fail +// on a non-existent namespace). +func (o *OpenBaoInstaller) releaseExistsInTargetNamespace(releaseName string) (bool, error) { + _, nsErr := o.Clientset.CoreV1().Namespaces().Get(o.ctx, o.Config.Namespace, metav1.GetOptions{}) + if nsErr != nil { + if k8serrors.IsNotFound(nsErr) { + return false, nil + } + return false, fmt.Errorf("checking namespace %s: %w", o.Config.Namespace, nsErr) + } + + rel, err := o.Helm.FindRelease(o.Config.Namespace, releaseName) + if err != nil { + return false, fmt.Errorf("finding release %s in namespace %s: %w", releaseName, o.Config.Namespace, err) + } + return rel != nil, nil +} + +// operatorInstalledClusterWide reports whether the bank-vaults operator is +// already installed anywhere in the cluster, detected via its cluster-scoped +// "vault-operator" ClusterRole. +func (o *OpenBaoInstaller) operatorInstalledClusterWide() (bool, error) { + _, err := o.Clientset.RbacV1().ClusterRoles().Get(o.ctx, "vault-operator", metav1.GetOptions{}) + if err == nil { + return true, nil + } + if !k8serrors.IsNotFound(err) { + return false, fmt.Errorf("checking for existing vault-operator ClusterRole: %w", err) + } + return false, nil } // vaultCRTemplateData holds the values injected into the Vault CR template. @@ -292,12 +384,12 @@ func (o *OpenBaoInstaller) ApplyVaultCR() error { // later only requires changing the replica count. var retryJoinAddrs []string for i := 0; i < o.Config.Replicas; i++ { - addr := fmt.Sprintf("http://openbao-%d.%s.svc.cluster.local:8200", i, openBaoNamespace) + addr := fmt.Sprintf("http://openbao-%d.%s.%s.svc.cluster.local:8200", i, openBaoHeadlessService, o.Config.Namespace) retryJoinAddrs = append(retryJoinAddrs, addr) } data := vaultCRTemplateData{ - Namespace: openBaoNamespace, + Namespace: o.Config.Namespace, OpenBaoImage: openBaoImage, BankVaultsImage: bankVaultsImage, SecretsEngineName: o.Config.SecretsEngineName, @@ -333,30 +425,133 @@ func (o *OpenBaoInstaller) ApplyVaultCR() error { // WaitForInitialization polls the openbao-unseal-keys Secret until it contains // unseal key data, indicating that Bank-Vaults has completed initialization. +// +// When a DR backup was loaded (backupUnsealKeys is set), the function ensures +// the secret exists with the backup's unseal keys on every poll iteration. This +// handles the case where the bank-vaults operator deletes or recreates the +// secret during Vault CR reconciliation — we simply re-apply it until the +// operator settles and the sidecar can successfully unseal. func (o *OpenBaoInstaller) WaitForInitialization() error { - secretsClient := o.Clientset.CoreV1().Secrets(openBaoNamespace) + secretsClient := o.Clientset.CoreV1().Secrets(o.Config.Namespace) return o.pollUntil("waiting for openbao-unseal-keys to be populated", func() (bool, error) { secret, err := secretsClient.Get(o.ctx, openBaoUnsealSecretName, metav1.GetOptions{}) if err != nil { - if k8serrors.IsNotFound(err) { - return false, nil // Secret doesn't exist yet — keep polling + if !k8serrors.IsNotFound(err) { + return false, fmt.Errorf("fetching unseal secret: %w", err) } - return false, fmt.Errorf("fetching unseal secret: %w", err) + // Secret doesn't exist yet. + if o.backupUnsealKeys != nil { + // DR restore: create the secret from backup so the sidecar can unseal. + if createErr := o.ensureUnsealSecret(secretsClient); createErr != nil { + return false, createErr + } + } + return false, nil // Keep polling — sidecar hasn't confirmed unseal yet } // Check if the secret has meaningful data: at least one key must be // present, indicating bank-vaults has completed initialization and - // written the unseal keys. Bank-vaults writes all keys atomically - // during Init(), so any data means init is done. + // written the unseal keys. if len(secret.Data) > 0 { o.unsealSecret = secret return true, nil } + + // Secret exists but is empty — restore from backup if available. + if o.backupUnsealKeys != nil { + if updateErr := o.ensureUnsealSecret(secretsClient); updateErr != nil { + return false, updateErr + } + } return false, nil }) } +// ensureUnsealSecret creates or updates the unseal keys secret from the DR backup. +// It preserves existing metadata (labels, annotations, ownerReferences) when updating. +func (o *OpenBaoInstaller) ensureUnsealSecret(secretsClient corev1client.SecretInterface) error { + existing, err := secretsClient.Get(o.ctx, openBaoUnsealSecretName, metav1.GetOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("checking unseal secret: %w", err) + } + // Create new secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: openBaoUnsealSecretName, + Namespace: o.Config.Namespace, + }, + Data: o.backupUnsealKeys, + } + _, err = secretsClient.Create(o.ctx, secret, metav1.CreateOptions{}) + if err == nil { + return nil + } + if !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("creating unseal secret from backup: %w", err) + } + // Secret was created concurrently (e.g. by the operator) between our Get + // and Create. Re-fetch so we can update its data rather than leaving + // whatever (possibly empty/stale) data the racing writer set. + existing, err = secretsClient.Get(o.ctx, openBaoUnsealSecretName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("re-fetching unseal secret after create conflict: %w", err) + } + } + + // Update existing secret — preserve metadata, only set Data + existing.Data = o.backupUnsealKeys + _, err = secretsClient.Update(o.ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("updating unseal secret from backup: %w", err) + } + return nil +} + +// WaitForPodsReady polls until the expected number of vault pods (matching the +// configured replica count) are in Running phase with all containers Ready. +// This ensures scaling operations have fully completed before reporting success. +func (o *OpenBaoInstaller) WaitForPodsReady() error { + selector := vaultCRLabelSelector + expected := o.Config.Replicas + + return o.pollUntil("waiting for all OpenBao pods to be ready", func() (bool, error) { + list, err := o.Clientset.CoreV1().Pods(o.Config.Namespace).List(o.ctx, metav1.ListOptions{ + LabelSelector: selector, + }) + if err != nil { + return false, fmt.Errorf("listing vault pods: %w", err) + } + + var activePods int + var readyCount int + for i := range list.Items { + if list.Items[i].DeletionTimestamp != nil { + continue // Skip terminating pods + } + activePods++ + if isPodReady(&list.Items[i]) { + readyCount++ + } + } + return activePods == expected && readyCount == expected, nil + }) +} + +// isPodReady returns true if the pod is in Running phase and has the Ready condition. +func isPodReady(pod *corev1.Pod) bool { + if pod.Status.Phase != corev1.PodRunning { + return false + } + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + return true + } + } + return false +} + // ExtractAndEncrypt reads the unseal keys Secret, combines it with the generated // password, and creates a SOPS-encrypted backup file. func (o *OpenBaoInstaller) ExtractAndEncrypt() error { @@ -410,48 +605,118 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { return nil } -// DeleteStaleUnsealKeys removes unseal keys left by a prior installation whose +// CleanStaleInstallState removes all state left by a prior installation whose // Raft storage no longer exists (e.g. cluster rebuild, PVC deletion). Without -// removal, bank-vaults would attempt to unseal with the old master key's shares +// cleanup, bank-vaults would attempt to unseal with the old master key's shares // and fail permanently — the new instance needs to run a fresh init. // -// To prevent the old bank-vaults sidecar from re-creating the secret via its -// retry loop, the Vault CR is deleted first and we wait for all pods to exit -// before removing the secret. -func (o *OpenBaoInstaller) DeleteStaleUnsealKeys() error { +// The cleanup sequence is: +// 1. Delete the Vault CR (stops the bank-vaults sidecar retry loop) +// 2. Wait for vault pods to terminate (only when a Vault CR was deleted) +// 3. Delete PVCs (removes stale Raft data that would confuse initialization) +// 4. Delete the unseal-keys Secret +func (o *OpenBaoInstaller) CleanStaleInstallState() error { vaultGVR := k8s.VaultGVR() + var cleaned []string // Tolerates NotFound — this may be a first-time install with no prior Vault CR. - delErr := o.DynClient.Resource(vaultGVR).Namespace(openBaoNamespace).Delete( + delErr := o.DynClient.Resource(vaultGVR).Namespace(o.Config.Namespace).Delete( o.ctx, "openbao", metav1.DeleteOptions{}, ) if delErr != nil && !k8serrors.IsNotFound(delErr) { return fmt.Errorf("deleting Vault CR: %w", delErr) } + if delErr == nil { + cleaned = append(cleaned, "Vault CR") + // Only wait for pods to terminate when we actually deleted a Vault CR — + // without a CR there are no operator-managed pods to wait on. + if err := o.waitForVaultPodsGone(); err != nil { + return err + } + } - if err := o.waitForVaultPodsGone(); err != nil { - return err + // Delete PVCs associated with the prior StatefulSet so that stale Raft + // data does not cause OpenBao to report as "initialized" on a fresh install. + pvcList, err := o.Clientset.CoreV1().PersistentVolumeClaims(o.Config.Namespace).List( + o.ctx, metav1.ListOptions{LabelSelector: vaultCRLabelSelector}, + ) + if err != nil { + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("listing stale PVCs: %w", err) + } + // Namespace doesn't exist yet — no stale PVCs to clean. + pvcList = &corev1.PersistentVolumeClaimList{} + } + for i := range pvcList.Items { + delErr = o.Clientset.CoreV1().PersistentVolumeClaims(o.Config.Namespace).Delete( + o.ctx, pvcList.Items[i].Name, metav1.DeleteOptions{}, + ) + if delErr != nil && !k8serrors.IsNotFound(delErr) { + return fmt.Errorf("deleting PVC %s: %w", pvcList.Items[i].Name, delErr) + } + } + if len(pvcList.Items) > 0 { + cleaned = append(cleaned, fmt.Sprintf("%d PVC(s)", len(pvcList.Items))) + if err := o.waitForPVCsGone(); err != nil { + return err + } } - // Now it is safe to delete the stale secret. - delErr = o.Clientset.CoreV1().Secrets(openBaoNamespace).Delete( + // Delete the stale unseal secret. + delErr = o.Clientset.CoreV1().Secrets(o.Config.Namespace).Delete( o.ctx, openBaoUnsealSecretName, metav1.DeleteOptions{}, ) if delErr != nil && !k8serrors.IsNotFound(delErr) { return fmt.Errorf("deleting stale unseal secret: %w", delErr) } + if delErr == nil { + cleaned = append(cleaned, "unseal secret") + } - o.Logger.Logf("Stale unseal keys removed (vault CR deleted, pods terminated)") + if len(cleaned) > 0 { + o.Logger.Logf("Cleaned stale resources: %s", strings.Join(cleaned, ", ")) + } else { + o.Logger.Logf("No stale install state found in namespace %q", o.Config.Namespace) + } return nil } +// hasExistingDeployment checks whether an OpenBao deployment already exists +// in the cluster by looking for the Vault CR or PVCs with vault_cr=openbao. +// This is used to distinguish a genuine first install (nothing exists) from a +// re-install where the user may have supplied the wrong DR backup path. +func (o *OpenBaoInstaller) hasExistingDeployment() (bool, error) { + vaultGVR := k8s.VaultGVR() + _, err := o.DynClient.Resource(vaultGVR).Namespace(o.Config.Namespace).Get( + o.ctx, "openbao", metav1.GetOptions{}, + ) + if err == nil { + return true, nil + } + if !k8serrors.IsNotFound(err) { + return false, fmt.Errorf("checking Vault CR: %w", err) + } + + // Vault CR gone but PVCs may linger (e.g. CR was manually deleted). + pvcList, err := o.Clientset.CoreV1().PersistentVolumeClaims(o.Config.Namespace).List( + o.ctx, metav1.ListOptions{LabelSelector: vaultCRLabelSelector}, + ) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil // Namespace doesn't exist — no prior deployment. + } + return false, fmt.Errorf("listing PVCs: %w", err) + } + return len(pvcList.Items) > 0, nil +} + // waitForVaultPodsGone polls until no pods with label vault_cr=openbao remain -// in the vault namespace, or until the context deadline is exceeded. +// in the target namespace, or until the context deadline is exceeded. func (o *OpenBaoInstaller) waitForVaultPodsGone() error { - selector := labels.SelectorFromSet(labels.Set{"vault_cr": "openbao"}).String() + selector := vaultCRLabelSelector return o.pollUntil("waiting for vault pods to terminate", func() (bool, error) { - list, err := o.Clientset.CoreV1().Pods(openBaoNamespace).List(o.ctx, metav1.ListOptions{ + list, err := o.Clientset.CoreV1().Pods(o.Config.Namespace).List(o.ctx, metav1.ListOptions{ LabelSelector: selector, }) if err != nil { @@ -464,6 +729,26 @@ func (o *OpenBaoInstaller) waitForVaultPodsGone() error { }) } +// waitForPVCsGone polls until no PVCs with label vault_cr=openbao remain in the +// target namespace. This ensures asynchronous PVC deletion has fully completed +// before the install pipeline creates new resources, avoiding conflicts. +func (o *OpenBaoInstaller) waitForPVCsGone() error { + selector := vaultCRLabelSelector + + return o.pollUntil("waiting for stale PVCs to be deleted", func() (bool, error) { + list, err := o.Clientset.CoreV1().PersistentVolumeClaims(o.Config.Namespace).List( + o.ctx, metav1.ListOptions{LabelSelector: selector}, + ) + if err != nil { + if k8serrors.IsNotFound(err) { + return true, nil + } + return false, fmt.Errorf("listing PVCs: %w", err) + } + return len(list.Items) == 0, nil + }) +} + // pollUntil runs check in a loop with exponential backoff until it returns // true or the configured timeout expires. timeoutMsg describes the operation // for the timeout error message. @@ -498,18 +783,18 @@ func (o *OpenBaoInstaller) pollUntil(timeoutMsg string, check func() (bool, erro func (o *OpenBaoInstaller) ensureNamespace(ctx context.Context) error { ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - Name: openBaoNamespace, + Name: o.Config.Namespace, }, } - _, err := o.Clientset.CoreV1().Namespaces().Get(ctx, openBaoNamespace, metav1.GetOptions{}) + _, err := o.Clientset.CoreV1().Namespaces().Get(ctx, o.Config.Namespace, metav1.GetOptions{}) if err != nil { if !k8serrors.IsNotFound(err) { - return fmt.Errorf("checking namespace %s: %w", openBaoNamespace, err) + return fmt.Errorf("checking namespace %s: %w", o.Config.Namespace, err) } _, err = o.Clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) if err != nil && !k8serrors.IsAlreadyExists(err) { - return fmt.Errorf("creating namespace %s: %w", openBaoNamespace, err) + return fmt.Errorf("creating namespace %s: %w", o.Config.Namespace, err) } } return nil @@ -532,28 +817,3 @@ func GenerateSecurePassword(length int) (string, error) { } return base64.RawURLEncoding.EncodeToString(b), nil } - -// SetCtx is a test helper. -func (o *OpenBaoInstaller) SetCtx(ctx context.Context) { - o.ctx = ctx -} - -// SetUnsealSecret is a test helper. -func (o *OpenBaoInstaller) SetUnsealSecret(secret *corev1.Secret) { - o.unsealSecret = secret -} - -// SetPassword is a test helper. -func (o *OpenBaoInstaller) SetPassword(password string) { - o.password = password -} - -// GetDRBackupExists returns whether a DR backup was found during pre-flight check. -func (o *OpenBaoInstaller) GetDRBackupExists() bool { - return o.drBackupExists -} - -// GetUnsealSecret returns the unseal secret populated during initialization. -func (o *OpenBaoInstaller) GetUnsealSecret() *corev1.Secret { - return o.unsealSecret -} diff --git a/internal/installer/openbao_test.go b/internal/installer/openbao_test.go index 0444a3d9..8f4021ab 100644 --- a/internal/installer/openbao_test.go +++ b/internal/installer/openbao_test.go @@ -21,8 +21,13 @@ import ( . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/yaml" + dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes/fake" ) @@ -49,20 +54,50 @@ var _ = Describe("OpenBaoInstaller", func() { }) Describe("Install — deploy Bank-Vaults Operator", func() { - It("calls UpgradeChart with InstallIfNotExist for the operator", func() { - helmMock.EXPECT().UpgradeChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { + It("performs fresh install when operator does not exist", func() { + // Pre-create the namespace so FindRelease is reachable. + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // FindRelease returns nil (no existing release in target namespace) + helmMock.EXPECT().FindRelease("vault", "vault-operator").Return(nil, nil) + + // No ClusterRole exists (fake clientset has nothing), so InstallChart is called + helmMock.EXPECT().InstallChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { return cfg.ReleaseName == "vault-operator" && cfg.ChartName == "oci://ghcr.io/bank-vaults/helm-charts/vault-operator" && cfg.Version == "1.22.5" && cfg.Namespace == "vault" && - cfg.CreateNamespace == true - }), installer.UpgradeChartOptions{InstallIfNotExist: true}).Return(nil) + cfg.CreateNamespace == false + }), mock.Anything).Return(nil) inst := &installer.OpenBaoInstaller{ Helm: helmMock, Clientset: clientset, Logger: bootstrap.NewStepLogger(true), - Config: installer.OpenBaoInstallerConfig{}, + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + err = inst.DeployBankVaultsOperator() + Expect(err).ToNot(HaveOccurred()) + }) + + It("performs fresh install when target namespace does not exist", func() { + // Namespace "new-ns" is NOT created — FindRelease must be skipped. + // No ClusterRole exists, so InstallChart is called directly. + helmMock.EXPECT().InstallChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { + return cfg.ReleaseName == "vault-operator" && + cfg.Namespace == "new-ns" && + cfg.CreateNamespace == false + }), mock.Anything).Return(nil) + + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "new-ns"}, } inst.SetCtx(ctx) @@ -70,19 +105,83 @@ var _ = Describe("OpenBaoInstaller", func() { Expect(err).ToNot(HaveOccurred()) }) - It("returns an error when Helm fails", func() { - helmMock.EXPECT().UpgradeChart(mock.Anything, mock.Anything, mock.Anything). + It("upgrades when release already exists in target namespace", func() { + // Pre-create the namespace so FindRelease is reachable. + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // FindRelease returns an existing release + helmMock.EXPECT().FindRelease("vault", "vault-operator").Return(&installer.ReleaseInfo{ + Name: "vault-operator", + InstalledVersion: "1.22.0", + }, nil) + + helmMock.EXPECT().UpgradeChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { + return cfg.ReleaseName == "vault-operator" && + cfg.Namespace == "vault" + }), installer.UpgradeChartOptions{}).Return(nil) + + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + err = inst.DeployBankVaultsOperator() + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips deployment when operator exists in another namespace", func() { + // Pre-create the namespace so FindRelease is reachable. + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "second"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // FindRelease returns nil (not in target namespace) + helmMock.EXPECT().FindRelease("second", "vault-operator").Return(nil, nil) + + // Pre-create the ClusterRole to simulate operator installed elsewhere + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "vault-operator"}, + } + _, err = clientset.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "second"}, + } + inst.SetCtx(ctx) + + // Should not call InstallChart or UpgradeChart + err = inst.DeployBankVaultsOperator() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns an error when Helm InstallChart fails", func() { + // Pre-create the namespace so FindRelease is reachable. + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + helmMock.EXPECT().FindRelease("vault", "vault-operator").Return(nil, nil) + helmMock.EXPECT().InstallChart(mock.Anything, mock.Anything, mock.Anything). Return(fmt.Errorf("chart not found")) inst := &installer.OpenBaoInstaller{ Helm: helmMock, Clientset: clientset, Logger: bootstrap.NewStepLogger(true), - Config: installer.OpenBaoInstallerConfig{}, + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, } inst.SetCtx(ctx) - err := inst.DeployBankVaultsOperator() + err = inst.DeployBankVaultsOperator() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("chart not found")) }) @@ -148,7 +247,8 @@ var _ = Describe("OpenBaoInstaller", func() { Clientset: clientset, Logger: bootstrap.NewStepLogger(true), Config: installer.OpenBaoInstallerConfig{ - Timeout: 5 * time.Second, + Namespace: "vault", + Timeout: 5 * time.Second, }, } inst.SetCtx(ctx) @@ -164,7 +264,8 @@ var _ = Describe("OpenBaoInstaller", func() { Clientset: clientset, Logger: bootstrap.NewStepLogger(true), Config: installer.OpenBaoInstallerConfig{ - Timeout: 1 * time.Second, + Namespace: "vault", + Timeout: 1 * time.Second, }, } inst.SetCtx(ctx) @@ -175,6 +276,185 @@ var _ = Describe("OpenBaoInstaller", func() { }) }) + Describe("WaitForPodsReady", func() { + It("succeeds when all expected pods are running and ready", func() { + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // Create 3 ready pods + for i := 0; i < 3; i++ { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("openbao-%d", i), + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + _, err = clientset.CoreV1().Pods("vault").Create(ctx, pod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + } + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{ + Namespace: "vault", + Replicas: 3, + Timeout: 5 * time.Second, + }, + } + inst.SetCtx(ctx) + + err = inst.WaitForPodsReady() + Expect(err).ToNot(HaveOccurred()) + }) + + It("times out when fewer pods than expected exist", func() { + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // Create only 1 ready pod but expect 3 + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openbao-0", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + _, err = clientset.CoreV1().Pods("vault").Create(ctx, pod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{ + Namespace: "vault", + Replicas: 3, + Timeout: 1 * time.Second, + }, + } + inst.SetCtx(ctx) + + err = inst.WaitForPodsReady() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + }) + + It("excludes terminating pods from the count", func() { + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + now := metav1.Now() + + // Create 1 ready pod and 1 terminating pod — expect 2 replicas + readyPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openbao-0", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + terminatingPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openbao-1", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + DeletionTimestamp: &now, + Finalizers: []string{"test-finalizer"}, // Required for DeletionTimestamp in fake + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + _, err = clientset.CoreV1().Pods("vault").Create(ctx, readyPod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + _, err = clientset.CoreV1().Pods("vault").Create(ctx, terminatingPod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{ + Namespace: "vault", + Replicas: 2, + Timeout: 1 * time.Second, + }, + } + inst.SetCtx(ctx) + + // Only 1 active pod (terminating is excluded), but need 2 → times out + err = inst.WaitForPodsReady() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + }) + + It("times out when pod exists but is not ready", func() { + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // Create a pod that is Running but not Ready + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openbao-0", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionFalse}, + }, + }, + } + _, err = clientset.CoreV1().Pods("vault").Create(ctx, pod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{ + Namespace: "vault", + Replicas: 1, + Timeout: 1 * time.Second, + }, + } + inst.SetCtx(ctx) + + err = inst.WaitForPodsReady() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + }) + }) + Describe("ExtractAndEncrypt", func() { It("creates an encrypted DR backup file with password and unseal keys", func() { if !sopsAndAgeAvailable() { @@ -315,7 +595,7 @@ var _ = Describe("OpenBaoInstaller", func() { BaoPassword: "test-password", Replicas: 1, StorageSize: "10Gi", - RetryJoinAddrs: []string{"http://openbao-0.vault.svc.cluster.local:8200"}, + RetryJoinAddrs: []string{"http://openbao-0.openbao.vault.svc.cluster.local:8200"}, } docs := renderTemplate(data) @@ -356,6 +636,7 @@ var _ = Describe("OpenBaoInstaller", func() { unsealConfig := spec["unsealConfig"].(map[string]interface{}) options := unsealConfig["options"].(map[string]interface{}) Expect(options["storeRootToken"]).To(BeFalse()) + Expect(options["preFlightChecks"]).To(BeTrue()) // Verify externalConfig has the secrets engine externalConfig := spec["externalConfig"].(map[string]interface{}) @@ -381,9 +662,9 @@ var _ = Describe("OpenBaoInstaller", func() { It("renders valid YAML with raft storage, PVCs, and retry_join for replicas=3", func() { retryJoinAddrs := []string{ - "http://openbao-0.vault.svc.cluster.local:8200", - "http://openbao-1.vault.svc.cluster.local:8200", - "http://openbao-2.vault.svc.cluster.local:8200", + "http://openbao-0.openbao.vault.svc.cluster.local:8200", + "http://openbao-1.openbao.vault.svc.cluster.local:8200", + "http://openbao-2.openbao.vault.svc.cluster.local:8200", } data := templateData{ @@ -440,6 +721,342 @@ var _ = Describe("OpenBaoInstaller", func() { Expect(envNames).To(ContainElements("POD_NAME", "BAO_CLUSTER_ADDR", "BAO_API_ADDR")) }) }) + + Describe("HasExistingDeployment", func() { + It("returns false when the target namespace does not exist", func() { + // Use a fake dynamic client with no objects — namespace "new-ns" does not exist. + scheme := runtime.NewScheme() + dynClient := dynamicfake.NewSimpleDynamicClient(scheme) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + DynClient: dynClient, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "non-existent-ns"}, + } + inst.SetCtx(ctx) + + exists, err := inst.HasExistingDeployment() + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("returns true when PVCs with vault_cr=openbao exist in the namespace", func() { + scheme := runtime.NewScheme() + dynClient := dynamicfake.NewSimpleDynamicClient(scheme) + + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // Since fake dynamic client returns NotFound for unregistered resources, + // the hasExistingDeployment method will fall through to PVC check. + // Create a PVC with the vault_cr=openbao label to verify detection. + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vault-raft-openbao-0", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + } + _, err = clientset.CoreV1().PersistentVolumeClaims("vault").Create(ctx, pvc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + DynClient: dynClient, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + exists, checkErr := inst.HasExistingDeployment() + Expect(checkErr).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("returns false when namespace exists but has no vault resources", func() { + scheme := runtime.NewScheme() + dynClient := dynamicfake.NewSimpleDynamicClient(scheme) + + // Pre-create the namespace + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "empty-ns"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + DynClient: dynClient, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "empty-ns"}, + } + inst.SetCtx(ctx) + + exists, checkErr := inst.HasExistingDeployment() + Expect(checkErr).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + }) + + Describe("releaseExistsInTargetNamespace", func() { + It("returns false without querying Helm when the namespace does not exist", func() { + // No namespace created, and no Helm expectations set — FindRelease + // must not be called. + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "absent-ns"}, + } + inst.SetCtx(ctx) + + exists, err := inst.ReleaseExistsInTargetNamespace("vault-operator") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("returns true when a release exists in the namespace", func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + helmMock.EXPECT().FindRelease("vault", "vault-operator").Return(&installer.ReleaseInfo{Name: "vault-operator"}, nil) + + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + exists, err := inst.ReleaseExistsInTargetNamespace("vault-operator") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("wraps the error when FindRelease fails", func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + helmMock.EXPECT().FindRelease("vault", "vault-operator").Return(nil, fmt.Errorf("helm boom")) + + inst := &installer.OpenBaoInstaller{ + Helm: helmMock, + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + _, err = inst.ReleaseExistsInTargetNamespace("vault-operator") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("finding release vault-operator in namespace vault")) + Expect(err.Error()).To(ContainSubstring("helm boom")) + }) + }) + + Describe("operatorInstalledClusterWide", func() { + It("returns true when the vault-operator ClusterRole exists", func() { + cr := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "vault-operator"}} + _, err := clientset.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + clusterWide, err := inst.OperatorInstalledClusterWide() + Expect(err).ToNot(HaveOccurred()) + Expect(clusterWide).To(BeTrue()) + }) + + It("returns false when the ClusterRole does not exist", func() { + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault"}, + } + inst.SetCtx(ctx) + + clusterWide, err := inst.OperatorInstalledClusterWide() + Expect(err).ToNot(HaveOccurred()) + Expect(clusterWide).To(BeFalse()) + }) + }) + + Describe("ensureUnsealSecret", func() { + const ns = "vault" + + newInstaller := func(backup map[string][]byte) *installer.OpenBaoInstaller { + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: ns}, + } + inst.SetCtx(ctx) + inst.SetBackupUnsealKeys(backup) + return inst + } + + It("creates the secret from the backup keys when absent", func() { + inst := newInstaller(map[string][]byte{"vault-unseal-0": []byte("backup-key")}) + + Expect(inst.EnsureUnsealSecret()).To(Succeed()) + + secret, err := clientset.CoreV1().Secrets(ns).Get(ctx, "openbao-unseal-keys", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(secret.Data).To(HaveKeyWithValue("vault-unseal-0", []byte("backup-key"))) + }) + + It("overwrites an existing secret holding empty/wrong data", func() { + // Pre-create a secret with empty data to simulate a partially + // reconciled / wrong secret left by the operator. + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "openbao-unseal-keys", Namespace: ns}, + Data: map[string][]byte{}, + } + _, err := clientset.CoreV1().Secrets(ns).Create(ctx, existing, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := newInstaller(map[string][]byte{"vault-unseal-0": []byte("backup-key")}) + Expect(inst.EnsureUnsealSecret()).To(Succeed()) + + secret, err := clientset.CoreV1().Secrets(ns).Get(ctx, "openbao-unseal-keys", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(secret.Data).To(HaveKeyWithValue("vault-unseal-0", []byte("backup-key"))) + }) + }) + + Describe("WaitForInitialization (DR restore)", func() { + It("populates the secret from the backup when it is initially absent", func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{ + Namespace: "vault", + Timeout: 30 * time.Second, + }, + } + inst.SetCtx(ctx) + inst.SetBackupUnsealKeys(map[string][]byte{"vault-unseal-0": []byte("backup-key")}) + + // First poll creates the secret from backup (returns "not done yet"); + // the next poll observes the populated secret and succeeds. + err = inst.WaitForInitialization() + Expect(err).ToNot(HaveOccurred()) + + secret := inst.GetUnsealSecret() + Expect(secret.Data).To(HaveKeyWithValue("vault-unseal-0", []byte("backup-key"))) + }) + }) + + Describe("CleanStaleInstallState", func() { + It("succeeds and skips the pod wait when no Vault CR exists, even if labeled pods linger", func() { + scheme := runtime.NewScheme() + dynClient := dynamicfake.NewSimpleDynamicClient(scheme) // no Vault CR registered → Delete returns NotFound + + nsObj := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, nsObj, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // A lingering pod that never terminates — if the code waited for pods + // without a deleted CR, this would time out. + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openbao-0", + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + _, err = clientset.CoreV1().Pods("vault").Create(ctx, pod, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + DynClient: dynClient, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault", Timeout: 2 * time.Second}, + } + inst.SetCtx(ctx) + + err = inst.CleanStaleInstallState() + Expect(err).ToNot(HaveOccurred()) + }) + + It("deletes a stale Vault CR, PVCs, and the unseal secret", func() { + vaultGVR := schema.GroupVersionResource{Group: "vault.banzaicloud.com", Version: "v1alpha1", Resource: "vaults"} + scheme := runtime.NewScheme() + vaultCR := &unstructured.Unstructured{} + vaultCR.SetGroupVersionKind(schema.GroupVersionKind{Group: "vault.banzaicloud.com", Version: "v1alpha1", Kind: "Vault"}) + vaultCR.SetName("openbao") + vaultCR.SetNamespace("vault") + dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{vaultGVR: "VaultList"}, + vaultCR, + ) + + nsObj := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault"}} + _, err := clientset.CoreV1().Namespaces().Create(ctx, nsObj, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // Two stale PVCs labeled for the openbao Vault CR. + for i := 0; i < 2; i++ { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("vault-raft-openbao-%d", i), + Namespace: "vault", + Labels: map[string]string{"vault_cr": "openbao"}, + }, + } + _, err = clientset.CoreV1().PersistentVolumeClaims("vault").Create(ctx, pvc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + } + + // A stale unseal secret. + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "openbao-unseal-keys", Namespace: "vault"}, + Data: map[string][]byte{"vault-unseal-0": []byte("old")}, + } + _, err = clientset.CoreV1().Secrets("vault").Create(ctx, secret, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + inst := &installer.OpenBaoInstaller{ + Clientset: clientset, + DynClient: dynClient, + Logger: bootstrap.NewStepLogger(true), + Config: installer.OpenBaoInstallerConfig{Namespace: "vault", Timeout: 5 * time.Second}, + } + inst.SetCtx(ctx) + + err = inst.CleanStaleInstallState() + Expect(err).ToNot(HaveOccurred()) + + // Vault CR removed. + _, getErr := dynClient.Resource(vaultGVR).Namespace("vault").Get(ctx, "openbao", metav1.GetOptions{}) + Expect(getErr).To(HaveOccurred()) + + // PVCs removed. + pvcs, listErr := clientset.CoreV1().PersistentVolumeClaims("vault").List(ctx, metav1.ListOptions{}) + Expect(listErr).ToNot(HaveOccurred()) + Expect(pvcs.Items).To(BeEmpty()) + + // Unseal secret removed. + _, secretErr := clientset.CoreV1().Secrets("vault").Get(ctx, "openbao-unseal-keys", metav1.GetOptions{}) + Expect(secretErr).To(HaveOccurred()) + }) + }) }) // extractAgeRecipient extracts the public key from age-keygen's output. diff --git a/internal/installer/vault_encryption.go b/internal/installer/vault_encryption.go index 8bddd0d8..91a108b3 100644 --- a/internal/installer/vault_encryption.go +++ b/internal/installer/vault_encryption.go @@ -21,7 +21,13 @@ var ( ) // ResolveAgeKey resolves an existing age key or generates a new one. -// It checks (in order): +// +// When explicitKeyFile is non-empty it takes priority over everything else: the +// recipient is read directly from that file and it is returned as the key path. +// This lets callers thread an explicit --age-key-file through without mutating +// the process environment. +// +// Otherwise it checks (in order): // 1. SOPS_AGE_KEY environment variable (raw key content) // 2. SOPS_AGE_KEY_FILE environment variable (path to key file) // 3. Default location: ~/.config/sops/age/keys.txt @@ -29,7 +35,16 @@ var ( // // Returns the age public key (recipient) and the path to the key file (empty when // the key was supplied via SOPS_AGE_KEY). -func ResolveAgeKey(fallbackDir string) (recipient string, keyPath string, err error) { +func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { + // 0. Explicit key file – supplied by the caller, takes priority. + if explicitKeyFile != "" { + recipient, err = readRecipientFromFile(explicitKeyFile) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", explicitKeyFile, err) + } + return recipient, explicitKeyFile, nil + } + // 1. SOPS_AGE_KEY env var – contains raw key content. if raw := os.Getenv(sopsage.SopsAgeKeyEnv); raw != "" { recipient, err = parseAgeRecipient(strings.NewReader(raw)) diff --git a/internal/installer/vault_encryption_test.go b/internal/installer/vault_encryption_test.go index 8611259b..3637a4a6 100644 --- a/internal/installer/vault_encryption_test.go +++ b/internal/installer/vault_encryption_test.go @@ -61,6 +61,31 @@ var _ = Describe("VaultEncryption", func() { } }) + Context("with an explicit key file argument", func() { + It("reads the recipient from the explicit file and returns it as the key path", func() { + if !sopsAndAgeAvailable() { + Skip("age-keygen not available") + } + keyFile := filepath.Join(tmpDir, "explicit.txt") + out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput() + Expect(err).ToNot(HaveOccurred(), string(out)) + + // Set conflicting env vars to prove the explicit file takes priority. + Expect(os.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(tmpDir, "ignored.txt"))).To(Succeed()) + + recipient, keyPath, err := installer.ResolveAgeKey(keyFile, tmpDir) + Expect(err).ToNot(HaveOccurred()) + Expect(recipient).To(HavePrefix("age1")) + Expect(keyPath).To(Equal(keyFile)) + }) + + It("returns an error if the explicit file does not exist", func() { + _, _, err := installer.ResolveAgeKey(filepath.Join(tmpDir, "missing.txt"), tmpDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read age key")) + }) + }) + Context("with SOPS_AGE_KEY env var containing only a private key (no comment)", func() { It("should fall back to age-keygen -y to derive the recipient", func() { if !sopsAndAgeAvailable() { @@ -86,7 +111,7 @@ var _ = Describe("VaultEncryption", func() { Expect(os.Setenv("SOPS_AGE_KEY", privKeyLine)).To(Succeed()) - recipient, keyPath, err := installer.ResolveAgeKey(tmpDir) + recipient, keyPath, err := installer.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(BeEmpty()) @@ -104,7 +129,7 @@ var _ = Describe("VaultEncryption", func() { Expect(os.Setenv("SOPS_AGE_KEY_FILE", keyFile)).To(Succeed()) - recipient, keyPath, err := installer.ResolveAgeKey(tmpDir) + recipient, keyPath, err := installer.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(Equal(keyFile)) @@ -113,7 +138,7 @@ var _ = Describe("VaultEncryption", func() { It("should return error if the file does not exist", func() { Expect(os.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(tmpDir, "nonexistent.txt"))).To(Succeed()) - _, _, err := installer.ResolveAgeKey(tmpDir) + _, _, err := installer.ResolveAgeKey("", tmpDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to read age key")) }) @@ -125,7 +150,7 @@ var _ = Describe("VaultEncryption", func() { Skip("age-keygen not available") } - recipient, keyPath, err := installer.ResolveAgeKey(tmpDir) + recipient, keyPath, err := installer.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(Equal(filepath.Join(tmpDir, "age_key.txt")))