From 2cce4e887e3c95959b0b7adf2083390e37854bfd Mon Sep 17 00:00:00 2001 From: Arnob Kumar Saha Date: Thu, 2 Jul 2026 22:52:28 +0600 Subject: [PATCH 1/4] Resolve convert placeholders from cluster resources Use the live cluster to resolve the target, addon and task for AppBinding-backed BackupConfiguration/RestoreSession instead of emitting placeholders: - Capture the client convert already built (was discarded) and register the appcatalog scheme so AppBindings can be fetched. - resolveAppBindingTarget looks up the AppBinding -> spec.appRef (real KubeDB target) and the KubeStash addon name from the catalog version's spec.archiver.addon.name (falling back to -addon). Backup/restore tasks use the logical-backup / logical-backup-restore constants. - If the cluster is unreachable, keep the existing placeholder behaviour; only hard-fail when a referenced AppBinding/version is genuinely NotFound. - Add --encryption-secret-name / --encryption-secret-namespace flags to fill the encryptionSecret; set repository.directory to / of the target. - Emit review YAML line-comments for fields that still need operator attention. Signed-off-by: Arnob Kumar Saha --- go.mod | 2 +- pkg/convert/convert.go | 43 ++++++- pkg/convert/resources.go | 234 ++++++++++++++++++++++++++++++++++----- 3 files changed, 247 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index baac6a88..680c31fe 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( k8s.io/kubectl v0.30.1 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 kmodules.xyz/client-go v0.34.3 + kmodules.xyz/custom-resources v0.34.0 kmodules.xyz/offshoot-api v0.34.0 kmodules.xyz/prober v0.34.0 kubedb.dev/apimachinery v0.63.0 @@ -216,7 +217,6 @@ require ( k8s.io/component-base v0.34.3 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect kmodules.xyz/apiversion v0.2.0 // indirect - kmodules.xyz/custom-resources v0.34.0 // indirect kmodules.xyz/monitoring-agent-api v0.34.1 // indirect kmodules.xyz/objectstore-api v0.34.0 // indirect kubeops.dev/operator-shard-manager v0.0.5 // indirect diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index c6b73852..cafaa315 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -31,12 +31,21 @@ import ( "k8s.io/klog/v2" cu "kmodules.xyz/client-go/client" "kmodules.xyz/client-go/tools/parser" + appcatalogapi "kmodules.xyz/custom-resources/apis/appcatalog/v1alpha1" coreapi "kubestash.dev/apimachinery/apis/core/v1alpha1" storageapi "kubestash.dev/apimachinery/apis/storage/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" ) -var sourceDir, targetDir string +var ( + sourceDir, targetDir string + + encryptionSecretName string + encryptionSecretNamespace string + + klient client.Client +) func NewCmdConvert(clientGetter genericclioptions.RESTClientGetter) *cobra.Command { cmd := &cobra.Command{ @@ -50,12 +59,13 @@ func NewCmdConvert(clientGetter genericclioptions.RESTClientGetter) *cobra.Comma if err != nil { return err } - _, err = cu.NewUncachedClient( + klient, err = cu.NewUncachedClient( cfg, v1alpha1.AddToScheme, v1beta1.AddToScheme, storageapi.AddToScheme, coreapi.AddToScheme, + appcatalogapi.AddToScheme, ) if err != nil { return err @@ -70,6 +80,8 @@ func NewCmdConvert(clientGetter genericclioptions.RESTClientGetter) *cobra.Comma } cmd.Flags().StringVar(&sourceDir, "source-dir", sourceDir, "Source directory.") cmd.Flags().StringVar(&targetDir, "target-dir", targetDir, "Target directory.") + cmd.Flags().StringVar(&encryptionSecretName, "encryption-secret-name", encryptionSecretName, "Name of the encryption Secret to reference in the converted resources.") + cmd.Flags().StringVar(&encryptionSecretNamespace, "encryption-secret-namespace", encryptionSecretNamespace, "Namespace of the encryption Secret to reference in the converted resources.") return cmd } @@ -94,6 +106,10 @@ func setValidValue(fieldName string) string { } func writeToTargetDir(srcPath string, addSeparator bool, obj any) error { + return writeToTargetDirWithComments(srcPath, addSeparator, obj, nil) +} + +func writeToTargetDirWithComments(srcPath string, addSeparator bool, obj any, comments map[string]string) error { targetPath := strings.ReplaceAll(srcPath, sourceDir, targetDir) if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil { return err @@ -110,6 +126,7 @@ func writeToTargetDir(srcPath string, addSeparator bool, obj any) error { if err != nil { return err } + marshalled = annotateFieldComments(marshalled, comments) file, err := os.OpenFile(targetPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { @@ -122,6 +139,28 @@ func writeToTargetDir(srcPath string, addSeparator bool, obj any) error { return nil } +// annotateFieldComments appends a trailing YAML line-comment to every line whose key +// matches an entry in comments (keyed by the field's YAML key, e.g. "directory"). +func annotateFieldComments(data []byte, comments map[string]string) []byte { + if len(comments) == 0 { + return data + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.Contains(line, " #") { + continue + } + trimmed := strings.TrimSpace(line) + for key, comment := range comments { + if strings.HasPrefix(trimmed, key+":") { + lines[i] = strings.TrimRight(line, " ") + " # " + comment + break + } + } + } + return []byte(strings.Join(lines, "\n")) +} + func addSeparatorToTargetFile(filePath string) error { file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { diff --git a/pkg/convert/resources.go b/pkg/convert/resources.go index 830c06cb..7e887a49 100644 --- a/pkg/convert/resources.go +++ b/pkg/convert/resources.go @@ -17,6 +17,7 @@ limitations under the License. package convert import ( + "context" "encoding/json" "path/filepath" "strings" @@ -25,19 +26,109 @@ import ( "stash.appscode.dev/apimachinery/apis/stash/v1beta1" "gomodules.xyz/pointer" + apierrors "k8s.io/apimachinery/pkg/api/errors" 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/klog/v2" kmapi "kmodules.xyz/client-go/api/v1" core_util "kmodules.xyz/client-go/core/v1" meta_util "kmodules.xyz/client-go/meta" "kmodules.xyz/client-go/tools/parser" + appcatalogapi "kmodules.xyz/custom-resources/apis/appcatalog/v1alpha1" ofst "kmodules.xyz/offshoot-api/api/v1" prober "kmodules.xyz/prober/api/v1" + catalog "kubedb.dev/apimachinery/apis/catalog" "kubestash.dev/apimachinery/apis" coreapi "kubestash.dev/apimachinery/apis/core/v1alpha1" storageapi "kubestash.dev/apimachinery/apis/storage/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" ) +// resolvedTarget holds values looked up from the cluster for an AppBinding target. +type resolvedTarget struct { + target *kmapi.TypedObjectReference // from AppBinding.spec.appRef + addonName string // from Version.spec.archiver.addon.name +} + +// resolveAppBindingTarget resolves a Stash AppBinding target into the real KubeDB +// target and its KubeStash addon by querying the cluster. +// +// It returns (nil, nil) when the cluster is unreachable so callers fall back to the +// existing placeholder behaviour. It returns an error only when the cluster is +// reachable but the referenced AppBinding/version genuinely does not exist. +func resolveAppBindingTarget(ref v1beta1.TargetRef, defaultNS string) (*resolvedTarget, error) { + ns := ref.Namespace + if ns == "" { + ns = defaultNS + } + + ab := &appcatalogapi.AppBinding{} + key := client.ObjectKey{Name: ref.Name, Namespace: ns} + if err := klient.Get(context.Background(), key, ab); err != nil { + if apierrors.IsNotFound(err) { + return nil, err + } + klog.Warningf("Cluster unreachable while resolving AppBinding %s/%s, keeping placeholders. Reason: %v", ns, ref.Name, err) + return nil, nil + } + + if ab.Spec.AppRef == nil { + klog.Warningf("AppBinding %s/%s has no spec.appRef, keeping placeholders.", ns, ref.Name) + return nil, nil + } + + target := &kmapi.TypedObjectReference{ + APIGroup: ab.Spec.AppRef.APIGroup, + Kind: ab.Spec.AppRef.Kind, + Name: ab.Spec.AppRef.Name, + Namespace: ab.Spec.AppRef.Namespace, + } + if target.Namespace == "" { + target.Namespace = ab.Namespace + } + + addonName, err := resolveAddonName(ab) + if err != nil { + return nil, err + } + + return &resolvedTarget{target: target, addonName: addonName}, nil +} + +// resolveAddonName derives the KubeStash addon name for an AppBinding. It prefers the +// authoritative spec.archiver.addon.name on the KubeDB catalog version object, falling +// back to the "-addon" convention. +func resolveAddonName(ab *appcatalogapi.AppBinding) (string, error) { + fallback := strings.ToLower(ab.Spec.AppRef.Kind) + "-addon" + + if ab.Spec.Version == "" { + return fallback, nil + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: catalog.GroupName, + Version: "v1alpha1", + Kind: ab.Spec.AppRef.Kind + "Version", + }) + key := client.ObjectKey{Name: ab.Spec.Version} + if err := klient.Get(context.Background(), key, u); err != nil { + if apierrors.IsNotFound(err) { + return "", err + } + klog.Warningf("Cluster unreachable while resolving %sVersion %q, using %q. Reason: %v", ab.Spec.AppRef.Kind, ab.Spec.Version, fallback, err) + return fallback, nil + } + + name, found, err := unstructured.NestedString(u.Object, "spec", "archiver", "addon", "name") + if err != nil || !found || name == "" { + return fallback, nil + } + return name, nil +} + func convertRepository(ri parser.ResourceInfo) error { repo := &v1alpha1.Repository{} if err := runtime.DefaultUnstructuredConverter.FromUnstructured(ri.Object.Object, repo); err != nil { @@ -120,8 +211,17 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { return err } - newBC := createBackupConfiguration(oldBC) - if err := writeToTargetDir(ri.Filename, false, newBC); err != nil { + var rt *resolvedTarget + if oldBC.Spec.Target != nil && oldBC.Spec.Target.Ref.Kind == appcatalogapi.ResourceKindApp { + var err error + rt, err = resolveAppBindingTarget(oldBC.Spec.Target.Ref, oldBC.Namespace) + if err != nil { + return err + } + } + + newBC := createBackupConfiguration(oldBC, rt) + if err := writeToTargetDirWithComments(ri.Filename, false, newBC, repositoryComments(rt)); err != nil { return err } @@ -159,7 +259,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { return nil } -func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration) *coreapi.BackupConfiguration { +func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration, rt *resolvedTarget) *coreapi.BackupConfiguration { var ref v1beta1.TargetRef if oldBC.Spec.Target != nil { ref = v1beta1.TargetRef{ @@ -180,9 +280,9 @@ func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration) *coreapi.Back }, Spec: coreapi.BackupConfigurationSpec{ Paused: oldBC.Spec.Paused, - Target: configureTarget(oldBC.Namespace, ref), + Target: configureTarget(oldBC.Namespace, ref, rt), Backends: []coreapi.BackendReference{configureBackend(oldBC)}, - Sessions: []coreapi.Session{configureSession(oldBC)}, + Sessions: []coreapi.Session{configureSession(oldBC, rt)}, }, } } @@ -337,7 +437,10 @@ func configureUsagePolicy(policy *v1alpha1.UsagePolicy) *apis.UsagePolicy { } } -func configureTarget(namespace string, ref v1beta1.TargetRef) *kmapi.TypedObjectReference { +func configureTarget(namespace string, ref v1beta1.TargetRef, rt *resolvedTarget) *kmapi.TypedObjectReference { + if rt != nil { + return rt.target + } if isTargetWorkload(ref) { if ref.Namespace != "" { namespace = ref.Namespace @@ -402,7 +505,7 @@ func configureBackendFromBlueprint(bb *v1beta1.BackupBlueprint) coreapi.BackendR } } -func configureSession(bc *v1beta1.BackupConfiguration) coreapi.Session { +func configureSession(bc *v1beta1.BackupConfiguration, rt *resolvedTarget) coreapi.Session { return coreapi.Session{ SessionConfig: &coreapi.SessionConfig{ Name: "backup", @@ -419,17 +522,49 @@ func configureSession(bc *v1beta1.BackupConfiguration) coreapi.Session { }, Repositories: []coreapi.RepositoryInfo{ { - Name: bc.Spec.Repository.Name, - Backend: "storage", - Directory: setValidValue("Directory"), - EncryptionSecret: &kmapi.ObjectReference{ - Name: setValidValue("Name"), - Namespace: setValidValue("Namespace"), - }, + Name: bc.Spec.Repository.Name, + Backend: "storage", + Directory: repositoryDirectory(rt), + EncryptionSecret: encryptionSecretRef(), }, }, - Addon: configureBackupAddonInfo(bc), + Addon: configureBackupAddonInfo(bc, rt), + } +} + +// encryptionSecretRef returns the encryption Secret reference from the CLI flags, +// falling back to placeholders when a flag is not provided. +func encryptionSecretRef() *kmapi.ObjectReference { + name := encryptionSecretName + if name == "" { + name = setValidValue("Name") + } + namespace := encryptionSecretNamespace + if namespace == "" { + namespace = setValidValue("Namespace") } + return &kmapi.ObjectReference{Name: name, Namespace: namespace} +} + +// repositoryDirectory derives the repository directory from the resolved target, +// falling back to a placeholder when the target could not be resolved. +func repositoryDirectory(rt *resolvedTarget) string { + if rt != nil { + return filepath.Join(rt.target.Namespace, rt.target.Name) + } + return setValidValue("Directory") +} + +// repositoryComments builds the review line-comments to attach to the generated YAML. +func repositoryComments(rt *resolvedTarget) map[string]string { + comments := map[string]string{} + if rt != nil { + comments["directory"] = "review: / of the target database" + } + if encryptionSecretName == "" || encryptionSecretNamespace == "" { + comments["encryptionSecret"] = "review: set via --encryption-secret-name / --encryption-secret-namespace" + } + return comments } func configureSessionFromBlueprint(bb *v1beta1.BackupBlueprint) coreapi.Session { @@ -506,13 +641,25 @@ func configureHookExecutionPolicy(policy v1beta1.HookExecutionPolicy) coreapi.Ho } } -func configureBackupAddonInfo(bc *v1beta1.BackupConfiguration) *coreapi.AddonInfo { +func configureBackupAddonInfo(bc *v1beta1.BackupConfiguration, rt *resolvedTarget) *coreapi.AddonInfo { var podTemplateSpec *ofst.PodTemplateSpec if bc.Spec.RuntimeSettings.Pod != nil { podTemplateSpec = &ofst.PodTemplateSpec{ Spec: configurePodRuntimeSettings(bc.Spec.RuntimeSettings.Pod), } } + if rt != nil { + return &coreapi.AddonInfo{ + Name: rt.addonName, + Tasks: []coreapi.TaskReference{ + { + Name: apis.LogicalBackup, + }, + }, + ContainerRuntimeSettings: bc.Spec.RuntimeSettings.Container, + JobTemplate: podTemplateSpec, + } + } if bc.Spec.Target != nil && isTargetWorkload(bc.Spec.Target.Ref) { params := &runtime.RawExtension{} pathsMap := make(map[string]any) @@ -623,8 +770,17 @@ func convertRestoreSession(ri parser.ResourceInfo) error { return err } - newRS := createRestoreSession(oldRS) - if err := writeToTargetDir(ri.Filename, false, newRS); err != nil { + var rt *resolvedTarget + if oldRS.Spec.Target != nil && oldRS.Spec.Target.Ref.Kind == appcatalogapi.ResourceKindApp { + var err error + rt, err = resolveAppBindingTarget(oldRS.Spec.Target.Ref, oldRS.Namespace) + if err != nil { + return err + } + } + + newRS := createRestoreSession(oldRS, rt) + if err := writeToTargetDirWithComments(ri.Filename, false, newRS, restoreSessionComments()); err != nil { return err } @@ -652,7 +808,7 @@ func convertRestoreSession(ri parser.ResourceInfo) error { return nil } -func createRestoreSession(oldRS *v1beta1.RestoreSession) *coreapi.RestoreSession { +func createRestoreSession(oldRS *v1beta1.RestoreSession, rt *resolvedTarget) *coreapi.RestoreSession { namespace := oldRS.Namespace if oldRS.Spec.Repository.Namespace != "" { namespace = oldRS.Spec.Repository.Namespace @@ -673,30 +829,50 @@ func createRestoreSession(oldRS *v1beta1.RestoreSession) *coreapi.RestoreSession Namespace: oldRS.Namespace, }, Spec: coreapi.RestoreSessionSpec{ - Target: configureTarget(oldRS.Namespace, ref), + Target: configureTarget(oldRS.Namespace, ref, rt), DataSource: &coreapi.RestoreDataSource{ - Namespace: namespace, - Repository: oldRS.Spec.Repository.Name, - Snapshot: setValidValue("Snapshot"), - EncryptionSecret: &kmapi.ObjectReference{ - Name: setValidValue("Name"), - Namespace: setValidValue("Namespace"), - }, + Namespace: namespace, + Repository: oldRS.Spec.Repository.Name, + Snapshot: setValidValue("Snapshot"), + EncryptionSecret: encryptionSecretRef(), }, - Addon: configureRestoreAddonInfo(oldRS), + Addon: configureRestoreAddonInfo(oldRS, rt), RestoreTimeout: oldRS.Spec.TimeOut, Hooks: configureRestoreHooks(oldRS), }, } } -func configureRestoreAddonInfo(rs *v1beta1.RestoreSession) *coreapi.AddonInfo { +// restoreSessionComments builds the review line-comments for a converted RestoreSession. +func restoreSessionComments() map[string]string { + comments := map[string]string{ + "snapshot": "review: set the snapshot to restore (e.g. latest)", + } + if encryptionSecretName == "" || encryptionSecretNamespace == "" { + comments["encryptionSecret"] = "review: set via --encryption-secret-name / --encryption-secret-namespace" + } + return comments +} + +func configureRestoreAddonInfo(rs *v1beta1.RestoreSession, rt *resolvedTarget) *coreapi.AddonInfo { var podTemplateSpec *ofst.PodTemplateSpec if rs.Spec.RuntimeSettings.Pod != nil { podTemplateSpec = &ofst.PodTemplateSpec{ Spec: configurePodRuntimeSettings(rs.Spec.RuntimeSettings.Pod), } } + if rt != nil { + return &coreapi.AddonInfo{ + Name: rt.addonName, + Tasks: []coreapi.TaskReference{ + { + Name: apis.LogicalBackupRestore, + }, + }, + ContainerRuntimeSettings: rs.Spec.RuntimeSettings.Container, + JobTemplate: podTemplateSpec, + } + } if rs.Spec.Target != nil && isTargetWorkload(rs.Spec.Target.Ref) { // TODO: convert rules to params return &coreapi.AddonInfo{ From ff5acb0b74c50baafc92db2c7c9e4c23c03d59a7 Mon Sep 17 00:00:00 2001 From: Arnob Kumar Saha Date: Fri, 3 Jul 2026 11:05:04 +0600 Subject: [PATCH 2/4] Separate converted resources with '---' based on file content Signed-off-by: Arnob Kumar Saha --- pkg/convert/convert.go | 23 +++++++++++++++++++---- pkg/convert/resources.go | 24 ++++++++++++------------ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index cafaa315..ab884976 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -105,18 +105,22 @@ func setValidValue(fieldName string) string { return fmt.Sprintf("### Set Valid %s ###", fieldName) } -func writeToTargetDir(srcPath string, addSeparator bool, obj any) error { - return writeToTargetDirWithComments(srcPath, addSeparator, obj, nil) +func writeToTargetDir(srcPath string, obj any) error { + return writeToTargetDirWithComments(srcPath, obj, nil) } -func writeToTargetDirWithComments(srcPath string, addSeparator bool, obj any, comments map[string]string) error { +func writeToTargetDirWithComments(srcPath string, obj any, comments map[string]string) error { targetPath := strings.ReplaceAll(srcPath, sourceDir, targetDir) if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil { return err } klog.Infof("Writing %s to %s", srcPath, targetPath) - if addSeparator { + hasContent, err := targetFileHasContent(targetPath) + if err != nil { + return err + } + if hasContent { if err := addSeparatorToTargetFile(targetPath); err != nil { return err } @@ -161,6 +165,17 @@ func annotateFieldComments(data []byte, comments map[string]string) []byte { return []byte(strings.Join(lines, "\n")) } +func targetFileHasContent(filePath string) (bool, error) { + info, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return info.Size() > 0, nil +} + func addSeparatorToTargetFile(filePath string) error { file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { diff --git a/pkg/convert/resources.go b/pkg/convert/resources.go index 7e887a49..5ba672fb 100644 --- a/pkg/convert/resources.go +++ b/pkg/convert/resources.go @@ -135,7 +135,7 @@ func convertRepository(ri parser.ResourceInfo) error { return err } bs := createBackupStorage(repo) - return writeToTargetDir(ri.Filename, false, bs) + return writeToTargetDir(ri.Filename, bs) } func createBackupStorage(repo *v1alpha1.Repository) *storageapi.BackupStorage { @@ -221,7 +221,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { } newBC := createBackupConfiguration(oldBC, rt) - if err := writeToTargetDirWithComments(ri.Filename, false, newBC, repositoryComments(rt)); err != nil { + if err := writeToTargetDirWithComments(ri.Filename, newBC, repositoryComments(rt)); err != nil { return err } @@ -231,7 +231,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldBC.Name, "prebackup", "hook"), Namespace: oldBC.Namespace, }, oldBC.Spec.Hooks.PreBackup) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } @@ -241,7 +241,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldBC.Name, "postbackup", "hook"), Namespace: oldBC.Namespace, }, oldBC.Spec.Hooks.PostBackup.Handler) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } @@ -252,7 +252,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { ns = oldBC.Namespace } rp := createRetentionPolicy(oldBC.Spec.RetentionPolicy, ns) - if err := writeToTargetDir(ri.Filename, true, rp); err != nil { + if err := writeToTargetDir(ri.Filename, rp); err != nil { return err } @@ -294,7 +294,7 @@ func convertBackupBlueprint(ri parser.ResourceInfo) error { } newBC := createBackupBlueprint(oldBB) - if err := writeToTargetDir(ri.Filename, false, newBC); err != nil { + if err := writeToTargetDir(ri.Filename, newBC); err != nil { return err } @@ -304,7 +304,7 @@ func convertBackupBlueprint(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldBB.Name, "prebackup", "hook"), Namespace: oldBB.Namespace, }, oldBB.Spec.Hooks.PreBackup) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } @@ -314,7 +314,7 @@ func convertBackupBlueprint(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldBB.Name, "postbackup", "hook"), Namespace: oldBB.Namespace, }, oldBB.Spec.Hooks.PostBackup.Handler) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } @@ -325,7 +325,7 @@ func convertBackupBlueprint(ri parser.ResourceInfo) error { ns = oldBB.Namespace } rp := createRetentionPolicy(oldBB.Spec.RetentionPolicy, ns) - if err := writeToTargetDir(ri.Filename, true, rp); err != nil { + if err := writeToTargetDir(ri.Filename, rp); err != nil { return err } @@ -780,7 +780,7 @@ func convertRestoreSession(ri parser.ResourceInfo) error { } newRS := createRestoreSession(oldRS, rt) - if err := writeToTargetDirWithComments(ri.Filename, false, newRS, restoreSessionComments()); err != nil { + if err := writeToTargetDirWithComments(ri.Filename, newRS, restoreSessionComments()); err != nil { return err } @@ -790,7 +790,7 @@ func convertRestoreSession(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldRS.Name, "prerestore", "hook"), Namespace: oldRS.Namespace, }, oldRS.Spec.Hooks.PreRestore) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } @@ -800,7 +800,7 @@ func convertRestoreSession(ri parser.ResourceInfo) error { Name: meta_util.ValidNameWithPrefixNSuffix(oldRS.Name, "postrestore", "hook"), Namespace: oldRS.Namespace, }, oldRS.Spec.Hooks.PostRestore.Handler) - if err := writeToTargetDir(ri.Filename, true, ht); err != nil { + if err := writeToTargetDir(ri.Filename, ht); err != nil { return err } } From 22c21d4830a9db3b704fd91367e95f23790f965d Mon Sep 17 00:00:00 2001 From: Arnob Kumar Saha Date: Fri, 3 Jul 2026 11:19:47 +0600 Subject: [PATCH 3/4] Generate encryption Secret from storage Secret's RESTIC_PASSWORD Signed-off-by: Arnob Kumar Saha --- pkg/convert/resources.go | 172 ++++++++++++++++++++++++++++++++------- 1 file changed, 142 insertions(+), 30 deletions(-) diff --git a/pkg/convert/resources.go b/pkg/convert/resources.go index 5ba672fb..f2e94e1c 100644 --- a/pkg/convert/resources.go +++ b/pkg/convert/resources.go @@ -26,6 +26,7 @@ import ( "stash.appscode.dev/apimachinery/apis/stash/v1beta1" "gomodules.xyz/pointer" + core "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -46,6 +47,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// resticPasswordKey is the data key holding the encryption password in a Stash +// storage Secret; the converted KubeStash encryption Secret carries only this key. +const resticPasswordKey = "RESTIC_PASSWORD" + // resolvedTarget holds values looked up from the cluster for an AppBinding target. type resolvedTarget struct { target *kmapi.TypedObjectReference // from AppBinding.spec.appRef @@ -135,7 +140,72 @@ func convertRepository(ri parser.ResourceInfo) error { return err } bs := createBackupStorage(repo) - return writeToTargetDir(ri.Filename, bs) + if err := writeToTargetDir(ri.Filename, bs); err != nil { + return err + } + + if secret := buildEncryptionSecret(repo); secret != nil { + return writeToTargetDir(ri.Filename, secret) + } + return nil +} + +// buildEncryptionSecret returns an encryption Secret carrying only the RESTIC_PASSWORD +// copied from the Repository's storage Secret. It returns nil when the user supplies +// their own encryption Secret via flags, or when the RESTIC_PASSWORD cannot be read +// from the cluster (unreachable / missing / key absent) so callers keep placeholders. +func buildEncryptionSecret(repo *v1alpha1.Repository) *core.Secret { + if encryptionSecretName != "" || encryptionSecretNamespace != "" { + return nil + } + password, ok := resticPasswordFromStorageSecret(repo.Spec.Backend.StorageSecretName, repo.Namespace) + if !ok { + return nil + } + return &core.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: core.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: encryptionSecretNameFor(repo.Name), + Namespace: repo.Namespace, + }, + Type: core.SecretTypeOpaque, + Data: map[string][]byte{ + resticPasswordKey: password, + }, + } +} + +// encryptionSecretNameFor is the name of the encryption Secret generated for a Repository. +func encryptionSecretNameFor(repoName string) string { + return repoName + "-encryption-secret" +} + +// resticPasswordFromStorageSecret reads RESTIC_PASSWORD from a Stash storage Secret. +// It soft-fails (returns ok=false, logs) when the cluster is unreachable, the Secret +// is missing, or the key is absent, matching resolveAppBindingTarget's placeholder style. +func resticPasswordFromStorageSecret(secretName, namespace string) ([]byte, bool) { + if secretName == "" { + return nil, false + } + secret := &core.Secret{} + key := client.ObjectKey{Name: secretName, Namespace: namespace} + if err := klient.Get(context.Background(), key, secret); err != nil { + if apierrors.IsNotFound(err) { + klog.Warningf("Storage Secret %s/%s not found; keeping encryption Secret placeholders.", namespace, secretName) + } else { + klog.Warningf("Cluster unreachable while reading Storage Secret %s/%s; keeping encryption Secret placeholders. Reason: %v", namespace, secretName, err) + } + return nil, false + } + password, ok := secret.Data[resticPasswordKey] + if !ok || len(password) == 0 { + klog.Warningf("Storage Secret %s/%s has no %s; keeping encryption Secret placeholders.", namespace, secretName, resticPasswordKey) + return nil, false + } + return password, true } func createBackupStorage(repo *v1alpha1.Repository) *storageapi.BackupStorage { @@ -220,8 +290,14 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { } } - newBC := createBackupConfiguration(oldBC, rt) - if err := writeToTargetDirWithComments(ri.Filename, newBC, repositoryComments(rt)); err != nil { + repoNS := oldBC.Spec.Repository.Namespace + if repoNS == "" { + repoNS = oldBC.Namespace + } + encRef, encResolved := encryptionSecretRef(oldBC.Spec.Repository.Name, repoNS) + + newBC := createBackupConfiguration(oldBC, rt, encRef) + if err := writeToTargetDirWithComments(ri.Filename, newBC, repositoryComments(rt, encResolved)); err != nil { return err } @@ -247,11 +323,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { } } - ns := oldBC.Spec.Repository.Namespace - if ns == "" { - ns = oldBC.Namespace - } - rp := createRetentionPolicy(oldBC.Spec.RetentionPolicy, ns) + rp := createRetentionPolicy(oldBC.Spec.RetentionPolicy, repoNS) if err := writeToTargetDir(ri.Filename, rp); err != nil { return err } @@ -259,7 +331,7 @@ func convertBackupConfiguration(ri parser.ResourceInfo) error { return nil } -func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration, rt *resolvedTarget) *coreapi.BackupConfiguration { +func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration, rt *resolvedTarget, encRef *kmapi.ObjectReference) *coreapi.BackupConfiguration { var ref v1beta1.TargetRef if oldBC.Spec.Target != nil { ref = v1beta1.TargetRef{ @@ -282,7 +354,7 @@ func createBackupConfiguration(oldBC *v1beta1.BackupConfiguration, rt *resolvedT Paused: oldBC.Spec.Paused, Target: configureTarget(oldBC.Namespace, ref, rt), Backends: []coreapi.BackendReference{configureBackend(oldBC)}, - Sessions: []coreapi.Session{configureSession(oldBC, rt)}, + Sessions: []coreapi.Session{configureSession(oldBC, rt, encRef)}, }, } } @@ -505,7 +577,7 @@ func configureBackendFromBlueprint(bb *v1beta1.BackupBlueprint) coreapi.BackendR } } -func configureSession(bc *v1beta1.BackupConfiguration, rt *resolvedTarget) coreapi.Session { +func configureSession(bc *v1beta1.BackupConfiguration, rt *resolvedTarget, encRef *kmapi.ObjectReference) coreapi.Session { return coreapi.Session{ SessionConfig: &coreapi.SessionConfig{ Name: "backup", @@ -525,25 +597,59 @@ func configureSession(bc *v1beta1.BackupConfiguration, rt *resolvedTarget) corea Name: bc.Spec.Repository.Name, Backend: "storage", Directory: repositoryDirectory(rt), - EncryptionSecret: encryptionSecretRef(), + EncryptionSecret: encRef, }, }, Addon: configureBackupAddonInfo(bc, rt), } } -// encryptionSecretRef returns the encryption Secret reference from the CLI flags, -// falling back to placeholders when a flag is not provided. -func encryptionSecretRef() *kmapi.ObjectReference { - name := encryptionSecretName - if name == "" { - name = setValidValue("Name") +// encryptionSecretRef returns the encryption Secret reference for a session together +// with whether it fully resolved (no placeholder). The CLI flags win when set; when +// both are empty it references the Secret generated from the Repository's storage +// Secret (see buildEncryptionSecret), otherwise it falls back to placeholders. +func encryptionSecretRef(repoName, repoNamespace string) (*kmapi.ObjectReference, bool) { + if encryptionSecretName != "" || encryptionSecretNamespace != "" { + name := encryptionSecretName + if name == "" { + name = setValidValue("Name") + } + namespace := encryptionSecretNamespace + if namespace == "" { + namespace = setValidValue("Namespace") + } + resolved := encryptionSecretName != "" && encryptionSecretNamespace != "" + return &kmapi.ObjectReference{Name: name, Namespace: namespace}, resolved + } + + if generatedEncryptionSecretExists(repoName, repoNamespace) { + return &kmapi.ObjectReference{ + Name: encryptionSecretNameFor(repoName), + Namespace: repoNamespace, + }, true } - namespace := encryptionSecretNamespace - if namespace == "" { - namespace = setValidValue("Namespace") + return &kmapi.ObjectReference{ + Name: setValidValue("Name"), + Namespace: setValidValue("Namespace"), + }, false +} + +// generatedEncryptionSecretExists reports whether convertRepository would have +// generated an encryption Secret for the given Stash Repository, i.e. its storage +// Secret carries a RESTIC_PASSWORD. The Repository is looked up from the cluster so +// the session reference stays consistent with generation regardless of the order in +// which resources are processed. +func generatedEncryptionSecretExists(repoName, repoNamespace string) bool { + repo := &v1alpha1.Repository{} + key := client.ObjectKey{Name: repoName, Namespace: repoNamespace} + if err := klient.Get(context.Background(), key, repo); err != nil { + if !apierrors.IsNotFound(err) { + klog.Warningf("Cluster unreachable while reading Repository %s/%s; keeping encryption Secret placeholders. Reason: %v", repoNamespace, repoName, err) + } + return false } - return &kmapi.ObjectReference{Name: name, Namespace: namespace} + _, ok := resticPasswordFromStorageSecret(repo.Spec.Backend.StorageSecretName, repo.Namespace) + return ok } // repositoryDirectory derives the repository directory from the resolved target, @@ -556,12 +662,12 @@ func repositoryDirectory(rt *resolvedTarget) string { } // repositoryComments builds the review line-comments to attach to the generated YAML. -func repositoryComments(rt *resolvedTarget) map[string]string { +func repositoryComments(rt *resolvedTarget, encResolved bool) map[string]string { comments := map[string]string{} if rt != nil { comments["directory"] = "review: / of the target database" } - if encryptionSecretName == "" || encryptionSecretNamespace == "" { + if !encResolved { comments["encryptionSecret"] = "review: set via --encryption-secret-name / --encryption-secret-namespace" } return comments @@ -779,8 +885,14 @@ func convertRestoreSession(ri parser.ResourceInfo) error { } } - newRS := createRestoreSession(oldRS, rt) - if err := writeToTargetDirWithComments(ri.Filename, newRS, restoreSessionComments()); err != nil { + repoNS := oldRS.Spec.Repository.Namespace + if repoNS == "" { + repoNS = oldRS.Namespace + } + encRef, encResolved := encryptionSecretRef(oldRS.Spec.Repository.Name, repoNS) + + newRS := createRestoreSession(oldRS, rt, encRef) + if err := writeToTargetDirWithComments(ri.Filename, newRS, restoreSessionComments(encResolved)); err != nil { return err } @@ -808,7 +920,7 @@ func convertRestoreSession(ri parser.ResourceInfo) error { return nil } -func createRestoreSession(oldRS *v1beta1.RestoreSession, rt *resolvedTarget) *coreapi.RestoreSession { +func createRestoreSession(oldRS *v1beta1.RestoreSession, rt *resolvedTarget, encRef *kmapi.ObjectReference) *coreapi.RestoreSession { namespace := oldRS.Namespace if oldRS.Spec.Repository.Namespace != "" { namespace = oldRS.Spec.Repository.Namespace @@ -834,7 +946,7 @@ func createRestoreSession(oldRS *v1beta1.RestoreSession, rt *resolvedTarget) *co Namespace: namespace, Repository: oldRS.Spec.Repository.Name, Snapshot: setValidValue("Snapshot"), - EncryptionSecret: encryptionSecretRef(), + EncryptionSecret: encRef, }, Addon: configureRestoreAddonInfo(oldRS, rt), RestoreTimeout: oldRS.Spec.TimeOut, @@ -844,11 +956,11 @@ func createRestoreSession(oldRS *v1beta1.RestoreSession, rt *resolvedTarget) *co } // restoreSessionComments builds the review line-comments for a converted RestoreSession. -func restoreSessionComments() map[string]string { +func restoreSessionComments(encResolved bool) map[string]string { comments := map[string]string{ "snapshot": "review: set the snapshot to restore (e.g. latest)", } - if encryptionSecretName == "" || encryptionSecretNamespace == "" { + if !encResolved { comments["encryptionSecret"] = "review: set via --encryption-secret-name / --encryption-secret-namespace" } return comments From ab62d4fc5acb6bb2857f459fd6722a0ea6438380 Mon Sep 17 00:00:00 2001 From: Arnob Kumar Saha Date: Fri, 3 Jul 2026 11:25:47 +0600 Subject: [PATCH 4/4] Demote per-resource convert logs to -v=1 Signed-off-by: Arnob Kumar Saha --- pkg/convert/convert.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index ab884976..3a00c2b2 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -86,7 +86,7 @@ func NewCmdConvert(clientGetter genericclioptions.RESTClientGetter) *cobra.Comma } func convertResources(ri parser.ResourceInfo) error { - klog.Infof("Converting file: %s", ri.Filename) + klog.V(1).Infof("Converting file: %s", ri.Filename) switch ri.Object.GetKind() { case v1alpha1.ResourceKindRepository: return convertRepository(ri) @@ -114,7 +114,7 @@ func writeToTargetDirWithComments(srcPath string, obj any, comments map[string]s if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil { return err } - klog.Infof("Writing %s to %s", srcPath, targetPath) + klog.V(1).Infof("Writing %s to %s", srcPath, targetPath) hasContent, err := targetFileHasContent(targetPath) if err != nil {