From f09e243837a9809ae6535bc808e4bb438b1b04fe Mon Sep 17 00:00:00 2001 From: ialexeze Date: Tue, 12 May 2026 02:02:27 +0000 Subject: [PATCH] registry: improve push UX and unify pattern model Consolidate the Orkestra registry CLI around a single "pattern" mental model (rather than treating katalogs specially). Motifs and katalogs are pushed the same way and the code is now extensible to future pattern kinds. - Allow `ork registry push ` (no tag) to default to the pattern's metadata.name:metadata.version from the primary file. - Enforce tag uniformity: when a tag is provided, it is validated against metadata.version. If metadata.version is empty the tag is adopted. - Respect user intent: `--force` overrides metadata.version with the provided tag; `--update-meta` optionally persists the overridden metadata.version back to the primary YAML file. - Keep the registry/document consistent by ensuring the pushed ref and the pattern metadata agree. - Add a small YAML write-then-format helper so persisted changes are written and formatted consistently. Tests and tooling: - Add unit tests for `ExtractTagVersion` covering registry refs, digests, ports and edge cases. - Add unit test for `persistMetadataVersion` using a temporary directory. - Add simple YAML formatting helper used by persist logic. This change improves safety by warning on mismatches while giving users the autonomy to override and persist changes when they explicitly ask for it. --- cmd/cli/deploy.go | 12 +- cmd/cli/doctor.go | 14 +- cmd/cli/registry.go | 424 +------------------------- cmd/cli/registry_helpers.go | 117 +++++++ cmd/cli/registry_info.go | 64 ++++ cmd/cli/registry_list.go | 134 ++++++++ cmd/cli/registry_pull.go | 64 ++++ cmd/cli/registry_push.go | 198 ++++++++++++ pkg/doctor/bundle.go | 7 +- pkg/doctor/compose.go | 43 +++ pkg/doctor/generate.go | 10 +- pkg/doctor/state.go | 34 ++- pkg/katalog/motif_imports.go | 13 +- pkg/merger/registry.go | 2 +- pkg/motif/expander.go | 34 ++- pkg/motif/expander_test.go | 18 +- pkg/motif/loader.go | 119 +++++--- pkg/motif/testdata/valid.yaml | 7 +- pkg/registry/README.md | 107 +++++-- pkg/registry/client.go | 127 ++++---- pkg/registry/constant.go | 53 ++++ pkg/registry/docs/01-push-pull.md | 22 +- pkg/registry/docs/03-resolve-cache.md | 2 +- pkg/registry/docs/04-artifacts.md | 158 ++++++++++ pkg/registry/docs/04-patterns.md | 66 ---- pkg/registry/helper.go | 80 +++++ pkg/registry/pattern.go | 231 +++++++------- pkg/registry/persist_meta_test.go | 89 ++++++ pkg/registry/ref_test.go | 36 +++ pkg/registry/resolve.go | 55 ++-- pkg/registry/types.go | 52 ++++ pkg/types/hook_methods.go | 25 ++ pkg/types/motif.go | 39 ++- pkg/utils/yaml.go | 44 +++ 34 files changed, 1675 insertions(+), 825 deletions(-) create mode 100644 cmd/cli/registry_helpers.go create mode 100644 cmd/cli/registry_info.go create mode 100644 cmd/cli/registry_list.go create mode 100644 cmd/cli/registry_pull.go create mode 100644 cmd/cli/registry_push.go create mode 100644 pkg/registry/constant.go create mode 100644 pkg/registry/docs/04-artifacts.md delete mode 100644 pkg/registry/docs/04-patterns.md create mode 100644 pkg/registry/helper.go create mode 100644 pkg/registry/persist_meta_test.go create mode 100644 pkg/registry/ref_test.go create mode 100644 pkg/registry/types.go diff --git a/cmd/cli/deploy.go b/cmd/cli/deploy.go index 8f4ca92e..bb74d302 100644 --- a/cmd/cli/deploy.go +++ b/cmd/cli/deploy.go @@ -182,7 +182,10 @@ to the cluster, and patch the CR to trigger a rolling deploy. // Step 3 — Resolve motif + generate bundle from central Katalog fmt.Println("\nGenerating bundle...") - bundleDir := filepath.Join(dir, orkDir, "bundle") + bundleDir, err := doctor.AppBundleDir(appName) + if err != nil { + return fmt.Errorf("resolving bundle dir: %w", err) + } if len(info.Secrets) > 0 { fmt.Printf(" %s %s-secrets (%d variables from .env)\n", utils.SuccessMark(), appName, len(info.Secrets)) @@ -453,7 +456,10 @@ func deployMultiApp(dc deployContext) error { image := doctor.ImageTag(dc.registry, appName, tag) crName := appName + orkSuffix ns := crName + "-ns" - bundleDir := filepath.Join(dc.dir, orkDir, appName, "bundle") + bundleDir, err := doctor.AppBundleDir(appName) + if err != nil { + return fmt.Errorf("resolving bundle dir for %s: %w", appName, err) + } katalogPath := filepath.Join(dc.dir, orkDir, appName, "katalog.yaml") appInfo, _ := doctor.Detect(entry.Dir) @@ -1070,7 +1076,7 @@ type devPathArgs struct { } // deployDeveloperPath implements the developer deploy flow: -// 1. Load the motif template from ~/.orkestra/apps//motif.yaml +// 1. Load the motif template from ~/.orkestra/doctor/init/apps//motif.yaml // 2. Collect all deployed app namespaces (current + previously deployed) for allowedNamespaces // 3. Write ~/.orkestra/deploy/katalog.yaml with ONE platform CRD — resources // embedded directly in operatorBox.onReconcile (no file imports) diff --git a/cmd/cli/doctor.go b/cmd/cli/doctor.go index c18bc9ad..3badd6b6 100644 --- a/cmd/cli/doctor.go +++ b/cmd/cli/doctor.go @@ -370,7 +370,10 @@ var doctorInitCmd = &cobra.Command{ } state.Projects[name].Dir = dir state.Projects[name].UseCompose = useCompose != "" - state.Projects[name].ComposeFile = useCompose + if useCompose != "" { + absCompose, _ := filepath.Abs(useCompose) + state.Projects[name].ComposeFile = absCompose + } _ = state.Save() fmt.Println() @@ -551,7 +554,7 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate } state.Projects[a.Name].Dir = a.Dir state.Projects[a.Name].UseCompose = useCompose != "" - state.Projects[a.Name].ComposeFile = useCompose + state.Projects[a.Name].ComposeFile = composePath // already absolute } state.DirApps[baseDir] = appNames if err := state.Save(); err != nil { @@ -586,11 +589,8 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate } fmt.Printf(" .orkestra/%s/app.yaml\n", app.name) for _, dep := range deps { - fmt.Printf(" ↳ %s (e.g. %sImage, %sVolumeSize)\n", - dep.Motif.MotifRef, - dep.Motif.MotifRef, - dep.Motif.MotifRef, - ) + hint := strings.Join(dep.Motif.AppYAMLKeys, ", ") + fmt.Printf(" ↳ %s (e.g. %s)\n", dep.Motif.MotifRef, hint) } } } diff --git a/cmd/cli/registry.go b/cmd/cli/registry.go index 4b40133e..63deeaab 100644 --- a/cmd/cli/registry.go +++ b/cmd/cli/registry.go @@ -9,327 +9,23 @@ package cli -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "text/tabwriter" - "time" - - "github.com/orkspace/orkestra/pkg/katalog" - "github.com/orkspace/orkestra/pkg/merger" - "github.com/orkspace/orkestra/pkg/registry" - "github.com/orkspace/orkestra/pkg/utils" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) +import "github.com/spf13/cobra" var registryCmd = &cobra.Command{ Use: "registry", Short: "Push, pull, and inspect Orkestra patterns from OCI registries", - Long: `Manage Orkestra operator patterns in OCI registries. + Long: `Manage Orkestra patterns (patterns and motifs) in OCI registries. - ork registry push : push a pattern directory - ork registry pull : pull a pattern to local cache + ork registry push : push a pattern or motif directory + ork registry pull : pull an pattern to local cache ork registry info : show pattern metadata ork registry list [registry-url] list available patterns Authentication uses ~/.docker/config.json — run 'docker login' first. -Override the default registry with ORKESTRA_REGISTRY: - - export ORKESTRA_REGISTRY=oci://myregistry.internal/patterns`, -} - -// ── push ────────────────────────────────────────────────────────────────────── - -var registryPushCmd = &cobra.Command{ - Use: "push : ", - Short: "Push a pattern directory to the registry", - Args: cobra.ExactArgs(2), - Example: ` ork registry push postgres:v14 ./postgres/ - ork registry push mycompany/payments:v1.0.0 ./payments/ - ORKESTRA_REGISTRY=oci://myregistry.io/patterns ork registry push redis:v7 ./redis/`, - RunE: func(cmd *cobra.Command, args []string) error { - ref, err := registry.Resolve(args[0]) - if err != nil { - return fmt.Errorf("invalid reference: %w", err) - } - dir, err := filepath.Abs(args[1]) - if err != nil { - return err - } - - printBanner() - fmt.Printf("Validating pattern...\n") - - meta, files, err := registry.ValidateDirectory(dir) - if err != nil { - return fmt.Errorf("\n ✗ %w", err) - } - - // Validate katalog.yaml — same pipeline as ork validate. - m := merger.New(filepath.Join(dir, registry.FileKatalog)) - if err := m.Merge(); err != nil { - return fmt.Errorf(" ✗ katalog.yaml: %w", err) - } - var kat katalog.Katalog - if _, err := kat.KomposeRuntimeKatalog(kfg, m); err != nil { - return fmt.Errorf(" ✗ katalog.yaml: %w", err) - } - if _, err := kat.ValidateConfig(kfg); err != nil { - return fmt.Errorf(" ✗ katalog.yaml: %w", err) - } - fmt.Printf(" %s %-20s valid\n", utils.ColorGreen+"✓"+utils.ColorReset, "katalog.yaml") - - // Validate crd.yaml has the required CRD structure. - if err := validateCRDFile(filepath.Join(dir, registry.FileCRD)); err != nil { - return fmt.Errorf(" ✗ crd.yaml: %w", err) - } - fmt.Printf(" %s %-20s valid\n", utils.ColorGreen+"✓"+utils.ColorReset, "crd.yaml") - - for _, f := range files { - if f == registry.FileKatalog || f == registry.FileCRD { - continue // already printed above - } - info, _ := os.Stat(filepath.Join(dir, f)) - fmt.Printf(" %s %-20s (%s)\n", utils.ColorGreen+"✓"+utils.ColorReset, f, formatSize(info.Size())) - } - - fmt.Printf("\nPushing %s to %s...\n", meta.Name+":"+meta.Version, ref.Registry) - - client, err := registry.NewClient() - if err != nil { - return fmt.Errorf("initializing client: %w", err) - } - - progress := func(file string, size int64) { - fmt.Printf(" → %-20s (%s)\n", file, formatSize(size)) - } - - digest, err := client.Push(cmd.Context(), ref, dir, progress) - if err != nil { - return fmt.Errorf("push failed: %w", err) - } - - fmt.Printf("\n%s Pushed: %s\n", utils.ColorGreen+"✓"+utils.ColorReset, ref.String()) - fmt.Printf(" Digest: %s\n", digest[:19]+"...") - fmt.Printf("\nReference this pattern in a Komposer:\n") - fmt.Printf(" sources:\n") - fmt.Printf(" registry:\n") - fmt.Printf(" - url: %s\n", ref.String()) - return nil - }, -} - -// ── pull ────────────────────────────────────────────────────────────────────── - -var registryPullCmd = &cobra.Command{ - Use: "pull :", - Short: "Pull a pattern to the local cache", - Args: cobra.ExactArgs(1), - Example: ` ork registry pull postgres:v14 - ork registry pull oci://ghcr.io/myorg/patterns/redis:v7 - ork registry pull postgres:v14 --refresh - ork registry pull postgres:v14 --out ./postgres/`, - RunE: func(cmd *cobra.Command, args []string) error { - refresh, _ := cmd.Flags().GetBool("refresh") - outDir, _ := cmd.Flags().GetString("out") - - ref, err := registry.Resolve(args[0]) - if err != nil { - return fmt.Errorf("invalid reference: %w", err) - } - - client, err := registry.NewClient() - if err != nil { - return fmt.Errorf("initializing client: %w", err) - } - - if ref.IsCached() && !refresh { - cacheDir, _ := ref.CachePath() - fmt.Printf(" %s Already cached\n", utils.ColorGreen+"✓"+utils.ColorReset) - fmt.Printf(" → %s\n", cacheDir) - printPullSuggestions(ref, cacheDir) - return nil - } - - fmt.Printf("Pulling %s...\n → %s\n", ref.ShortName(), ref.String()) - - cacheDir, err := client.Pull(cmd.Context(), ref, refresh) - if err != nil { - return fmt.Errorf("pull failed: %w", err) - } - - // Copy to --out if requested - if outDir != "" { - if err := copyDir(cacheDir, outDir); err != nil { - return fmt.Errorf("extracting to %s: %w", outDir, err) - } - fmt.Printf(" %s Extracted to %s\n", utils.ColorGreen+"✓"+utils.ColorReset, outDir) - return nil - } - - fmt.Printf(" %s Cached at %s\n", utils.ColorGreen+"✓"+utils.ColorReset, cacheDir) - printPullSuggestions(ref, cacheDir) - return nil - }, -} - -func printPullSuggestions(ref *registry.Ref, cacheDir string) { - fmt.Printf("\nTo use this pattern:\n") - fmt.Printf(" ork run -f %s\n", filepath.Join(cacheDir, registry.FileKatalog)) - fmt.Printf("\nOr reference in a Komposer:\n") - fmt.Printf(" sources:\n") - fmt.Printf(" registry:\n") - fmt.Printf(" - url: %s\n", ref.String()) -} - -// ── info ────────────────────────────────────────────────────────────────────── - -var registryInfoCmd = &cobra.Command{ - Use: "info :", - Short: "Show metadata for a pattern version", - Args: cobra.ExactArgs(1), - Example: ` ork registry info postgres:v14 - ork registry info oci://ghcr.io/myorg/patterns/redis:v7`, - RunE: func(cmd *cobra.Command, args []string) error { - ref, err := registry.Resolve(args[0]) - if err != nil { - return fmt.Errorf("invalid reference: %w", err) - } - - client, err := registry.NewClient() - if err != nil { - return fmt.Errorf("initializing client: %w", err) - } - - info, err := client.Info(cmd.Context(), ref) - if err != nil { - return fmt.Errorf("fetching info: %w", err) - } - - m := info.Meta - fmt.Printf("\n%s:%s\n", m.Name, m.Version) - fmt.Printf(" Registry: %s\n", ref.Registry) - fmt.Printf(" Digest: %s\n", info.Digest[:19]+"...") - if !info.PushedAt.IsZero() { - fmt.Printf(" Pushed: %s\n", info.PushedAt.Format(time.RFC3339)) - } - fmt.Printf(" Size: %s\n", formatSize(info.Size)) - fmt.Printf("\n Description: %s\n", wordWrap(m.Description, 55, " ")) - if len(m.Tags) > 0 { - fmt.Printf(" Tags: %s\n", strings.Join(m.Tags, ", ")) - } - if m.Author != "" { - fmt.Printf(" Author: %s\n", m.Author) - } - if m.License != "" { - fmt.Printf(" License: %s\n", m.License) - } - if len(m.Requires.Providers) > 0 { - fmt.Printf("\n Requires:\n") - for _, p := range m.Requires.Providers { - fmt.Printf(" - %s provider\n", p) - } - } - if len(m.Changelog) > 0 { - fmt.Printf("\n Changelog:\n") - for _, e := range m.Changelog { - fmt.Printf(" %-10s %s\n", e.Version, e.Notes) - } - } - fmt.Printf("\nTo pull:\n") - fmt.Printf(" ork registry pull %s:%s\n", m.Name, m.Version) - fmt.Printf("\nTo use in a Komposer:\n") - fmt.Printf(" sources:\n") - fmt.Printf(" registry:\n") - fmt.Printf(" - url: %s\n", ref.String()) - fmt.Println() - return nil - }, -} - -// ── list ────────────────────────────────────────────────────────────────────── - -var registryListCmd = &cobra.Command{ - Use: "list [registry-url]", - Short: "List available patterns in a registry", - Args: cobra.MaximumNArgs(1), - Example: ` ork registry list - ork registry list oci://ghcr.io/mycompany/patterns - ork registry list --tag database`, - RunE: func(cmd *cobra.Command, args []string) error { - tag, _ := cmd.Flags().GetString("tag") +Override the default registries with environment variables: - registryURL := registry.DefaultRegistry - if len(args) > 0 { - registryURL = args[0] - } else if env := os.Getenv(registry.EnvRegistry); env != "" { - registryURL = env - } - - client, err := registry.NewClient() - if err != nil { - return fmt.Errorf("initializing client: %w", err) - } - - index, err := client.List(cmd.Context(), registryURL) - if err != nil { - return fmt.Errorf("listing patterns: %w", err) - } - - displayURL := strings.TrimPrefix(registryURL, "oci://") - fmt.Printf("\nOrkestra Registry (%s)\n", displayURL) - fmt.Printf("%s\n", strings.Repeat("─", 57)) - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tLATEST\tTAGS\tDESCRIPTION") - - count := 0 - for _, p := range index.Patterns { - if tag != "" && !containsTag(p.Tags, tag) { - continue - } - tags := strings.Join(p.Tags, ", ") - if len(tags) > 22 { - tags = tags[:19] + "..." - } - desc := p.Description - if len(desc) > 30 { - desc = desc[:27] + "..." - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Name, p.LatestVersion, tags, desc) - count++ - } - w.Flush() - - updatedAt := "unknown" - if index.UpdatedAt != "" { - if t, err := time.Parse(time.RFC3339, index.UpdatedAt); err == nil { - updatedAt = humanDuration(time.Since(t)) + " ago" - } - } - - patternText := "patterns" - if count == 1 { - patternText = "pattern" - } - - if count == 0 { - fmt.Printf("\n%d %s · %s \n", count, patternText, displayURL) - } else { - fmt.Printf("\n%d %s · %s · Updated %s\n", count, patternText, displayURL, updatedAt) - } - - fmt.Printf("\nTo pull a pattern:\n ork registry pull :\n") - if os.Getenv(registry.EnvRegistry) == "" { - fmt.Printf("\nTo use a different registry:\n export %s=oci://myregistry.internal/patterns\n", registry.EnvRegistry) - } - fmt.Println() - return nil - }, + export ORKESTRA_REGISTRY=oci://myregistry.internal/patterns + export ORKESTRA_MOTIFS_REGISTRY=oci://myregistry.internal/motifs`, } // ── registration ────────────────────────────────────────────────────────────── @@ -345,6 +41,10 @@ func init() { registryPullCmd.Flags().StringP("out", "o", "", "Extract pulled pattern to this directory") registryListCmd.Flags().StringP("tag", "t", "", "Filter by tag (e.g. database, stateful, security)") + registryListCmd.Flags().BoolP("katalogs", "k", false, "Show only katalogs (kind: Katalog)") + registryListCmd.Flags().BoolP("motifs", "m", false, "Show only motifs (kind: Motif)") + registryPushCmd.Flags().BoolVar(®istryPushForce, "force", false, "force push even if metadata.version differs from tag") + registryPushCmd.Flags().BoolVar(®istryPushUpdateMeta, "update-meta", false, "persist overridden metadata.version back to the primary file") // Shadow global flags for _, cmd := range []*cobra.Command{registryCmd, registryPushCmd, registryPullCmd, registryInfoCmd, registryListCmd} { @@ -358,105 +58,3 @@ func init() { cmd.Flags().MarkHidden("verbose") } } - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func formatSize(b int64) string { - const unit = 1024 - if b < unit { - return fmt.Sprintf("%d B", b) - } - div, exp := int64(unit), 0 - for n := b / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) -} - -func wordWrap(s string, width int, indent string) string { - if len(s) <= width { - return s - } - return s[:width] + "\n" + indent + wordWrap(s[width:], width, indent) -} - -func containsTag(tags []string, tag string) bool { - for _, t := range tags { - if strings.EqualFold(t, tag) { - return true - } - } - return false -} - -func humanDuration(d time.Duration) string { - switch { - case d < time.Minute: - return fmt.Sprintf("%ds", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) - } -} - -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, _ := filepath.Rel(src, path) - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, 0755) - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(target, data, 0644) - }) -} - -// withContext wraps RunE to inject a context — used for cancellation. -func withContext(ctx context.Context, fn func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - cmd.SetContext(ctx) - return fn(cmd, args) - } -} - -// validateCRDFile checks that path is a valid YAML file with the required -// CustomResourceDefinition fields: apiVersion, kind, spec.group, spec.names.kind. -func validateCRDFile(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - var crd struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Spec struct { - Group string `yaml:"group"` - Names struct { - Kind string `yaml:"kind"` - } `yaml:"names"` - } `yaml:"spec"` - } - if err := yaml.Unmarshal(data, &crd); err != nil { - return fmt.Errorf("invalid YAML: %w", err) - } - if crd.Kind != "CustomResourceDefinition" { - return fmt.Errorf("kind must be CustomResourceDefinition, got %q", crd.Kind) - } - if crd.Spec.Group == "" { - return fmt.Errorf("spec.group is required") - } - if crd.Spec.Names.Kind == "" { - return fmt.Errorf("spec.names.kind is required") - } - return nil -} diff --git a/cmd/cli/registry_helpers.go b/cmd/cli/registry_helpers.go new file mode 100644 index 00000000..5562bcbb --- /dev/null +++ b/cmd/cli/registry_helpers.go @@ -0,0 +1,117 @@ +//go:build !runtime + +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/orkspace/orkestra/pkg/registry" + "gopkg.in/yaml.v3" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func printPullSuggestions(ref *registry.Ref, cacheDir string) { + fmt.Printf("\nTo use this pattern:\n") + fmt.Printf(" ork run -f %s\n", filepath.Join(cacheDir, registry.FileKatalog)) + fmt.Printf("\nOr reference in a Komposer:\n") + fmt.Printf(" sources:\n") + fmt.Printf(" registry:\n") + fmt.Printf(" - url: %s\n", ref.String()) +} + +func formatSize(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func wordWrap(s string, width int, indent string) string { + if len(s) <= width { + return s + } + return s[:width] + "\n" + indent + wordWrap(s[width:], width, indent) +} + +func containsTag(tags []string, tag string) bool { + for _, t := range tags { + if strings.EqualFold(t, tag) { + return true + } + } + return false +} + +func humanDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, path) + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0644) + }) +} + +// validateCRDFile checks that path is a valid YAML file with the required +// CustomResourceDefinition fields: apiVersion, kind, spec.group, spec.names.kind. +func validateCRDFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var crd struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec struct { + Group string `yaml:"group"` + Names struct { + Kind string `yaml:"kind"` + } `yaml:"names"` + } `yaml:"spec"` + } + if err := yaml.Unmarshal(data, &crd); err != nil { + return fmt.Errorf("invalid YAML: %w", err) + } + if crd.Kind != "CustomResourceDefinition" { + return fmt.Errorf("kind must be CustomResourceDefinition, got %q", crd.Kind) + } + if crd.Spec.Group == "" { + return fmt.Errorf("spec.group is required") + } + if crd.Spec.Names.Kind == "" { + return fmt.Errorf("spec.names.kind is required") + } + return nil +} diff --git a/cmd/cli/registry_info.go b/cmd/cli/registry_info.go new file mode 100644 index 00000000..a63c9d7f --- /dev/null +++ b/cmd/cli/registry_info.go @@ -0,0 +1,64 @@ +//go:build !runtime + +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/orkspace/orkestra/pkg/registry" + "github.com/spf13/cobra" +) + +// ── info ────────────────────────────────────────────────────────────────────── + +var registryInfoCmd = &cobra.Command{ + Use: "info :", + Short: "Show metadata for a pattern version", + Args: cobra.ExactArgs(1), + Example: ` ork registry info postgres:v14 + ork registry info oci://ghcr.io/myorg/patterns/redis:v7`, + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := registry.Resolve(args[0]) + if err != nil { + return fmt.Errorf("invalid reference: %w", err) + } + + client, err := registry.NewClient() + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + + info, err := client.Info(cmd.Context(), ref) + if err != nil { + return fmt.Errorf("fetching info: %w", err) + } + + m := info.Meta + fmt.Printf("\n%s:%s\n", m.Name, m.Version) + fmt.Printf(" Registry: %s\n", ref.Registry) + if m.Kind != "" { + fmt.Printf(" Kind: %s\n", m.Kind) + } + fmt.Printf(" Digest: %s\n", info.Digest[:19]+"...") + if !info.PushedAt.IsZero() { + fmt.Printf(" Pushed: %s\n", info.PushedAt.Format(time.RFC3339)) + } + fmt.Printf(" Size: %s\n", formatSize(info.Size)) + fmt.Printf("\n Description: %s\n", wordWrap(m.Description, 55, " ")) + if len(m.Tags) > 0 { + fmt.Printf(" Tags: %s\n", strings.Join(m.Tags, ", ")) + } + if m.Author != "" { + fmt.Printf(" Author: %s\n", m.Author) + } + if m.License != "" { + fmt.Printf(" License: %s\n", m.License) + } + fmt.Printf("\nTo pull:\n") + fmt.Printf(" ork registry pull %s:%s\n", m.Name, m.Version) + fmt.Println() + return nil + }, +} diff --git a/cmd/cli/registry_list.go b/cmd/cli/registry_list.go new file mode 100644 index 00000000..1c5e8811 --- /dev/null +++ b/cmd/cli/registry_list.go @@ -0,0 +1,134 @@ +//go:build !runtime + +package cli + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/orkspace/orkestra/pkg/registry" + "github.com/spf13/cobra" +) + +// ── list ────────────────────────────────────────────────────────────────────── + +var registryListCmd = &cobra.Command{ + Use: "list [registry-url]", + Short: "List available patterns in the registry", + Args: cobra.MaximumNArgs(1), + Example: ` ork registry list + ork registry list --motifs + ork registry list --katalogs + ork registry list oci://ghcr.io/mycompany/patterns + ork registry list --tag database`, + RunE: func(cmd *cobra.Command, args []string) error { + tag, _ := cmd.Flags().GetString("tag") + onlyKatalogs, _ := cmd.Flags().GetBool("katalogs") + onlyMotifs, _ := cmd.Flags().GetBool("motifs") + + client, err := registry.NewClient() + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + + var entries []registry.PatternEntry + var latestUpdatedAt string + + if len(args) > 0 { + // Explicit registry URL — fetch only that one. + idx, err := client.List(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("listing katalogs: %w", err) + } + entries = idx.Entries + latestUpdatedAt = idx.UpdatedAt + } else { + // Fetch katalogs unless --motifs only. + if !onlyMotifs { + patURL := os.Getenv(registry.EnvPatternRegistry) + if patURL == "" { + patURL = registry.DefaultPatternRegistry + } + idx, _ := client.List(cmd.Context(), patURL) + if idx != nil { + entries = append(entries, idx.Entries...) + if idx.UpdatedAt > latestUpdatedAt { + latestUpdatedAt = idx.UpdatedAt + } + } + } + // Fetch motifs unless --katalogs only. + if !onlyKatalogs { + motifURL := os.Getenv(registry.EnvMotifRegistry) + if motifURL == "" { + motifURL = registry.DefaultMotifRegistry + } + idx, _ := client.List(cmd.Context(), motifURL) + if idx != nil { + entries = append(entries, idx.Entries...) + if idx.UpdatedAt > latestUpdatedAt { + latestUpdatedAt = idx.UpdatedAt + } + } + } + } + + label := "Orkestra Registry" + switch { + case onlyKatalogs: + label = "Orkestra Katalogs" + case onlyMotifs: + label = "Orkestra Motifs" + } + fmt.Printf("\n%s\n", label) + fmt.Printf("%s\n", strings.Repeat("─", 57)) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tLATEST\tKIND\tTAGS\tDESCRIPTION") + + count := 0 + for _, e := range entries { + if tag != "" && !containsTag(e.Tags, tag) { + continue + } + k := e.Kind + if onlyKatalogs && k != registry.KatalogKind.ToString() { + continue + } + if onlyMotifs && k != registry.MotifKind.ToString() { + continue + } + tags := strings.Join(e.Tags, ", ") + if len(tags) > 22 { + tags = tags[:19] + "..." + } + desc := e.Description + if len(desc) > 30 { + desc = desc[:27] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.Name, e.LatestVersion, k, tags, desc) + count++ + } + w.Flush() + + updatedAt := "" + if latestUpdatedAt != "" { + if t, err := time.Parse(time.RFC3339, latestUpdatedAt); err == nil { + updatedAt = " · Updated " + humanDuration(time.Since(t)) + " ago" + } + } + noun := "patterns" + if count == 1 { + noun = "pattern" + } + fmt.Printf("\n%d %s%s\n", count, noun, updatedAt) + + fmt.Printf("\nTo pull:\n ork registry pull :\n") + fmt.Printf("\nTo filter:\n ork registry list --katalogs\n ork registry list --motifs\n") + fmt.Println() + return nil + }, +} diff --git a/cmd/cli/registry_pull.go b/cmd/cli/registry_pull.go new file mode 100644 index 00000000..b1604588 --- /dev/null +++ b/cmd/cli/registry_pull.go @@ -0,0 +1,64 @@ +//go:build !runtime + +package cli + +import ( + "fmt" + "github.com/orkspace/orkestra/pkg/registry" + "github.com/orkspace/orkestra/pkg/utils" + "github.com/spf13/cobra" +) + +// ── pull ────────────────────────────────────────────────────────────────────── + +var registryPullCmd = &cobra.Command{ + Use: "pull :", + Short: "Pull a pattern to the local cache", + Args: cobra.ExactArgs(1), + Example: ` ork registry pull postgres:v14 + ork registry pull oci://ghcr.io/myorg/patterns/redis:v7 + ork registry pull postgres:v14 --refresh + ork registry pull postgres:v14 --out ./postgres/`, + RunE: func(cmd *cobra.Command, args []string) error { + refresh, _ := cmd.Flags().GetBool("refresh") + outDir, _ := cmd.Flags().GetString("out") + + ref, err := registry.Resolve(args[0]) + if err != nil { + return fmt.Errorf("invalid reference: %w", err) + } + + client, err := registry.NewClient() + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + + if ref.IsCached() && !refresh { + cacheDir, _ := ref.CachePath() + fmt.Printf(" %s Already cached\n", utils.ColorGreen+"✓"+utils.ColorReset) + fmt.Printf(" → %s\n", cacheDir) + printPullSuggestions(ref, cacheDir) + return nil + } + + fmt.Printf("Pulling %s...\n → %s\n", ref.ShortName(), ref.String()) + + cacheDir, err := client.Pull(cmd.Context(), ref, refresh) + if err != nil { + return fmt.Errorf("pull failed: %w", err) + } + + // Copy to --out if requested + if outDir != "" { + if err := copyDir(cacheDir, outDir); err != nil { + return fmt.Errorf("extracting to %s: %w", outDir, err) + } + fmt.Printf(" %s Extracted to %s\n", utils.ColorGreen+"✓"+utils.ColorReset, outDir) + return nil + } + + fmt.Printf(" %s Cached at %s\n", utils.ColorGreen+"✓"+utils.ColorReset, cacheDir) + printPullSuggestions(ref, cacheDir) + return nil + }, +} diff --git a/cmd/cli/registry_push.go b/cmd/cli/registry_push.go new file mode 100644 index 00000000..dd9f252f --- /dev/null +++ b/cmd/cli/registry_push.go @@ -0,0 +1,198 @@ +//go:build !runtime + +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/orkspace/orkestra/pkg/katalog" + "github.com/orkspace/orkestra/pkg/merger" + "github.com/orkspace/orkestra/pkg/registry" + "github.com/orkspace/orkestra/pkg/utils" + "github.com/spf13/cobra" +) + +// ── push ────────────────────────────────────────────────────────────────────── + +var ( + registryPushForce bool + registryPushUpdateMeta bool +) + +var registryPushCmd = &cobra.Command{ + Use: "push : OR push ", + Short: "Push a pattern or motif directory to the registry", + Args: cobra.RangeArgs(1, 2), + Example: ` ork registry push postgres:v14 ./patterns/postgres/ + ork registry push redis:v7 ./motifs/redis/ + ORKESTRA_REGISTRY=oci://myregistry.io/patterns ork registry push payments:v1.0 ./payments/ + ork registry push ./patterns/postgres/ # use metadata.name:metadata.version from the pattern`, + RunE: func(cmd *cobra.Command, args []string) error { + var ( + refArg string + dirArg string + ) + + // Two forms: + // 1) push + // 2) push (use metadata.name:metadata.version) + if len(args) == 2 { + refArg = args[0] + dirArg = args[1] + } else { + // single arg: treat as directory + refArg = "" // will be derived from metadata + dirArg = args[0] + } + + dir, err := filepath.Abs(dirArg) + if err != nil { + return err + } + + // Auto-detect pattern kind before resolving the registry URL. + patternKind, spec, files, err := registry.ValidatePatternDirectory(dir) + if err != nil { + return fmt.Errorf("\n ✗ %w", err) + } + + // Load metadata from the primary file + meta, err := registry.LoadPatternMeta(dir, spec) + if err != nil { + return fmt.Errorf("reading metadata: %w", err) + } + + // If user provided a ref, extract tag and validate against metadata.version + var providedTag string + if refArg != "" { + providedTag = registry.ExtractTagVersion(refArg) + // If providedTag is empty, user may have passed a registry path without tag. + // We'll still resolve the ref below; but only validate if a tag was present. + } + + // If user did not provide a ref, build one from metadata.name:metadata.version + if refArg == "" { + if meta.Name == "" { + return fmt.Errorf("metadata.name is required in %s", spec.PrimaryFile) + } + if meta.Version == "" { + meta.Version = "latest" + } + refArg = fmt.Sprintf("%s:%s", meta.Name, meta.Version) + providedTag = meta.Version + } else { + // If user provided a tag and metadata has a non-empty version, ensure they match. + if providedTag != "" && meta.Version != "" && meta.Version != providedTag { + msg := fmt.Errorf("%s: metadata.version %q does not match provided tag %q; use '--force' to override", spec.PrimaryFile, meta.Version, providedTag) + + if registryPushForce { + fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v (continuing due to --force)\n", utils.Yellow("Warning"), msg) + + // persist if requested + if registryPushUpdateMeta { + if err := registry.PersistMetadataVersion(dir, spec.PrimaryFile, providedTag); err != nil { + return fmt.Errorf("failed to update metadata in %s: %w", spec.PrimaryFile, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "updated %s: metadata.version -> %q\n", spec.PrimaryFile, providedTag) + } + + meta.Version = providedTag + } else { + return msg + } + } + // If metadata.version is empty but user provided a tag, populate meta.Version so we remain consistent. + if meta.Version == "" && providedTag != "" { + meta.Version = providedTag + } + } + + // Resolve against the kind-specific default registry. + ref, err := registry.ResolveForKind(refArg, patternKind) + if err != nil { + return fmt.Errorf("invalid reference: %w", err) + } + + printBanner() + fmt.Printf("Pushing %s (%s) to %s...\n", refArg, patternKind, ref.Registry) + + // Kind-specific validation. + if patternKind == registry.KatalogKind { + m := merger.New(filepath.Join(dir, registry.FileKatalog)) + if err := m.Merge(); err != nil { + return fmt.Errorf(" ✗ %s: %w", registry.FileKatalog, err) + } + var kat katalog.Katalog + if _, err := kat.KomposeRuntimeKatalog(kfg, m); err != nil { + return fmt.Errorf(" ✗ %s: %w", registry.FileKatalog, err) + } + if _, err := kat.ValidateConfig(kfg); err != nil { + return fmt.Errorf(" ✗ %s: %w", registry.FileKatalog, err) + } + fmt.Printf(" %s %-20s valid\n", utils.ColorGreen+"✓"+utils.ColorReset, registry.FileKatalog) + + if err := validateCRDFile(filepath.Join(dir, registry.FileCRD)); err != nil { + return fmt.Errorf(" ✗ %s: %w", registry.FileCRD, err) + } + fmt.Printf(" %s %-20s valid\n", utils.ColorGreen+"✓"+utils.ColorReset, registry.FileCRD) + } + + for _, f := range files { + if f == registry.FileKatalog || f == registry.FileCRD { + continue // already printed above + } + info, _ := os.Stat(filepath.Join(dir, f)) + fmt.Printf(" %s %-20s (%s)\n", utils.ColorGreen+"✓"+utils.ColorReset, f, formatSize(info.Size())) + } + + client, err := registry.NewClient() + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + + progress := func(file string, size int64) { + fmt.Printf(" → %-20s (%s)\n", file, formatSize(size)) + } + + digest, err := client.Push(cmd.Context(), ref, dir, progress) + if err != nil { + return fmt.Errorf("push failed: %w", err) + } + + fmt.Printf("\n%s Pushed: %s\n", utils.ColorGreen+"✓"+utils.ColorReset, ref.String()) + fmt.Printf(" Digest: %s\n", digest[:19]+"...") + + // If a pattern directory also contains motif.yaml, push it separately + // to the motif registry so it can be imported standalone. + if patternKind == registry.KatalogKind { + motifYAML := filepath.Join(dir, registry.FileMotif) + if _, err := os.Stat(motifYAML); err == nil { + motifRef, err := registry.ResolveForKind(fmt.Sprintf("%s:%s", meta.Name, meta.Version), registry.MotifKind) + if err == nil { + fmt.Printf("\nAlso pushing %s to %s...\n", registry.FileMotif, motifRef.Registry) + if mDigest, err := client.Push(cmd.Context(), motifRef, dir, progress); err != nil { + fmt.Fprintf(os.Stderr, "warning: motif push failed: %v\n", err) + } else { + fmt.Printf("%s Pushed motif: %s\n", utils.ColorGreen+"✓"+utils.ColorReset, motifRef.String()) + fmt.Printf(" Digest: %s\n", mDigest[:19]+"...") + } + } + } + } + + fmt.Printf("\nTo import in a Katalog:\n") + if patternKind == registry.MotifKind { + fmt.Printf(" imports:\n") + fmt.Printf(" - motif: %s\n", ref.String()) + } else { + fmt.Printf(" sources:\n") + fmt.Printf(" registry:\n") + fmt.Printf(" - url: %s\n", ref.String()) + } + + _ = meta + return nil + }, +} diff --git a/pkg/doctor/bundle.go b/pkg/doctor/bundle.go index 8c03a49b..40f14ac6 100644 --- a/pkg/doctor/bundle.go +++ b/pkg/doctor/bundle.go @@ -55,9 +55,8 @@ func GenerateBundle(name, namespace string, secrets, config []orktypes.EnvVar, o func buildSecret(name, namespace string, vars []orktypes.EnvVar) string { var b strings.Builder - b.WriteString("# .orkestra/bundle/app-secrets.yaml\n") - b.WriteString("# Generated by ork doctor deploy — DO NOT COMMIT THIS FILE\n") - b.WriteString("# Add .orkestra/bundle/ to .gitignore\n\n") + b.WriteString("# ~/.orkestra/doctor/init/apps//bundle/app-secrets.yaml\n") + b.WriteString("# Generated by ork doctor deploy — DO NOT COMMIT THIS FILE\n\n") b.WriteString("apiVersion: v1\n") b.WriteString("kind: Secret\n") b.WriteString("metadata:\n") @@ -74,7 +73,7 @@ func buildSecret(name, namespace string, vars []orktypes.EnvVar) string { func buildConfigMap(name, namespace string, vars []orktypes.EnvVar) string { var b strings.Builder - b.WriteString("# .orkestra/bundle/app-config.yaml\n") + b.WriteString("# ~/.orkestra/doctor/init/apps//bundle/app-config.yaml\n") b.WriteString("# Generated by ork doctor deploy — DO NOT COMMIT THIS FILE\n\n") b.WriteString("apiVersion: v1\n") b.WriteString("kind: ConfigMap\n") diff --git a/pkg/doctor/compose.go b/pkg/doctor/compose.go index b257e3ad..f815ea38 100644 --- a/pkg/doctor/compose.go +++ b/pkg/doctor/compose.go @@ -450,3 +450,46 @@ func detectMotif(image string) (KnownMotif, bool) { } return KnownMotif{}, false } + +// ParseBuildContext resolves the build: field for a service to absolute paths. +// Returns the build context directory and the Dockerfile path. +// +// build: ./frontend → context=/frontend, dockerfile=/Dockerfile +// build: {context: ./api, dockerfile: Dockerfile.prod} +// → context=/api, dockerfile=/Dockerfile.prod +// build: nil → "", "" (service has no build: — uses a pre-built image) +func ParseBuildContext(svc ComposeService, projectDir string) (buildCtx, dockerfile string) { + rawCtx, rawDF := svc.BuildContext() + if rawCtx == "" && rawDF == "" { + return "", "" + } + + if rawCtx == "" { + rawCtx = "." + } + + if filepath.IsAbs(rawCtx) { + buildCtx = rawCtx + } else { + buildCtx = filepath.Join(projectDir, rawCtx) + } + + if rawDF == "" { + dockerfile = filepath.Join(buildCtx, "Dockerfile") + } else if filepath.IsAbs(rawDF) { + dockerfile = rawDF + } else { + dockerfile = filepath.Join(buildCtx, rawDF) + } + + return buildCtx, dockerfile +} + +// ServiceEnvVars is the public alias for resolveServiceEnvVars. +// Returns merged env vars for a single compose service in priority order: +// 1. root .env in projectDir (lowest priority) +// 2. each env_file entry (in declaration order) +// 3. environment: block (highest priority) +func ServiceEnvVars(svc ComposeService, projectDir string) []orktypes.EnvVar { + return resolveServiceEnvVars(svc, projectDir) +} diff --git a/pkg/doctor/generate.go b/pkg/doctor/generate.go index 9ad4fe08..e37bb036 100644 --- a/pkg/doctor/generate.go +++ b/pkg/doctor/generate.go @@ -670,8 +670,8 @@ func buildCR(name string, info *orktypes.ProjectInfo, opts GenerateOptions) stri } // SaveMotif writes the motif template for appName to -// ~/.orkestra/apps//motif.yaml — outside the project directory -// so it is never visible to the developer. +// ~/.orkestra/doctor/init/apps//motif.yaml — outside the project +// directory so it is never visible to the developer. func SaveMotif(appName, content string) error { motifPath, err := MotifPath(appName) if err != nil { @@ -1060,8 +1060,7 @@ runtime: // - Uses simple file append (no markers, no temporary files). // - Works reliably on all platforms, including Windows. // -// Required entries: -// - .orkestra/bundle/ +// Required entries: none currently (bundle lives in ~/.orkestra, not the project tree). func updateGitignore(dir string) error { giPath := filepath.Join(dir, ".gitignore") @@ -1108,8 +1107,7 @@ func updateGitignore(dir string) error { // - Uses simple file append (no markers, no temporary files). // - Works reliably on all platforms, including Windows. // -// Required entries: -// - .orkestra/bundle/ +// Required entries: none currently (bundle lives in ~/.orkestra, not the project tree). func updateDockerignore(dir string) error { giPath := filepath.Join(dir, ".dockerignore") diff --git a/pkg/doctor/state.go b/pkg/doctor/state.go index f999af57..a0bd204a 100644 --- a/pkg/doctor/state.go +++ b/pkg/doctor/state.go @@ -145,24 +145,44 @@ func (s *DeployState) DeployedAppNames() []string { return names } -// MotifDir returns ~/.orkestra/apps/ — where per-app motif templates are stored. -// Motifs live here rather than in the project directory so developers never see them. -func MotifDir() (string, error) { +// InitAppsDir returns ~/.orkestra/doctor/init/apps/ — where per-app motif +// templates and bundles are stored. Lives outside the project tree so +// developers never see it. +func InitAppsDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".orkestra", "apps"), nil + return filepath.Join(home, ".orkestra", "doctor", "init", "apps"), nil +} + +// MotifDir returns the per-app motif root: ~/.orkestra/doctor/init/apps// +func MotifDir(appName string) (string, error) { + base, err := InitAppsDir() + if err != nil { + return "", err + } + return filepath.Join(base, appName), nil } // MotifPath returns the path to the motif template for a given app name. -// ~/.orkestra/apps//motif.yaml +// ~/.orkestra/doctor/init/apps//motif.yaml func MotifPath(appName string) (string, error) { - base, err := MotifDir() + dir, err := MotifDir(appName) + if err != nil { + return "", err + } + return filepath.Join(dir, "motif.yaml"), nil +} + +// AppBundleDir returns ~/.orkestra/doctor/init/apps//bundle/ — +// where generated secrets and configmaps for the app are stored. +func AppBundleDir(appName string) (string, error) { + dir, err := MotifDir(appName) if err != nil { return "", err } - return filepath.Join(base, appName, "motif.yaml"), nil + return filepath.Join(dir, "bundle"), nil } // CurrentContext returns the active kubectl context name. diff --git a/pkg/katalog/motif_imports.go b/pkg/katalog/motif_imports.go index c7bf6ea8..4b7275c3 100644 --- a/pkg/katalog/motif_imports.go +++ b/pkg/katalog/motif_imports.go @@ -62,12 +62,19 @@ func (k *Katalog) loadAndExpandImport(imp *orktypes.MotifImport) (*motif.Expande // expanded motif into the target CRD entry. It respects the existing fields // (appending rules, preserving order, and merging condition flags sensibly). func (k *Katalog) mergeExpandedMotif(entry *orktypes.CRDEntry, expanded *motif.ExpandedMotif) error { - // Merge resources into operatorBox.OnReconcile - if expanded.HasResources() { + // resources.onCreate: → CRD onCreate (update=false, preserves once: true guard) + if expanded.OnCreate != nil { + if !entry.HasOnCreate() { + entry.OperatorBox.OnCreate = &orktypes.HookTemplates{} + } + motif.MergeHookTemplates(entry.OperatorBox.OnCreate, expanded.OnCreate) + } + // resources flat fields → CRD onReconcile (drift correction) + if expanded.OnReconcile != nil { if !entry.HasOnReconcile() { entry.OperatorBox.OnReconcile = &orktypes.HookTemplates{} } - motif.MergeHookTemplates(entry.OperatorBox.OnReconcile, expanded.Resources) + motif.MergeHookTemplates(entry.OperatorBox.OnReconcile, expanded.OnReconcile) } // Merge status fields diff --git a/pkg/merger/registry.go b/pkg/merger/registry.go index 568e3c5c..81098c73 100644 --- a/pkg/merger/registry.go +++ b/pkg/merger/registry.go @@ -170,7 +170,7 @@ func (m *Merger) pullOCIPattern(url, version, tmpDir string, auth *utils.FileAut return fmt.Errorf("OCI pull %q: %w", ref, err) } - logger.Info(). + logger.Debug(). Str("ref", ref). Str("dst", tmpDir). Msg("registry: OCI artifact pulled successfully") diff --git a/pkg/motif/expander.go b/pkg/motif/expander.go index 70bbf541..85a5f47e 100644 --- a/pkg/motif/expander.go +++ b/pkg/motif/expander.go @@ -27,14 +27,17 @@ import ( // ExpandedMotif holds the result of expanding a motif. type ExpandedMotif struct { - Resources *orktypes.HookTemplates - Status *orktypes.StatusConfig - Admission *orktypes.Admission + // OnCreate contains resources from resources.onCreate: — merged into the CRD's OnCreate phase. + OnCreate *orktypes.HookTemplates + // OnReconcile contains resources from the flat resources: fields — merged into OnReconcile. + OnReconcile *orktypes.HookTemplates + Status *orktypes.StatusConfig + Admission *orktypes.Admission } -// HasResources returns true when the motif produced resource templates. +// HasResources returns true when the motif produced any resource templates. func (e *ExpandedMotif) HasResources() bool { - return e.Resources != nil + return e.OnCreate != nil || e.OnReconcile != nil } // HasStatus reports whether the motif defines status fields or conditions. @@ -66,7 +69,7 @@ func Expand(m *orktypes.Motif, bindings map[string]string) (*ExpandedMotif, erro resolved := resolveDefaults(m, bindings) // ---- Expand resources ---- - var resources *orktypes.HookTemplates + var onCreate, onReconcile *orktypes.HookTemplates if m.Resources != nil { resourceYAML, err := yaml.Marshal(m.Resources) if err != nil { @@ -76,11 +79,17 @@ func Expand(m *orktypes.Motif, bindings map[string]string) (*ExpandedMotif, erro if err != nil { return nil, fmt.Errorf("rendering motif %q resources: %w", m.Metadata.Name, err) } - var hookTemplates orktypes.HookTemplates - if err := yaml.Unmarshal([]byte(rendered), &hookTemplates); err != nil { + var mr orktypes.MotifResources + if err := yaml.Unmarshal([]byte(rendered), &mr); err != nil { return nil, fmt.Errorf("parsing expanded motif %q resources: %w", m.Metadata.Name, err) } - resources = &hookTemplates + if mr.OnCreate != nil { + onCreate = mr.OnCreate + } + inline := mr.HookTemplates + if !inline.IsEmpty() { + onReconcile = &inline + } } // ---- Expand status ---- @@ -120,9 +129,10 @@ func Expand(m *orktypes.Motif, bindings map[string]string) (*ExpandedMotif, erro } return &ExpandedMotif{ - Resources: resources, - Status: status, - Admission: admission, + OnCreate: onCreate, + OnReconcile: onReconcile, + Status: status, + Admission: admission, }, nil } diff --git a/pkg/motif/expander_test.go b/pkg/motif/expander_test.go index 76fabe84..c46f896d 100644 --- a/pkg/motif/expander_test.go +++ b/pkg/motif/expander_test.go @@ -25,10 +25,11 @@ func TestExpand_RequiredInputProvided(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if expanded.Resources != nil { - if len(expanded.Resources.Deployments) != 1 { - t.Fatalf("deployments len = %d, want 1", len(expanded.Resources.Deployments)) - } + if expanded.OnReconcile == nil { + t.Fatal("expected OnReconcile to be non-nil") + } + if len(expanded.OnReconcile.Deployments) != 1 { + t.Fatalf("deployments len = %d, want 1", len(expanded.OnReconcile.Deployments)) } } @@ -42,10 +43,11 @@ func TestExpand_DefaultFilled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if expanded.Resources != nil { - if len(expanded.Resources.Deployments) != 1 { - t.Fatalf("deployments len = %d, want 1", len(expanded.Resources.Deployments)) - } + if expanded.OnReconcile == nil { + t.Fatal("expected OnReconcile to be non-nil") + } + if len(expanded.OnReconcile.Deployments) != 1 { + t.Fatalf("deployments len = %d, want 1", len(expanded.OnReconcile.Deployments)) } } diff --git a/pkg/motif/loader.go b/pkg/motif/loader.go index 03864799..4f492d95 100644 --- a/pkg/motif/loader.go +++ b/pkg/motif/loader.go @@ -1,11 +1,19 @@ // pkg/motif/loader.go // // Loads a Motif from a file path or registry reference. -// Resolution follows the same semantics as RegistrySource in a Komposer — -// if you know how to pull a pattern, you already know how to pull a Motif. // -// The Orkestra registry houses both patterns and motifs. Each Motif is a -// standalone OCI artifact or Git repo with motif.yaml at its root. +// Resolution mirrors RegistrySource in a Komposer exactly — the same four +// reference forms are supported: +// +// motif: postgres # bare name → default motif registry (OCI) +// motif: oci://ghcr.io/orkspace/orkestra-motifs/postgres:v0.1.0 # oci:// prefix → OCI +// motif: ghcr.io/orkspace/orkestra-motifs/postgres@v0.1.0 # full OCI ref with oci: true +// motif: https://github.com/myorg/postgres-motif@main # git URL +// motif: ./motifs/postgres/motif.yaml # file path +// +// Bare names and oci:// prefixes are auto-detected so oci: true is not required +// for those forms. For full OCI refs (host with dots), oci: true is still required +// — same as RegistrySource. package motif import ( @@ -16,6 +24,7 @@ import ( "github.com/orkspace/orkestra/pkg/konfig" "github.com/orkspace/orkestra/pkg/merger" + "github.com/orkspace/orkestra/pkg/registry" orktypes "github.com/orkspace/orkestra/pkg/types" "github.com/orkspace/orkestra/pkg/utils" ) @@ -31,52 +40,57 @@ func Load(path string) (*orktypes.Motif, error) { } // LoadImport resolves and loads a Motif from a MotifImport declaration. -// Supports file paths, OCI artifacts, and Git registries — the same -// resolution semantics as RegistrySource in a Komposer. -// -// File path (developer path): -// -// imp.Motif = "./postgres/motif.yaml" -// -// OCI artifact — motif.yaml at artifact root: -// -// imp.Motif = "ghcr.io/orkspace/orkestra-registry/postgres@v16" -// imp.OCI = true // -// Git registry — motif.yaml at repo root (standalone Motif repo): -// -// imp.Motif = "https://github.com/myorg/postgres-motif@main" -// -// Pattern with bundled Motif (pattern includes both katalog.yaml and motif.yaml): -// -// imp.Motif = "ghcr.io/orkspace/orkestra-registry/postgres@v16" -// imp.OCI = true +// Resolution order: +// 1. File path (starts with ./, ../, /, or ends with .yaml/.yml) +// 2. oci:// prefix → OCI pull (auto-detected, oci: field not required) +// 3. Bare name (no scheme, no dots in registry host) → resolved against the +// default motif registry (ORKESTRA_MOTIFS_REGISTRY or ghcr.io/orkspace/orkestra-motifs) +// 4. Full OCI ref + oci: true → OCI pull (komposer-compatible form) +// 5. Git URL (https://, http://, git@) → git pull func LoadImport(imp *orktypes.MotifImport) (*orktypes.Motif, error) { - ref := imp.Motif + ref := strings.TrimSpace(imp.Motif) // File path — relative, absolute, or ends with .yaml/.yml if isFilePath(ref) { return Load(ref) } - // Registry pull — parse url@version shorthand (mirrors RegistrySource.ResolvedURL) - cleanURL, version := resolveRef(ref, imp.Version, imp.OCI) + oci := imp.OCI + + // oci:// prefix → always OCI, strip prefix before further parsing. + if strings.HasPrefix(ref, "oci://") { + oci = true + ref = strings.TrimPrefix(ref, "oci://") + } + + // Bare name — no scheme, no dots in the host segment → resolve against + // the default motif registry and pull via OCI. + // e.g. "postgres" or "postgres:v0.1.0" + if !oci && !isGitURL(ref) && !looksLikeFullRef(ref) { + resolved, err := registry.ResolveForKind(ref, registry.MotifKind) + if err != nil { + return nil, fmt.Errorf("motif %q: resolving reference: %w", imp.Motif, err) + } + ref = resolved.Full // e.g. "ghcr.io/orkspace/orkestra-motifs/postgres:v0.1.0" + oci = true + } + + // Parse cleanURL and version. + // Supports both OCI's :tag syntax and the @version shorthand used by RegistrySource. + cleanURL, version := resolveMotifRef(ref, imp.Version, oci) - // Resolve auth credentials from environment variables (same as merger auth) auth, err := imp.Auth.Resolve() if err != nil { - return nil, fmt.Errorf("motif %q: auth: %w", ref, err) + return nil, fmt.Errorf("motif %q: auth: %w", imp.Motif, err) } - // Pull to temp directory — dedicated Motif pull fetches only motif.yaml for Git - // repos; OCI pull fetches the full artifact (motif.yaml must be at the root) - tmpDir, cleanup, err := merger.PullMotifToDir(cleanURL, version, imp.OCI, auth) + tmpDir, cleanup, err := merger.PullMotifToDir(cleanURL, version, oci, auth) if err != nil { return nil, fmt.Errorf("motif %q@%s: pull failed: %w", cleanURL, version, err) } defer cleanup() - // Motif artifacts contain motif.yaml at the root data, err := os.ReadFile(filepath.Join(tmpDir, "motif.yaml")) if err != nil { return nil, fmt.Errorf("motif %q@%s: motif.yaml not found in artifact: %w", cleanURL, version, err) @@ -93,12 +107,47 @@ func isFilePath(ref string) bool { return strings.HasSuffix(ref, ".yaml") || strings.HasSuffix(ref, ".yml") } -// resolveRef parses a Motif ref into (cleanURL, version). -// Mirrors RegistrySource.ResolvedURL exactly. -func resolveRef(ref, version string, oci bool) (cleanURL, resolvedVersion string) { +// isGitURL reports whether ref is a Git remote URL. +func isGitURL(ref string) bool { + return strings.HasPrefix(ref, "https://") || + strings.HasPrefix(ref, "http://") || + strings.HasPrefix(ref, "git@") +} + +// looksLikeFullRef reports whether ref already contains a registry hostname +// (has a dot or colon before the first slash, or is localhost). +// Mirrors the logic in registry.looksLikeFull. +func looksLikeFullRef(ref string) bool { + slashIdx := strings.Index(ref, "/") + if slashIdx < 0 { + return false + } + host := ref[:slashIdx] + return strings.Contains(host, ".") || strings.Contains(host, ":") || host == "localhost" +} + +// resolveMotifRef returns the (cleanURL, version) pair ready for PullMotifToDir. +// +// Precedence: +// 1. @version shorthand in ref — "ghcr.io/.../postgres@v14" +// 2. :tag at the end of an OCI ref — "ghcr.io/.../postgres:v14" +// 3. Explicit imp.Version field +// 4. Default: "latest" for OCI, "main" for Git +func resolveMotifRef(ref, version string, oci bool) (cleanURL, resolvedVersion string) { + // @ shorthand (komposer style) — takes precedence over everything. if idx := strings.LastIndex(ref, "@"); idx != -1 { return ref[:idx], ref[idx+1:] } + + // For OCI refs, a colon after the last slash is the image tag. + // e.g. "ghcr.io/orkspace/orkestra-motifs/postgres:v0.1.0" + if oci { + if colonIdx := strings.LastIndex(ref, ":"); colonIdx > strings.LastIndex(ref, "/") { + return ref[:colonIdx], ref[colonIdx+1:] + } + } + + // Explicit version field or defaults. cleanURL = ref if version != "" { return cleanURL, version diff --git a/pkg/motif/testdata/valid.yaml b/pkg/motif/testdata/valid.yaml index 2b969619..1baf4c68 100644 --- a/pkg/motif/testdata/valid.yaml +++ b/pkg/motif/testdata/valid.yaml @@ -19,5 +19,8 @@ resources: - name: "{{ .metadata.name }}-db" image: "{{ index .inputs \"image\" }}" replicas: "1" - storageSize: "{{ index .inputs \"volumeSize\" }}" - mountPath: /data + volumeClaimTemplates: + - name: data + storageSize: "{{ index .inputs \"volumeSize\" }}" + storageClass: standard + mountPath: /data diff --git a/pkg/registry/README.md b/pkg/registry/README.md index 9de2bbb3..1ef41e54 100644 --- a/pkg/registry/README.md +++ b/pkg/registry/README.md @@ -1,66 +1,115 @@ # pkg/registry -The registry package implements the Orkestra pattern registry — an OCI-based store for operator patterns that can be pushed, pulled, and listed by the `ork registry` CLI commands. +The registry package implements the Orkestra artifact registry — an OCI-based store for **patterns** (Katalog-based operators) and **motifs** (reusable resource primitives) that can be pushed, pulled, and listed by the `ork registry` CLI commands. -A **pattern** is a directory containing a `katalog.yaml` and `crd.yaml` plus optional documentation and example files. The registry pushes each file as a named OCI layer, tags the manifest, and automatically maintains an index artifact so `ork registry list` works without any server-side catalog support. +Artifact kind is detected automatically from the primary YAML file (`kind: Katalog` → pattern, `kind: Motif` → motif). No separate code path per kind is needed — adding a new artifact kind is a one-line entry in `artifactSpecs` in `artifact.go`. ## What this package provides | What | Where in code | |------|--------------| +| Generic artifact kind detection and validation | `artifact.go` — `DetectKind`, `ValidateArtifactDirectory`, `LoadArtifactMeta` | | Push/pull/info/list over OCI | `client.go` — `Client` | -| Reference resolution (bare name → full OCI ref) | `resolve.go` — `Resolve`, `Ref` | -| Pattern directory validation and metadata derivation | `pattern.go` — `ValidateDirectory`, `PatternMeta` | +| Reference resolution (bare name → full OCI ref) | `resolve.go` — `Resolve`, `ResolveForKind`, `Ref` | +| Pattern directory validation (legacy) | `pattern.go` — `PatternMeta`, `PatternIndex` | | Index management (auto-updated on push) | `client.go` — `updateIndex`, `fetchIndex`, `pushIndex` | | Local cache at `~/.orkestra/registry/` | `resolve.go` — `CachePath`, `IsCached` | +| File and media-type constants | `constant.go` | -## Artifact format +## Artifact kinds -Each pattern is an OCI artifact with media type `application/vnd.orkestra.pattern.v1+tar+gzip`. Files are pushed as individually-typed layers: +### Pattern (kind: Katalog) + +A Katalog-based operator pattern. Required files: `katalog.yaml`, `crd.yaml`. + +``` +my-operator/ + katalog.yaml application/vnd.orkestra.katalog.v1+yaml (required) + crd.yaml application/vnd.kubernetes.crd.v1+yaml (required) + README.md text/markdown (optional) + cr.yaml application/vnd.kubernetes.cr.v1+yaml (optional) +``` + +Manifest media type: `application/vnd.orkestra.pattern.v1+tar+gzip` +Default registry: `ghcr.io/orkspace/orkestra-registry/patterns` +Override: `ORKESTRA_REGISTRY` + +### Motif (kind: Motif) + +A reusable resource primitive (stateful service, shared infrastructure). Required file: `motif.yaml`. ``` -katalog.yaml application/vnd.orkestra.katalog.v1+yaml (required) -crd.yaml application/vnd.kubernetes.crd.v1+yaml (required) -README.md text/markdown (optional) -cr.yaml application/vnd.kubernetes.cr.v1+yaml (optional) -pattern.yaml application/vnd.orkestra.pattern-meta.v1+yaml (optional) +my-motif/ + motif.yaml application/vnd.orkestra.motif.v1+yaml (required) + README.md text/markdown (optional) + example/ (directory) (optional) ``` -Pattern metadata (name, version, description, tags, author) is stored as OCI manifest annotations and also derived from `katalog.yaml` at push time. +Manifest media type: `application/vnd.orkestra.motif.v1+tar+gzip` +Default registry: `ghcr.io/orkspace/orkestra-motifs` +Override: `ORKESTRA_MOTIFS_REGISTRY` ## Push flow ``` -ValidateDirectory(dir) — check required files, derive PatternMeta -merger.New(katalog.yaml).Merge() — parse and validate katalog semantics [CLI] -katalog.ValidateConfig(kfg) — full field/GVK/dependency validation [CLI] -validateCRDFile(crd.yaml) — structural CRD YAML check [CLI] +ValidateArtifactDirectory(dir) — detect kind, check required files, list all files +LoadArtifactMeta(dir, spec) — read name/version/description from primary YAML ↓ client.Push(ctx, ref, dir) - → read each file into memory store (nothing written to dir) - → oras.Pack — manifest with annotations + → read each file into memory store (nothing written back to dir) + → oras.Pack — manifest with kind-specific media type and artifact annotations → store.Tag — register manifest under ref.Tag → oras.Copy — push all blobs + manifest to remote - → updateIndex — upsert pattern into index:latest (best-effort) + → updateIndex — upsert entry into index:latest (best-effort) ``` +Additional validation layers run in the CLI before `client.Push` is called (see [docs/04-patterns.md](docs/04-patterns.md)). + +## Annotations + +Every manifest carries standard OCI annotations derived from the artifact's primary YAML: + +| Annotation | Source | +|-----------|--------| +| `org.opencontainers.image.title` | `ArtifactMeta.Name` | +| `org.opencontainers.image.version` | `ArtifactMeta.Version` | +| `org.opencontainers.image.description` | `ArtifactMeta.Description` | +| `org.opencontainers.image.authors` | `ArtifactMeta.Author` | +| `org.opencontainers.image.created` | time of push | +| `io.orkestra.artifact.kind` | `ArtifactMeta.Kind` (`Katalog` or `Motif`) | +| `io.orkestra.artifact.name` | `ArtifactMeta.Name` | +| `io.orkestra.artifact.version` | `ArtifactMeta.Version` | +| `io.orkestra.artifact.author` | `ArtifactMeta.Author` | +| `io.orkestra.artifact.license` | `ArtifactMeta.License` | +| `io.orkestra.artifact.tags` | comma-separated `ArtifactMeta.Tags` | + +`Info` reads these annotations to reconstruct metadata without downloading any files. The legacy `io.orkestra.pattern.*` keys are still read for backward compatibility with artifacts pushed before the generic artifact layer. + ## Index artifact -`ork registry list` reads a single `index:latest` artifact from the registry namespace root (`ghcr.io/orkspace/orkestra-registry/patterns/index:latest`). This artifact is a JSON blob containing a `PatternIndex`. +`ork registry list` reads a single `index:latest` artifact from the registry namespace root. Each registry has its own index: -`Push` automatically updates the index after every successful push: it fetches the existing index, upserts the new pattern entry, and pushes the updated index back. There is no separate reindex step. If the index push fails it is logged as a warning — the pattern push is not rolled back. +- Patterns: `ghcr.io/orkspace/orkestra-registry/patterns/index:latest` +- Motifs: `ghcr.io/orkspace/orkestra-motifs/index:latest` -## Reference resolution +`Push` automatically updates the index after every successful push. If the index push fails it is logged as a warning — the artifact push is not rolled back. -Bare references are resolved in this order: +## Reference resolution -1. Full OCI reference (`oci://host/repo:tag`) — used as-is after stripping `oci://` -2. `ORKESTRA_REGISTRY` environment variable + `/name:tag` -3. Default: `ghcr.io/orkspace/orkestra-registry/patterns/name:tag` +`Resolve` routes bare names to the pattern registry (for backward compatibility). `ResolveForKind` picks the correct registry based on detected kind: ```go -ref, err := registry.Resolve("postgres:v14") -// → ghcr.io/orkspace/orkestra-registry/patterns/postgres:v14 +// Pattern +ref, err := registry.ResolveForKind("postgres:v1", registry.KatalogKind) +// → ghcr.io/orkspace/orkestra-registry/patterns/postgres:v1 + +// Motif +ref, err := registry.ResolveForKind("redis:v7", registry.MotifKind) +// → ghcr.io/orkspace/orkestra-motifs/redis:v7 + +// Full OCI ref — used as-is regardless of kind +ref, err := registry.ResolveForKind("oci://ghcr.io/myorg/motifs/redis:v7", registry.MotifKind) +// → ghcr.io/myorg/motifs/redis:v7 ``` ## Authentication @@ -74,4 +123,4 @@ Reads `~/.docker/config.json` via `credentials.NewStoreFromDocker`. Run `docker | Understand push and pull end-to-end | [docs/01-push-pull.md](docs/01-push-pull.md) | | Understand the index and how list works | [docs/02-index.md](docs/02-index.md) | | Understand reference resolution and the cache | [docs/03-resolve-cache.md](docs/03-resolve-cache.md) | -| Add a new pattern or extend the artifact format | [docs/04-patterns.md](docs/04-patterns.md) | +| Publish a pattern or motif / extend the artifact format | [docs/04-artifacts.md](docs/04-artifacts.md) | diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 435914c0..ee65c670 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -1,6 +1,6 @@ // pkg/registry/client.go // -// ORAS-based OCI client for pushing and pulling Orkestra patterns. +// ORAS-based OCI client for pushing and pulling Orkestra artifacts. // // Authentication uses ~/.docker/config.json via oras.land/oras-go/v2. // No separate login step — docker login ghcr.io is sufficient. @@ -8,7 +8,7 @@ // Push: validates the directory, reads each file, pushes as OCI layers. // Pull: fetches the manifest, extracts layers to the cache directory. // Info: fetches the manifest only, reads annotations. -// List: fetches the index artifact from the registry root. +// List: fetches the index pattern from the registry root. package registry import ( @@ -34,7 +34,6 @@ import ( // Client wraps ORAS for Orkestra pattern operations. type Client struct { - // credStore loads credentials from ~/.docker/config.json. credStore credentials.Store } @@ -49,20 +48,21 @@ func NewClient() (*Client, error) { return &Client{credStore: store}, nil } -// Push validates the directory and pushes all pattern files to the registry. -// Returns the manifest digest on success. +// Push validates the directory, auto-detects the pattern kind, and pushes all +// files to the registry. Returns the manifest digest on success. func (c *Client) Push(ctx context.Context, ref *Ref, dir string, progress func(file string, size int64)) (string, error) { - // Validate first — fail before any network call - meta, files, err := ValidateDirectory(dir) + patternKind, spec, files, err := ValidatePatternDirectory(dir) if err != nil { return "", fmt.Errorf("validation failed: %w", err) } - _ = meta // used for annotations below - // Use an in-memory store so nothing is written back to dir. + meta, err := LoadPatternMeta(dir, spec) + if err != nil { + return "", fmt.Errorf("reading metadata: %w", err) + } + store := memory.New() - // Read each file and push it into the memory store as a blob. var descs []ocispec.Descriptor for _, f := range files { path := filepath.Join(dir, f) @@ -73,7 +73,7 @@ func (c *Client) Push(ctx context.Context, ref *Ref, dir string, progress func(f if progress != nil { progress(f, int64(len(data))) } - desc := content.NewDescriptorFromBytes(mediaTypeForFile(f), data) + desc := content.NewDescriptorFromBytes(mediaTypeForPatternFile(f, patternKind), data) desc.Annotations = map[string]string{ "org.opencontainers.image.title": f, } @@ -83,9 +83,8 @@ func (c *Client) Push(ctx context.Context, ref *Ref, dir string, progress func(f descs = append(descs, desc) } - // Pack into a manifest with annotations from pattern.yaml - annotations := metaToAnnotations(meta, ref) - manifestDesc, err := oras.Pack(ctx, store, MediaType, descs, oras.PackOptions{ + annotations := artifactMetaToAnnotations(meta, ref) + manifestDesc, err := oras.Pack(ctx, store, spec.MediaType, descs, oras.PackOptions{ PackImageManifest: true, ManifestAnnotations: annotations, }) @@ -93,12 +92,10 @@ func (c *Client) Push(ctx context.Context, ref *Ref, dir string, progress func(f return "", fmt.Errorf("packing manifest: %w", err) } - // Tag the manifest in the local store so oras.Copy can resolve it by name. if err := store.Tag(ctx, manifestDesc, ref.Tag); err != nil { return "", fmt.Errorf("tagging manifest: %w", err) } - // Push to the remote repository repo, err := c.remoteRepo(ref) if err != nil { return "", err @@ -108,23 +105,22 @@ func (c *Client) Push(ctx context.Context, ref *Ref, dir string, progress func(f return "", fmt.Errorf("pushing: %w", err) } - // Update the shared index so ork registry list reflects the new pattern. entry := PatternEntry{ Name: meta.Name, LatestVersion: meta.Version, Description: meta.Description, Tags: meta.Tags, Author: meta.Author, + Kind: string(patternKind), } if err := c.updateIndex(ctx, ref, entry); err != nil { - // Non-fatal: push succeeded; index update failure is best-effort. fmt.Fprintf(os.Stderr, "warning: index update failed: %v\n", err) } return manifestDesc.Digest.String(), nil } -// Pull fetches a pattern from the registry into the local cache. +// Pull fetches an pattern from the registry into the local cache. // Returns the cache directory path. func (c *Client) Pull(ctx context.Context, ref *Ref, refresh bool) (string, error) { cacheDir, err := ref.CachePath() @@ -132,7 +128,6 @@ func (c *Client) Pull(ctx context.Context, ref *Ref, refresh bool) (string, erro return "", err } - // Serve from cache unless refresh is requested if !refresh && ref.IsCached() { return cacheDir, nil } @@ -141,7 +136,6 @@ func (c *Client) Pull(ctx context.Context, ref *Ref, refresh bool) (string, erro return "", fmt.Errorf("creating cache dir: %w", err) } - // Pull into the cache directory store, err := file.New(cacheDir) if err != nil { return "", fmt.Errorf("creating file store: %w", err) @@ -154,7 +148,6 @@ func (c *Client) Pull(ctx context.Context, ref *Ref, refresh bool) (string, erro } if _, err := oras.Copy(ctx, repo, ref.Tag, store, ref.Tag, oras.DefaultCopyOptions); err != nil { - // Clean up partial cache on failure os.RemoveAll(cacheDir) return "", fmt.Errorf("pulling: %w", err) } @@ -162,15 +155,6 @@ func (c *Client) Pull(ctx context.Context, ref *Ref, refresh bool) (string, erro return cacheDir, nil } -// PatternInfo holds the metadata returned by Info. -type PatternInfo struct { - Ref *Ref - Digest string - Size int64 - PushedAt time.Time - Meta *PatternMeta -} - // Info fetches manifest metadata without downloading the pattern files. func (c *Client) Info(ctx context.Context, ref *Ref) (*PatternInfo, error) { repo, err := c.remoteRepo(ref) @@ -183,7 +167,6 @@ func (c *Client) Info(ctx context.Context, ref *Ref) (*PatternInfo, error) { return nil, fmt.Errorf("fetching manifest: %w", err) } - // Read annotations from the manifest rc, err := repo.Fetch(ctx, desc) if err != nil { return nil, fmt.Errorf("reading manifest: %w", err) @@ -215,11 +198,11 @@ func (c *Client) Info(ctx context.Context, ref *Ref) (*PatternInfo, error) { return info, nil } -// List fetches the pattern index from the registry index artifact. -// Returns an empty index (not an error) when no patterns have been pushed yet. +// List fetches the pattern index from the given registry URL. +// Returns an empty index (not an error) when no artifacts have been pushed yet. func (c *Client) List(ctx context.Context, registryURL string) (*PatternIndex, error) { if registryURL == "" { - registryURL = DefaultRegistry + registryURL = DefaultPatternRegistry } clean := strings.TrimSuffix(strings.TrimPrefix(registryURL, "oci://"), "/") idxRef, err := parseRef(clean + "/index:latest") @@ -228,7 +211,6 @@ func (c *Client) List(ctx context.Context, registryURL string) (*PatternIndex, e } index, err := c.fetchIndex(ctx, idxRef) if err != nil { - // Index doesn't exist yet — return empty, not an error. return &PatternIndex{UpdatedAt: time.Now().UTC().Format(time.RFC3339)}, nil } return index, nil @@ -236,11 +218,6 @@ func (c *Client) List(ctx context.Context, registryURL string) (*PatternIndex, e // ── Index management ────────────────────────────────────────────────────────── -const indexMediaType = "application/vnd.orkestra.index.v1+json" - -// indexRef derives the index repository ref from a pattern ref. -// "ghcr.io/orkspace/orkestra-registry/patterns/website:v1" -// → "ghcr.io/orkspace/orkestra-registry/patterns/index:latest" func indexRefFrom(ref *Ref) (*Ref, error) { lastSlash := strings.LastIndex(ref.Repository, "/") if lastSlash < 0 { @@ -250,14 +227,12 @@ func indexRefFrom(ref *Ref) (*Ref, error) { return parseRef(ref.Registry + "/" + namespace + "/index:latest") } -// fetchIndex pulls the index JSON from the registry. func (c *Client) fetchIndex(ctx context.Context, idxRef *Ref) (*PatternIndex, error) { repo, err := c.remoteRepo(idxRef) if err != nil { return nil, err } - // Pull to a temp directory. tmp, err := os.MkdirTemp("", "ork-index-*") if err != nil { return nil, err @@ -286,7 +261,6 @@ func (c *Client) fetchIndex(ctx context.Context, idxRef *Ref) (*PatternIndex, er return &index, nil } -// pushIndex writes the index JSON to the registry as an OCI artifact. func (c *Client) pushIndex(ctx context.Context, idxRef *Ref, index *PatternIndex) error { data, err := json.Marshal(index) if err != nil { @@ -300,8 +274,6 @@ func (c *Client) pushIndex(ctx context.Context, idxRef *Ref, index *PatternIndex return fmt.Errorf("staging index blob: %w", err) } - // Also stage as a named file so file-based pulls extract it as index.json. - // We encode the descriptor's filename in the title annotation. blob.Annotations = map[string]string{ "org.opencontainers.image.title": "index.json", } @@ -328,7 +300,6 @@ func (c *Client) pushIndex(ctx context.Context, idxRef *Ref, index *PatternIndex return nil } -// updateIndex fetches the existing index (if any), upserts the entry, and pushes. func (c *Client) updateIndex(ctx context.Context, ref *Ref, entry PatternEntry) error { idxRef, err := indexRefFrom(ref) if err != nil { @@ -337,19 +308,19 @@ func (c *Client) updateIndex(ctx context.Context, ref *Ref, entry PatternEntry) index, err := c.fetchIndex(ctx, idxRef) if err != nil { - index = &PatternIndex{} // first push — start fresh + index = &PatternIndex{} } found := false - for i, p := range index.Patterns { - if p.Name == entry.Name { - index.Patterns[i] = entry + for i, e := range index.Entries { + if e.Name == entry.Name { + index.Entries[i] = entry found = true break } } if !found { - index.Patterns = append(index.Patterns, entry) + index.Entries = append(index.Entries, entry) } index.UpdatedAt = time.Now().UTC().Format(time.RFC3339) @@ -370,29 +341,14 @@ func (c *Client) remoteRepo(ref *Ref) (*remote.Repository, error) { return repo, nil } -// mediaTypeForFile returns the OCI media type for a pattern file. -func mediaTypeForFile(name string) string { - switch name { - case FileKatalog: - return "application/vnd.orkestra.katalog.v1+yaml" - case FileCRD: - return "application/vnd.kubernetes.crd.v1+yaml" - case FileCR: - return "application/vnd.kubernetes.cr.v1+yaml" - case FileReadme: - return "text/markdown" - default: - return "application/octet-stream" - } -} - -// metaToAnnotations converts PatternMeta to OCI manifest annotations. -func metaToAnnotations(meta *PatternMeta, ref *Ref) map[string]string { +// artifactMetaToAnnotations converts PatternMeta to OCI manifest annotations. +func artifactMetaToAnnotations(meta *PatternMeta, ref *Ref) map[string]string { ann := map[string]string{ "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), "org.opencontainers.image.title": meta.Name, "org.opencontainers.image.version": meta.Version, "org.opencontainers.image.description": meta.Description, + "io.orkestra.pattern.kind": string(meta.Kind), "io.orkestra.pattern.name": meta.Name, "io.orkestra.pattern.version": meta.Version, "io.orkestra.pattern.author": meta.Author, @@ -405,18 +361,39 @@ func metaToAnnotations(meta *PatternMeta, ref *Ref) map[string]string { return ann } -// annotationsToMeta reconstructs PatternMeta from OCI annotations. +// annotationsToMeta reconstructs PatternMeta from OCI manifest annotations. +// Reads both current "io.orkestra.pattern.*" and legacy "io.orkestra.pattern.*" keys. func annotationsToMeta(ann map[string]string) *PatternMeta { tags := []string{} + name := ann["io.orkestra.pattern.name"] + if name == "" { + name = ann["io.orkestra.pattern.name"] + } + version := ann["io.orkestra.pattern.version"] + if version == "" { + version = ann["io.orkestra.pattern.version"] + } + author := ann["io.orkestra.pattern.author"] + if author == "" { + author = ann["io.orkestra.pattern.author"] + } + license := ann["io.orkestra.pattern.license"] + if license == "" { + license = ann["io.orkestra.pattern.license"] + } + kindStr := ann["io.orkestra.pattern.kind"] if t := ann["io.orkestra.pattern.tags"]; t != "" { tags = strings.Split(t, ",") + } else if t := ann["io.orkestra.pattern.tags"]; t != "" { + tags = strings.Split(t, ",") } return &PatternMeta{ - Name: ann["io.orkestra.pattern.name"], - Version: ann["io.orkestra.pattern.version"], + Kind: PatternKind(kindStr), + Name: name, + Version: version, Description: ann["org.opencontainers.image.description"], - Author: ann["io.orkestra.pattern.author"], - License: ann["io.orkestra.pattern.license"], + Author: author, + License: license, Tags: tags, } } diff --git a/pkg/registry/constant.go b/pkg/registry/constant.go new file mode 100644 index 00000000..3ec0a97d --- /dev/null +++ b/pkg/registry/constant.go @@ -0,0 +1,53 @@ +package registry + +const ( + KatalogKind PatternKind = "Katalog" // Katalog-based operator pattern + MotifKind PatternKind = "Motif" // Reusable resource primitive + UnknownKind PatternKind = "" + + // MediaType is the OCI pattern media type for Orkestra patterns. + MediaType = "application/vnd.orkestra.pattern.v1+tar+gzip" + + // indexMediaType is the OCI media type for the shared registry index. + indexMediaType = "application/vnd.orkestra.index.v1+json" + + // FileKatalog is the required operator declaration file. + FileKatalog = "katalog.yaml" + + // FileMotif is the required motif declaration file. + FileMotif = "motif.yaml" + + // FileCRD is the required CRD schema file. + FileCRD = "crd.yaml" + + // FileReadme is the human documentation file. + FileReadme = "README.md" + + // FileCR is the example CR file. + FileCR = "cr.yaml" + + // DefaultKatalogRegistry is the official OCI path for Katalog patterns. + DefaultKatalogRegistry = "ghcr.io/orkspace/orkestra-registry/patterns/katalogs" + + // DefaultMotifRegistry is the official OCI path for Motif patterns. + DefaultMotifRegistry = "ghcr.io/orkspace/orkestra-registry/patterns/motifs" + + // DefaultPatternRegistry is an alias for DefaultKatalogRegistry. + DefaultPatternRegistry = DefaultKatalogRegistry + + // DefaultRegistry is an alias for DefaultKatalogRegistry. + DefaultRegistry = DefaultKatalogRegistry + + // EnvPatternRegistry overrides the default katalog registry path. + EnvPatternRegistry = "ORKESTRA_REGISTRY" + + // EnvMotifRegistry overrides the default motif registry path. + EnvMotifRegistry = "ORKESTRA_MOTIFS_REGISTRY" + + // EnvRegistry is an alias for EnvPatternRegistry. + EnvRegistry = EnvPatternRegistry + + // CacheDir is the local cache directory for pulled artifacts. + // Resolved relative to the user's home directory. + CacheDir = ".orkestra/registry" +) diff --git a/pkg/registry/docs/01-push-pull.md b/pkg/registry/docs/01-push-pull.md index e9f358b0..4ec0bfec 100644 --- a/pkg/registry/docs/01-push-pull.md +++ b/pkg/registry/docs/01-push-pull.md @@ -24,19 +24,23 @@ The progress callback fires once per file before any network I/O begins (sizes a ### Annotations -Every pattern manifest carries a standard set of OCI annotations derived from the pattern metadata: +Every manifest carries standard OCI annotations derived from `ArtifactMeta` (read from the primary YAML file): | Annotation | Source | |-----------|--------| -| `org.opencontainers.image.title` | `PatternMeta.Name` | -| `org.opencontainers.image.version` | `PatternMeta.Version` | -| `org.opencontainers.image.description` | `PatternMeta.Description` | -| `org.opencontainers.image.authors` | `PatternMeta.Author` | +| `org.opencontainers.image.title` | `ArtifactMeta.Name` | +| `org.opencontainers.image.version` | `ArtifactMeta.Version` | +| `org.opencontainers.image.description` | `ArtifactMeta.Description` | +| `org.opencontainers.image.authors` | `ArtifactMeta.Author` | | `org.opencontainers.image.created` | time of push | -| `io.orkestra.pattern.name` | `PatternMeta.Name` | -| `io.orkestra.pattern.tags` | comma-separated `PatternMeta.Tags` | - -`Info` reads these annotations to reconstruct `PatternMeta` without downloading the pattern files. +| `io.orkestra.artifact.kind` | `ArtifactMeta.Kind` (`Katalog` or `Motif`) | +| `io.orkestra.artifact.name` | `ArtifactMeta.Name` | +| `io.orkestra.artifact.version` | `ArtifactMeta.Version` | +| `io.orkestra.artifact.author` | `ArtifactMeta.Author` | +| `io.orkestra.artifact.license` | `ArtifactMeta.License` | +| `io.orkestra.artifact.tags` | comma-separated `ArtifactMeta.Tags` | + +`Info` reads these annotations to reconstruct metadata without downloading any artifact files. `annotationsToMeta` also reads the legacy `io.orkestra.pattern.*` keys so artifacts pushed before the generic artifact layer continue to work. ### Index update diff --git a/pkg/registry/docs/03-resolve-cache.md b/pkg/registry/docs/03-resolve-cache.md index f2c948d2..4a2ac462 100644 --- a/pkg/registry/docs/03-resolve-cache.md +++ b/pkg/registry/docs/03-resolve-cache.md @@ -78,4 +78,4 @@ if ref.IsCached() { `client.Pull(ctx, ref, true)` re-pulls even when the cache is warm. The CLI exposes this as `ork registry pull postgres:v14 --refresh`. -→ Next: [04-patterns.md](04-patterns.md) +→ Next: [04-artifacts.md](04-artifacts.md) diff --git a/pkg/registry/docs/04-artifacts.md b/pkg/registry/docs/04-artifacts.md new file mode 100644 index 00000000..2a50c01a --- /dev/null +++ b/pkg/registry/docs/04-artifacts.md @@ -0,0 +1,158 @@ +# 04 — Artifacts: Patterns and Motifs + +Orkestra publishes two kinds of artifacts to OCI registries. Both share the same push/pull/info/list pipeline — the kind is detected automatically from the primary YAML file. + +--- + +## Patterns (kind: Katalog) + +### Directory layout + +``` +my-operator/ + katalog.yaml ← operator declaration (required) + crd.yaml ← CRD schema (required) + README.md ← human documentation (optional) + cr.yaml ← example CR for the CRD (optional) +``` + +### Metadata derivation + +Metadata is read from `katalog.yaml` by `LoadArtifactMeta`. Fields: + +```yaml +metadata: + name: my-operator + version: v1.0.0 + description: "Manages MyResource lifecycle" + author: "Your Name" + license: Apache-2.0 + tags: [databases, stateful] +``` + +`name` is required. `version` defaults to `latest` and `description` defaults to `"Pattern "` if absent. + +### Validation pipeline (on push) + +Four layers run before any bytes are sent to the registry: + +| Layer | What is checked | +|-------|----------------| +| `ValidateArtifactDirectory` | Required files present (`katalog.yaml`, `crd.yaml`) | +| `merger.New().Merge()` | Katalog YAML parses correctly; sources resolve | +| `katalog.ValidateConfig` | Full semantic validation: field types, GVK uniqueness, dependency graph | +| `validateCRDFile` | YAML parses; `kind: CustomResourceDefinition`; `spec.group` and `spec.names.kind` present | + +### Publishing a pattern + +```bash +# 1. Authenticate +docker login ghcr.io + +# 2. Push — validation runs automatically before any network call +ork registry push my-operator:v1.0.0 ./my-operator/ + +# 3. Verify +ork registry info my-operator:v1.0.0 +ork registry list +``` + +The index at `ghcr.io/orkspace/orkestra-registry/patterns/index:latest` is updated automatically after each push. + +To push to a custom registry: + +```bash +ork registry push oci://ghcr.io/myorg/patterns/my-operator:v1.0.0 ./my-operator/ +# or via env var +ORKESTRA_REGISTRY=ghcr.io/myorg/patterns ork registry push my-operator:v1.0.0 ./my-operator/ +``` + +For the official `ghcr.io/orkspace/orkestra-registry` registry, patterns are published by opening a PR against `github.com/orkspace/orkestra-registry`. CI validates the pattern against a live kind cluster and pushes on merge. + +--- + +## Motifs (kind: Motif) + +Motifs are reusable resource primitives — typically stateful infrastructure services (databases, message queues, caches) that are shared across multiple apps. + +### Directory layout + +``` +my-motif/ + motif.yaml ← motif declaration (required) + README.md ← human documentation (optional) + example/ ← example usage directory (optional) +``` + +### Metadata derivation + +Metadata is read from `motif.yaml` by `LoadArtifactMeta`. The file must declare `kind: Motif`: + +```yaml +kind: Motif +metadata: + name: redis + version: v7.2.0 + description: "Redis in-memory data store" + author: "Your Name" + license: MIT + tags: [cache, stateful] +``` + +`name` is required. `version` defaults to `latest` and `description` defaults to `"Motif "` if absent. + +### Validation on push + +`ValidateArtifactDirectory` checks that `motif.yaml` is present and readable. No CRD or katalog validation is performed — motifs are self-contained YAML templates. + +### Publishing a motif + +```bash +# 1. Authenticate +docker login ghcr.io + +# 2. Push +ork registry push redis:v7.2.0 ./redis/ + +# 3. Verify +ork registry info redis:v7.2.0 +ork registry list +``` + +The default motif registry is `ghcr.io/orkspace/orkestra-motifs`. The index at `ghcr.io/orkspace/orkestra-motifs/index:latest` is updated automatically. + +To push to a custom motif registry: + +```bash +ork registry push oci://ghcr.io/myorg/motifs/redis:v7.2.0 ./redis/ +# or via env var +ORKESTRA_MOTIFS_REGISTRY=ghcr.io/myorg/motifs ork registry push redis:v7.2.0 ./redis/ +``` + +--- + +## Extending the artifact format + +### Adding a new file type to an existing kind + +1. Add a `FileXxx` constant to `constant.go`. +2. Add a case to `mediaTypeForArtifactFile` in `artifact.go`. +3. Add the filename to `OptionalFiles` (or `RequiredFiles`) in the relevant `ArtifactSpec` in `artifactSpecs`. + +The file will be pushed as a layer with the appropriate media type and an `org.opencontainers.image.title` annotation set to the filename. ORAS uses this annotation on pull to write the file to the correct path in the cache directory. + +### Adding a new artifact kind + +Add one entry to `artifactSpecs` in `artifact.go`: + +```go +KindFoo: { + Kind: KindFoo, + MediaType: "application/vnd.orkestra.foo.v1+tar+gzip", + PrimaryFile: "foo.yaml", + RequiredFiles: []string{"foo.yaml"}, + OptionalFiles: []string{FileReadme}, +}, +``` + +No other changes are required. `DetectKind`, `ValidateArtifactDirectory`, `LoadArtifactMeta`, and the push/pull pipeline all work from `artifactSpecs` automatically. diff --git a/pkg/registry/docs/04-patterns.md b/pkg/registry/docs/04-patterns.md deleted file mode 100644 index b5ec1e64..00000000 --- a/pkg/registry/docs/04-patterns.md +++ /dev/null @@ -1,66 +0,0 @@ -# 04 — Patterns and the Artifact Format - -## Pattern directory layout - -``` -my-operator/ - katalog.yaml ← operator declaration (required) - crd.yaml ← CRD schema (required) - README.md ← human documentation (optional) - cr.yaml ← example CR for the CRD (optional) -``` - -`ValidateDirectory` enforces the required files and derives `PatternMeta` from the contents of `katalog.yaml`. - -## Metadata derivation - -Metadata is derived solely from `katalog.yaml`. The derivation runs in `ValidateDirectory`: - -1. Parse `katalog.yaml` — extract `metadata.name`, `metadata.version`, `metadata.description`, `metadata.author`, `metadata.tags`, and required providers from `spec.providers`. -2. Validate: `name`, `version`, and `description` must all be non‑empty. - (`metadata.name` is already required by Katalog validation; `version` and `description` default to `latest` and a placeholder if absent.) - -```go -meta, files, err := registry.ValidateDirectory("./my-operator/") -// meta.Name, meta.Version, meta.Description, meta.Tags, meta.Author -// files = ["katalog.yaml", "crd.yaml", "README.md", "cr.yaml"] (present files in canonical order) -``` - -## Validation pipeline (on push) - -Three layers of validation run before any bytes are sent to the registry: - -| Layer | What is checked | -|-------|----------------| -| `ValidateDirectory` | Required files present; metadata non‑empty after derivation | -| `merger.New().Merge()` | Katalog YAML parses correctly; sources resolve | -| `katalog.ValidateConfig` | Full semantic validation: field types, GVK uniqueness, dependency graph, reconciler modes | -| `validateCRDFile` | YAML parses; `kind: CustomResourceDefinition`; `spec.group` and `spec.names.kind` present | - -An empty `katalog.yaml` or an invalid CRD structure fails fast before any network call. - -## Adding a new file type to the artifact - -1. Add a `FileXxx` constant to `pattern.go`. -2. Add a case to `mediaTypeForFile` in `client.go` with an appropriate MIME type. -3. Add the file to the `optional` slice in `ValidateDirectory` (or `required` if it must always be present). - -The file will be pushed as a layer with its media type and the `org.opencontainers.image.title` annotation set to the filename. `oras.Copy` on the pull side uses this annotation to write the file to the correct path in the cache directory. - -## Publishing to the official registry - -```bash -# 1. Authenticate -docker login ghcr.io - -# 2. Push (validation runs automatically) -ork registry push my-operator:v1.0.0 ./my-operator/ - -# 3. Verify -ork registry info my-operator:v1.0.0 -ork registry list -``` - -The index at `ghcr.io/orkspace/orkestra-registry/index:latest` is updated automatically after each push. - -For the official `ghcr.io/orkspace/orkestra-registry` registry, patterns are published by opening a PR against `github.com/orkspace/orkestra-registry`. CI validates the pattern against a live kind cluster and pushes on merge. diff --git a/pkg/registry/helper.go b/pkg/registry/helper.go new file mode 100644 index 00000000..742680fa --- /dev/null +++ b/pkg/registry/helper.go @@ -0,0 +1,80 @@ +package registry + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/orkspace/orkestra/pkg/utils" + "gopkg.in/yaml.v3" +) + +// Helpers + +// ToString returns the underlying string value. +func (k PatternKind) ToString() string { + return string(k) +} + +// String implements fmt.Stringer so PatternKind prints nicely with fmt. +func (k PatternKind) String() string { + return k.ToString() +} + +// ExtractTagVersion extracts the tag portion from a reference like: +// +// "name:version" -> "version" +// "ghcr.io/org/repo/name:version" -> "version" +// "oci://ghcr.io/org/repo/name:version" -> "version" +// +// If no tag is present (e.g., "name" or "ghcr.io/org/repo/name@sha256:..."), returns "". +func ExtractTagVersion(ref string) string { + // strip any digest part first (after '@') + if at := strings.Index(ref, "@"); at != -1 { + ref = ref[:at] + } + // find last ':' and last '/' + lastColon := strings.LastIndex(ref, ":") + lastSlash := strings.LastIndex(ref, "/") + if lastColon == -1 || lastColon < lastSlash { + return "" + } + return ref[lastColon+1:] +} + +// helper to persist metadata.version into the primary file +// dir: directory containing the primary file +// primaryFile: filename (e.g., "motif.yaml" or "katalog.yaml") +// newVersion: version string to write into metadata.version +// uses WriteFileAndFormat so the file is formatted after the change. +func PersistMetadataVersion(dir, primaryFile, newVersion string) error { + path := filepath.Join(dir, primaryFile) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", primaryFile, err) + } + + var doc map[string]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("parsing %s: %w", primaryFile, err) + } + + meta, ok := doc["metadata"].(map[string]interface{}) + if !ok || meta == nil { + meta = make(map[string]interface{}) + doc["metadata"] = meta + } + meta["version"] = newVersion + + out, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("marshalling %s: %w", primaryFile, err) + } + + // Write and then format the file for consistent layout + if err := utils.WriteFileAndFormat(path, out, 0o644); err != nil { + return fmt.Errorf("writing/formatting %s: %w", primaryFile, err) + } + return nil +} diff --git a/pkg/registry/pattern.go b/pkg/registry/pattern.go index c9608c46..f7502e4f 100644 --- a/pkg/registry/pattern.go +++ b/pkg/registry/pattern.go @@ -1,18 +1,12 @@ // pkg/registry/pattern.go // -// Pattern is the atomic unit of the Orkestra Registry. -// A pattern directory contains: +// Generic pattern layer for the Orkestra registry. // -// katalog.yaml — operator declaration (required) -// crd.yaml — CRD schema (required) -// README.md — human documentation (optional) -// cr.yaml — example CR (optional) +// Every Orkestra pattern file carries a kind: field. This package reads that +// field to determine the pattern's media type, required/optional files, and +// which registry to push to — without a separate code path per kind. // -// Pattern metadata is derived entirely from katalog.yaml: -// - name, version, description, author, tags from metadata -// - required providers from spec.providers -// -// No separate pattern.yaml is required or allowed. +// To add a new pattern kind: add one entry to patternSpecs. Nothing else changes. package registry import ( @@ -23,142 +17,155 @@ import ( "gopkg.in/yaml.v3" ) -const ( - // MediaType is the OCI artifact media type for Orkestra patterns. - MediaType = "application/vnd.orkestra.pattern.v1+tar+gzip" - - // FileKatalog is the required operator declaration file. - FileKatalog = "katalog.yaml" - - // FileCRD is the required CRD schema file. - FileCRD = "crd.yaml" - - // FileReadme is the human documentation file. - FileReadme = "README.md" - - // FileCR is the example CR file. - FileCR = "cr.yaml" -) - -// PatternMeta holds metadata derived from the Katalog. -type PatternMeta struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Description string `yaml:"description"` - Author string `yaml:"author,omitempty"` - License string `yaml:"license,omitempty"` - Tags []string `yaml:"tags,omitempty"` - Requires PatternRequires `yaml:"requires,omitempty"` - Changelog []ChangelogEntry `yaml:"changelog,omitempty"` // reserved for future use -} - -// PatternRequires declares external dependencies. -type PatternRequires struct { - Providers []string `yaml:"providers,omitempty"` -} - -// ChangelogEntry represents one version entry in the changelog. -type ChangelogEntry struct { - Version string `yaml:"version"` - Notes string `yaml:"notes"` -} - -// PatternIndex is the top-level index stored at registry/index:latest. -type PatternIndex struct { - UpdatedAt string `json:"updatedAt"` - Patterns []PatternEntry `json:"patterns"` +// patternSpecs is the registry of known pattern kinds. +// Add new kinds here — no other changes required. +var patternSpecs = map[PatternKind]*PatternSpec{ + KatalogKind: { + Kind: KatalogKind, + MediaType: "application/vnd.orkestra.pattern.v1+tar+gzip", + PrimaryFile: FileKatalog, + RequiredFiles: []string{FileKatalog, FileCRD}, + OptionalFiles: []string{FileReadme, FileCR}, + }, + MotifKind: { + Kind: MotifKind, + MediaType: "application/vnd.orkestra.motif.v1+tar+gzip", + PrimaryFile: FileMotif, + RequiredFiles: []string{FileMotif}, + OptionalFiles: []string{FileReadme, "example/"}, + }, } -// PatternEntry is one row in the pattern index. -type PatternEntry struct { - Name string `json:"name"` - LatestVersion string `json:"latestVersion"` - Description string `json:"description"` - Tags []string `json:"tags"` - Author string `json:"author,omitempty"` +// DetectKind reads the primary YAML file in dir and returns the pattern kind. +// Tries katalog.yaml first, then motif.yaml. +func DetectKind(dir string) (PatternKind, *PatternSpec, error) { + candidates := []string{FileKatalog, FileMotif} + for _, name := range candidates { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var header struct { + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(data, &header); err != nil { + return UnknownKind, nil, fmt.Errorf("reading %s: %w", name, err) + } + kind := PatternKind(header.Kind) + if spec, ok := patternSpecs[kind]; ok { + return kind, spec, nil + } + } + return UnknownKind, nil, fmt.Errorf( + "no recognized Orkestra pattern in %s (expected %s with kind: Katalog, or motif.yaml with kind: Motif)", + dir, FileKatalog, + ) } -// ValidateDirectory checks that dir contains a valid pattern. -// Returns metadata (from katalog.yaml) and the list of files to include. -func ValidateDirectory(dir string) (*PatternMeta, []string, error) { - required := []string{FileKatalog, FileCRD} - for _, f := range required { - if _, err := os.Stat(filepath.Join(dir, f)); err != nil { - return nil, nil, fmt.Errorf("required file missing: %s", f) - } +// SpecFor returns the PatternSpec for a given kind. +func SpecFor(kind PatternKind) (*PatternSpec, error) { + spec, ok := patternSpecs[kind] + if !ok { + return nil, fmt.Errorf("unknown pattern kind: %q", kind) } + return spec, nil +} - meta, err := deriveMetadataFromKatalog(filepath.Join(dir, FileKatalog)) +// ValidatePatternDirectory validates that dir contains a well-formed pattern +// of the auto-detected kind. Returns the kind, spec, and the list of files to include. +func ValidatePatternDirectory(dir string) (PatternKind, *PatternSpec, []string, error) { + kind, spec, err := DetectKind(dir) if err != nil { - return nil, nil, fmt.Errorf("parsing katalog.yaml: %w", err) + return UnknownKind, nil, nil, err } - files := []string{FileKatalog, FileCRD} - optional := []string{FileReadme, FileCR} - for _, f := range optional { + var files []string + for _, f := range spec.RequiredFiles { + if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) { + return kind, spec, nil, fmt.Errorf("%s pattern missing required file: %s", kind, f) + } + files = append(files, f) + } + for _, f := range spec.OptionalFiles { if _, err := os.Stat(filepath.Join(dir, f)); err == nil { files = append(files, f) } } - return meta, files, nil + + return kind, spec, files, nil } -// deriveMetadataFromKatalog reads katalog.yaml and builds PatternMeta. -func deriveMetadataFromKatalog(path string) (*PatternMeta, error) { +// LoadPatternMeta reads name/version/description from the primary file. +// Works for both Katalog (katalog.yaml) and Motif (motif.yaml). +func LoadPatternMeta(dir string, spec *PatternSpec) (*PatternMeta, error) { + path := filepath.Join(dir, spec.PrimaryFile) data, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("reading %s: %w", spec.PrimaryFile, err) } - var katalog struct { + var raw struct { + Kind string `yaml:"kind"` Metadata struct { Name string `yaml:"name"` Version string `yaml:"version"` - Author string `yaml:"author"` Description string `yaml:"description"` + Author string `yaml:"author"` + License string `yaml:"license"` Tags []string `yaml:"tags"` } `yaml:"metadata"` - Spec struct { - Providers []struct { - Name string `yaml:"name"` - Required bool `yaml:"required"` - } `yaml:"providers"` - } `yaml:"spec"` } - if err := yaml.Unmarshal(data, &katalog); err != nil { - return nil, err + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing %s: %w", spec.PrimaryFile, err) } - if katalog.Metadata.Name == "" { - return nil, fmt.Errorf("metadata.name is required") + if raw.Metadata.Name == "" { + return nil, fmt.Errorf("%s: metadata.name is required", spec.PrimaryFile) } meta := &PatternMeta{ - Name: katalog.Metadata.Name, - Version: katalog.Metadata.Version, - Description: katalog.Metadata.Description, - Author: katalog.Metadata.Author, - Tags: katalog.Metadata.Tags, - } - // Collect required providers - var providers []string - for _, p := range katalog.Spec.Providers { - if p.Required { - providers = append(providers, p.Name) - } - } - if len(providers) > 0 { - meta.Requires.Providers = providers + Kind: PatternKind(raw.Kind), + Name: raw.Metadata.Name, + Version: raw.Metadata.Version, + Description: raw.Metadata.Description, + Author: raw.Metadata.Author, + License: raw.Metadata.License, + Tags: raw.Metadata.Tags, } - // Apply defaults if meta.Version == "" { meta.Version = "latest" } if meta.Description == "" { - meta.Description = fmt.Sprintf("Pattern for %s", meta.Name) + meta.Description = fmt.Sprintf("%s %s", kind(spec.Kind), meta.Name) } return meta, nil } -// LoadPatternMeta returns pattern metadata for a directory (convenience wrapper). -func LoadPatternMeta(dir string) (*PatternMeta, error) { - meta, _, err := ValidateDirectory(dir) - return meta, err +// kind returns a display string for a PatternKind. +func kind(k PatternKind) string { + switch k { + case KatalogKind: + return "Pattern" + case MotifKind: + return "Motif" + default: + return "Pattern" + } +} + +// mediaTypeForPatternFile returns the OCI layer media type for a file within +// a specific pattern kind. +func mediaTypeForPatternFile(name string, k PatternKind) string { + switch name { + case FileKatalog: + return "application/vnd.orkestra.katalog.v1+yaml" + case FileCRD: + return "application/vnd.kubernetes.crd.v1+yaml" + case FileCR: + return "application/vnd.kubernetes.cr.v1+yaml" + case FileReadme: + return "text/markdown" + case FileMotif: + return "application/vnd.orkestra.motif.v1+yaml" + default: + return "application/octet-stream" + } } diff --git a/pkg/registry/persist_meta_test.go b/pkg/registry/persist_meta_test.go new file mode 100644 index 00000000..34262e1f --- /dev/null +++ b/pkg/registry/persist_meta_test.go @@ -0,0 +1,89 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPersistMetadataVersion(t *testing.T) { + t.Run("updates existing metadata.version", func(t *testing.T) { + dir := t.TempDir() + primary := "motif.yaml" + path := filepath.Join(dir, primary) + + orig := ` +kind: Motif +metadata: + name: postgres + version: v1 + description: "test motif" +` + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("write primary file: %v", err) + } + + newVersion := "v2" + if err := PersistMetadataVersion(dir, primary, newVersion); err != nil { + t.Fatalf("persistMetadataVersion failed: %v", err) + } + + out, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read updated file: %v", err) + } + + var doc map[string]interface{} + if err := yaml.Unmarshal(out, &doc); err != nil { + t.Fatalf("unmarshal updated file: %v", err) + } + + meta, ok := doc["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("metadata section missing after update") + } + if got, _ := meta["version"].(string); got != newVersion { + t.Fatalf("version = %q; want %q", got, newVersion) + } + }) + + t.Run("creates metadata and sets version when metadata missing", func(t *testing.T) { + dir := t.TempDir() + primary := "katalog.yaml" + path := filepath.Join(dir, primary) + + orig := ` +kind: Katalog +spec: + something: true +` + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("write primary file: %v", err) + } + + newVersion := "v0.1.0" + if err := PersistMetadataVersion(dir, primary, newVersion); err != nil { + t.Fatalf("persistMetadataVersion failed: %v", err) + } + + out, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read updated file: %v", err) + } + + var doc map[string]interface{} + if err := yaml.Unmarshal(out, &doc); err != nil { + t.Fatalf("unmarshal updated file: %v", err) + } + + meta, ok := doc["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("metadata section missing after update") + } + if got, _ := meta["version"].(string); got != newVersion { + t.Fatalf("version = %q; want %q", got, newVersion) + } + }) +} diff --git a/pkg/registry/ref_test.go b/pkg/registry/ref_test.go new file mode 100644 index 00000000..8733368b --- /dev/null +++ b/pkg/registry/ref_test.go @@ -0,0 +1,36 @@ +package registry + +import "testing" + +func TestExtractTagVersion(t *testing.T) { + tests := []struct { + ref string + want string + }{ + // simple name:tag + {"postgres:v14", "v14"}, + {"redis:7.0.1", "7.0.1"}, + // registry host + path + {"ghcr.io/orkspace/postgres:v0.1.0", "v0.1.0"}, + {"oci://ghcr.io/orkspace/postgres:v0.1.0", "v0.1.0"}, + // no tag + {"postgres", ""}, + {"ghcr.io/orkspace/postgres", ""}, + // digest refs (should ignore digest) + {"ghcr.io/orkspace/postgres@sha256:abcdef", ""}, + {"ghcr.io/orkspace/postgres:v0.1.0@sha256:abcdef", "v0.1.0"}, + // edge cases with multiple colons (ports in host) + {"localhost:5000/postgres:v2", "v2"}, + {"localhost:5000/postgres", ""}, + // weird but valid-looking strings + {"repo/name:with:colons:tag", "tag"}, + {"repo/name:tag-with:colon", "colon"}, + } + + for _, tc := range tests { + got := ExtractTagVersion(tc.ref) + if got != tc.want { + t.Fatalf("ExtractTagVersion(%q) = %q; want %q", tc.ref, got, tc.want) + } + } +} diff --git a/pkg/registry/resolve.go b/pkg/registry/resolve.go index 787e6613..58a0c21a 100644 --- a/pkg/registry/resolve.go +++ b/pkg/registry/resolve.go @@ -7,7 +7,7 @@ // // 1. Full OCI reference (starts with "oci://") — used as-is // 2. ORKESTRA_REGISTRY env var + "/name:version" -// 3. Default: ghcr.io/orkspace/orkestra-registry/name:version +// 3. Default: ghcr.io/orkspace/orkestra-registry/patterns/katalogs/name:version // // The "oci://" prefix is stripped before passing to ORAS — it is a user-facing // convention to signal "this is an OCI reference", not part of the actual URL. @@ -20,40 +20,24 @@ import ( "strings" ) -const ( - // DefaultRegistry is the official Orkestra pattern registry. - DefaultRegistry = "ghcr.io/orkspace/orkestra-registry/patterns" - - // EnvRegistry is the environment variable for overriding the registry. - EnvRegistry = "ORKESTRA_REGISTRY" - - // CacheDir is the local cache directory for pulled patterns. - // Resolved relative to the user's home directory. - CacheDir = ".orkestra/registry" -) - // Ref holds a resolved OCI reference. type Ref struct { // Registry is the hostname (e.g. "ghcr.io"). Registry string - // Repository is the full repository path without the registry // (e.g. "orkspace/orkestra-registry/postgres"). Repository string - // Tag is the version tag (e.g. "v14"). Tag string - // Full is the complete reference without the oci:// prefix. - // Suitable for passing directly to ORAS. Full string } // Resolve converts a user-supplied reference to a fully qualified OCI Ref. // -// "postgres:v14" → ghcr.io/orkspace/orkestra-registry/postgres:v14 -// "oci://ghcr.io/myorg/patterns/redis:v7" → ghcr.io/myorg/patterns/redis:v7 -// "myorg/redis:v7" (with ORKESTRA_REGISTRY set) → resolved against env +// "postgres:v14" → ghcr.io/orkspace/orkestra-registry/patterns/katalogs/postgres:v14 +// "oci://ghcr.io/myorg/patterns/katalogs/redis:v7" → ghcr.io/myorg/patterns/katalogs/redis:v7 +// "myorg/redis:v7" (with ORKESTRA_REGISTRY set) → resolved against env func Resolve(input string) (*Ref, error) { input = strings.TrimSpace(input) if input == "" { @@ -149,3 +133,34 @@ func (r *Ref) ShortName() string { parts := strings.Split(r.Repository, "/") return parts[len(parts)-1] + ":" + r.Tag } + +// ResolveForKind resolves a reference against the correct default registry +// for the given pattern kind. A full OCI reference (contains a dot in the host) +// or an oci:// prefix is used as-is regardless of kind. +func ResolveForKind(input string, k PatternKind) (*Ref, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("empty reference") + } + raw := strings.TrimPrefix(input, "oci://") + if looksLikeFull(raw) { + return parseRef(raw) + } + + var base string + switch k { + case MotifKind: + base = os.Getenv(EnvMotifRegistry) + if base == "" { + base = DefaultMotifRegistry + } + default: + base = os.Getenv(EnvPatternRegistry) + if base == "" { + base = DefaultPatternRegistry + } + } + base = strings.TrimPrefix(base, "oci://") + base = strings.TrimSuffix(base, "/") + return parseRef(base + "/" + raw) +} diff --git a/pkg/registry/types.go b/pkg/registry/types.go new file mode 100644 index 00000000..4dfd315d --- /dev/null +++ b/pkg/registry/types.go @@ -0,0 +1,52 @@ +package registry + +import "time" + +// PatternKind is the Orkestra pattern type, read from the kind: field. +type PatternKind string + +// PatternSpec describes the conventions for one pattern kind. +type PatternSpec struct { + Kind PatternKind + MediaType string + PrimaryFile string + RequiredFiles []string + OptionalFiles []string +} + +// PatternMeta holds metadata for an pattern — read from its primary YAML on +// push, and reconstructed from OCI annotations on info/list. +type PatternMeta struct { + Kind PatternKind + Name string + Version string + Description string + Author string + License string + Tags []string +} + +// PatternEntry is one row in the pattern index. +type PatternEntry struct { + Name string `json:"name"` + LatestVersion string `json:"latestVersion"` + Description string `json:"description"` + Tags []string `json:"tags"` + Author string `json:"author,omitempty"` + Kind string `json:"kind,omitempty"` // "Katalog" or "Motif" +} + +// PatternIndex is the top-level index stored at registry/index:latest. +type PatternIndex struct { + UpdatedAt string `json:"updatedAt"` + Entries []PatternEntry `json:"entries"` +} + +// PatternInfo holds the metadata returned by Info. +type PatternInfo struct { + Ref *Ref + Digest string + Size int64 + PushedAt time.Time + Meta *PatternMeta +} diff --git a/pkg/types/hook_methods.go b/pkg/types/hook_methods.go index aca1cfba..ebf33c9e 100644 --- a/pkg/types/hook_methods.go +++ b/pkg/types/hook_methods.go @@ -1,5 +1,30 @@ package types +// IsEmpty reports whether this HookTemplates has no resource declarations. +func (h HookTemplates) IsEmpty() bool { + return len(h.Deployments) == 0 && + len(h.ReplicaSets) == 0 && + len(h.StatefulSets) == 0 && + len(h.Services) == 0 && + len(h.Pods) == 0 && + len(h.Jobs) == 0 && + len(h.CronJobs) == 0 && + len(h.Secrets) == 0 && + len(h.ConfigMaps) == 0 && + len(h.ServiceAccounts) == 0 && + len(h.Ingresses) == 0 && + len(h.PersistentVolumes) == 0 && + len(h.PersistentVolumeClaims) == 0 && + len(h.HorizontalPodAutoscalers) == 0 && + len(h.PodDisruptionBudgets) == 0 && + len(h.Namespaces) == 0 && + len(h.Roles) == 0 && + len(h.RoleBindings) == 0 && + len(h.External) == 0 && + h.Git == nil && + h.Docker == nil +} + // HasAnyHooks reports whether this CRD declares any onCreate, onReconcile, or onDelete hooks. func (c *CRDEntry) HasAnyHooks() bool { return c.HasOnCreate() || c.HasOnReconcile() || c.HasOnDelete() diff --git a/pkg/types/motif.go b/pkg/types/motif.go index 7747ae49..19c9e4b9 100644 --- a/pkg/types/motif.go +++ b/pkg/types/motif.go @@ -27,22 +27,37 @@ package types // - field: spec.replicas // default: "2" type Motif struct { - APIVersion string `yaml:"apiVersion" json:"apiVersion"` - Kind string `yaml:"kind" json:"kind"` - Metadata MotifMeta `yaml:"metadata" json:"metadata"` - Inputs []MotifInput `yaml:"inputs,omitempty" json:"inputs,omitempty"` - Resources *HookTemplates `yaml:"resources,omitempty" json:"resources,omitempty"` - Status *StatusConfig `yaml:"status,omitempty" json:"status,omitempty"` - Admission *Admission `yaml:"admission,omitempty" json:"admission,omitempty"` + APIVersion string `yaml:"apiVersion" json:"apiVersion"` + Kind string `yaml:"kind" json:"kind"` + Metadata MotifMeta `yaml:"metadata" json:"metadata"` + Inputs []MotifInput `yaml:"inputs,omitempty" json:"inputs,omitempty"` + Resources *MotifResources `yaml:"resources,omitempty" json:"resources,omitempty"` + Status *StatusConfig `yaml:"status,omitempty" json:"status,omitempty"` + Admission *Admission `yaml:"admission,omitempty" json:"admission,omitempty"` +} + +// MotifResources groups the resources a Motif contributes to a CRD entry. +// Resources declared directly under resources: are merged into onReconcile. +// Resources declared under resources.onCreate: are merged into onCreate, +// making them immune to the update=true path (correct for once: true secrets). +type MotifResources struct { + // OnCreate groups resources that must only be processed during creation — + // never updated on subsequent reconciles. Secrets with once: true belong here. + OnCreate *HookTemplates `yaml:"onCreate,omitempty" json:"onCreate,omitempty"` + + // All remaining HookTemplates fields are promoted to the resources: level + // and merged into the CRD's onReconcile phase. + HookTemplates `yaml:",inline"` } // MotifMeta holds Motif identity fields. type MotifMeta struct { - Name string `yaml:"name" json:"name"` - Version string `yaml:"version,omitempty" json:"version,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Author string `yaml:"author,omitempty" json:"author,omitempty"` - License string `yaml:"license,omitempty" json:"license,omitempty"` + Name string `yaml:"name" json:"name"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + License string `yaml:"license,omitempty" json:"license,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` } // MotifInput declares one input parameter for a Motif. diff --git a/pkg/utils/yaml.go b/pkg/utils/yaml.go index b2b5ab8b..fd06b155 100644 --- a/pkg/utils/yaml.go +++ b/pkg/utils/yaml.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "os" + "gopkg.in/yaml.v3" ) @@ -101,3 +103,45 @@ func getLineFromData(data []byte, lineNum int) string { } return "" } + +// FormatYAMLFile reads path, parses into a yaml.Node and writes it back +// with consistent indentation and preserved comments/order. +func FormatYAMLFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&root); err != nil { + _ = enc.Close() + return fmt.Errorf("encode %s: %w", path, err) + } + if err := enc.Close(); err != nil { + return fmt.Errorf("close encoder: %w", err) + } + + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// WriteFileAndFormat writes data to path and then formats the YAML file. +// It preserves a simple, consistent workflow: write -> format -> return error if any. +func WriteFileAndFormat(path string, data []byte, perm os.FileMode) error { + if err := os.WriteFile(path, data, perm); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + if err := FormatYAMLFile(path); err != nil { + return fmt.Errorf("format %s: %w", path, err) + } + return nil +}