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 +}