From 712da520e09f9e06d7bba743791513b8215ba15c Mon Sep 17 00:00:00 2001 From: ialexeze Date: Sun, 10 May 2026 12:43:00 +0000 Subject: [PATCH 1/2] try using motifs for the developer path --- README.md | 1 + cmd/cli/deploy.go | 300 ++++++++---- cmd/cli/doctor.go | 127 ++++- .../cc/assets/templates/dev_apps.html | 148 ++++++ cmd/controlcenter/cc/controlcenter.go | 42 ++ cmd/controlcenter/cc/types.go | 60 ++- .../one-katalog-developer-path.md | 430 +++++++++++++++++ pkg/autoscaler/autoscale_semaphore.go | 6 +- pkg/doctor/generate.go | 435 +++++++++++++++++- pkg/doctor/helm.go | 34 +- pkg/doctor/notify.go | 2 +- pkg/doctor/state.go | 54 +++ pkg/katalog/cliMethods.go | 6 + pkg/katalog/validation_autoscale.go | 18 +- pkg/kordinator/crd_health_handers.go | 36 +- pkg/orkestra-registry/common/methods.go | 18 + pkg/orkestra-registry/common/parse.go | 51 ++ pkg/orkestra-registry/configmaps/configmap.go | 1 + pkg/orkestra-registry/cronjobs/cronjob.go | 1 + .../deployments/deployment.go | 3 +- pkg/orkestra-registry/hpas/hpa.go | 1 + pkg/orkestra-registry/ingresses/ingress.go | 1 + pkg/orkestra-registry/jobs/job.go | 1 + pkg/orkestra-registry/namespaces/namespace.go | 1 + pkg/orkestra-registry/pdbs/pdb.go | 1 + pkg/orkestra-registry/pods/pod.go | 1 + pkg/orkestra-registry/pvcs/pvc.go | 1 + pkg/orkestra-registry/pvs/pv.go | 1 + .../replicasets/replicaset.go | 3 +- .../rolebindings/rolebinding.go | 1 + pkg/orkestra-registry/roles/role.go | 1 + pkg/orkestra-registry/secrets/secret.go | 1 + .../serviceaccounts/serviceaccount.go | 1 + pkg/orkestra-registry/services/services.go | 1 + .../statefulsets/statefulset.go | 3 +- pkg/orkestra-registry/template/resolver.go | 15 +- pkg/types/katalog.go | 20 +- 37 files changed, 1673 insertions(+), 154 deletions(-) create mode 100644 cmd/controlcenter/cc/assets/templates/dev_apps.html create mode 100644 docs/design-documents/one-katalog-developer-path.md diff --git a/README.md b/README.md index a5386f1b..d7343045 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Every CRD declared in a Katalog becomes a complete, isolated operator: | **Workqueue** | Per-CRD. Rate-limited. Deduplicated. Isolated from every other CRD. | | **Worker pool** | Configurable. A panic in one CRD does not affect any other. | | **Drift correction** | `reconcile: true` — desired state is enforced on every cycle. | +| **Safe reconcile** | Failures in one operatroBox is contained, logged and does not affect the runtime or other CRDs. | | **Owner references** | Child resources deleted when the CR is deleted. | | **Finalizers** | CRs protected from dirty deletion automatically. | | **Events** | Every reconcile is a traceable Kubernetes event. | diff --git a/cmd/cli/deploy.go b/cmd/cli/deploy.go index 952616cf..8627e6d1 100644 --- a/cmd/cli/deploy.go +++ b/cmd/cli/deploy.go @@ -68,17 +68,11 @@ to the cluster, and patch the CR to trigger a rolling deploy. return fmt.Errorf("--registry is required (e.g. --registry ghcr.io/myorg)") } - // Load persistent state and global Komposer (both non-fatal if missing). + // Load persistent state (non-fatal if missing). state, err := doctor.LoadState() if err != nil { state = &doctor.DeployState{Projects: make(map[string]*doctor.ProjectState)} } - komposer, _ := doctor.LoadGlobalKomposer() - if state.ClusterContext == "" { - komposer.Metadata.Description = "Orkestra Managed Deployment in " + state.ClusterContext - } else { - komposer.Metadata.Description = "Orkestra Managed Deployment" - } if !dryRun { // Step 0 — Cluster connectivity. @@ -102,6 +96,7 @@ to the cluster, and patch the CR to trigger a rolling deploy. // --use-compose init writes Apps entries but never writes app.yaml, so the // crName resolution below is skipped entirely for multi-app projects. if len(initCfg.Apps) > 0 { + komposer, _ := doctor.LoadGlobalKomposer() err := deployMultiApp(deployContext{ dir: dir, registry: registry, @@ -152,9 +147,8 @@ to the cluster, and patch the CR to trigger a rolling deploy. // Show cluster context and currently deployed projects. clusterCtx := doctor.CurrentContext() - deployed := komposer.DeployedProjects() fmt.Printf("\nCluster: %s\n", clusterCtx) - if len(deployed) > 0 { + if deployed := state.DeployedAppNames(); len(deployed) > 0 { fmt.Printf("Deployed: %s\n", strings.Join(deployed, ", ")) } @@ -186,24 +180,10 @@ to the cluster, and patch the CR to trigger a rolling deploy. fmt.Println(" ~ dry-run: skipping docker build and push") } - // Step 3 — Generate bundle + // Step 3 — Resolve motif + generate bundle from central Katalog fmt.Println("\nGenerating bundle...") - initOpts := doctor.GenerateOptions{ - NoHA: noHA, - NoSecure: noSecure, - Clean: clean, - } - bundleDir := filepath.Join(dir, orkDir, "bundle") - katalogPath := filepath.Join(dir, orkDir, "katalog.yaml") - absKatalogPath, _ := filepath.Abs(katalogPath) - - if !dryRun { - if err := doctor.GenerateBundle(appName, ns, info.Secrets, info.Config, bundleDir); err != nil { - return err - } - } if len(info.Secrets) > 0 { fmt.Printf(" %s %s-secrets (%d variables from .env)\n", utils.SuccessMark(), appName, len(info.Secrets)) @@ -212,45 +192,24 @@ to the cluster, and patch the CR to trigger a rolling deploy. fmt.Printf(" %s %s-config (%d variables from .env)\n", utils.SuccessMark(), appName, len(info.Config)) } - // Auto-generate katalog if not present yet. - if !fileExistsAtPath(katalogPath) { - if !dryRun { - if err := doctor.Init(info, initOpts); err != nil { - return fmt.Errorf("generating katalog: %w", err) - } - } - fmt.Printf(" %s katalog.yaml generated", utils.SuccessMark()) - } - - if fileExistsAtPath(katalogPath) || dryRun { - if !dryRun { - komposer.RegisterKatalog(absKatalogPath) - state.ClusterContext = clusterCtx - komposer.Metadata.Name = clusterCtx - komposer.Metadata.License = info.License - if saveErr := komposer.Save(); saveErr != nil { - return fmt.Errorf("saving komposer: %w", saveErr) - } - - mergedPath, mergeErr := runKompose() - if mergeErr != nil { - return fmt.Errorf("merging katalogs: %w", mergeErr) - } - if err := doctor.DeduplicateKatalogGVKs(mergedPath); err != nil { - return fmt.Errorf("deduplicating katalog GVKs: %w", err) - } - effectiveKatalog := mergedPath - fmt.Printf(" %s Komposer merged (%d projects)\n", utils.SuccessMark(), len(komposer.DeployedProjects())) - - genArgs := []string{"generate", "bundle", "-f", effectiveKatalog, "-w", ns, "-o", bundleDir} - genCmd := exec.Command("ork", genArgs...) - genCmd.Stdout = os.Stdout - genCmd.Stderr = os.Stderr - if err := genCmd.Run(); err != nil { - return fmt.Errorf("generating bundle: %w", err) - } - } - fmt.Printf(" %s RBAC + Katalog ConfigMap + namespace", utils.SuccessMark()) + if err := deployDeveloperPath(devPathArgs{ + dir: dir, + appName: appName, + ns: ns, + image: image, + port: info.Port, + language: string(info.Language), + bundleDir: bundleDir, + secrets: info.Secrets, + config: info.Config, + dryRun: dryRun, + opts: doctor.GenerateOptions{ + NoHA: noHA, + NoSecure: noSecure, + Clean: clean, + }, + }); err != nil { + return err } // Step 4 — Apply bundle @@ -280,7 +239,7 @@ to the cluster, and patch the CR to trigger a rolling deploy. if err := applyBundle.Run(); err != nil { return fmt.Errorf("applying bundle: %w", err) } - fmt.Printf(" %s Bundle applied", utils.SuccessMark()) + fmt.Printf(" %s Bundle applied ", utils.SuccessMark()) for _, f := range []string{doctor.AppConfigFile, doctor.AppSecretFile} { path := filepath.Join(bundleDir, f) @@ -326,15 +285,27 @@ to the cluster, and patch the CR to trigger a rolling deploy. } } - resolvedValues := values - if resolvedValues == "" { + var helmValues []string + if values != "" { + helmValues = append(helmValues, values) + } else { localValues := filepath.Join(dir, orkDir, "values.yaml") if fileExistsAtPath(localValues) { - resolvedValues = localValues + helmValues = append(helmValues, localValues) fmt.Printf(" Using values: %s\n", localValues) } } + // If the developer set controlCenterHost in app.yaml, enable CC ingress. + appData, _ := doctor.ReadAppYAMLData(filepath.Join(dir, orkDir, doctor.ApplicationFile)) + if ccHost := appData["controlCenterHost"]; ccHost != "" { + if ccValuesFile, err := doctor.BuildControlCenterValues(ccHost); err == nil { + defer os.Remove(ccValuesFile) + helmValues = append(helmValues, ccValuesFile) + fmt.Printf(" Control Center ingress: %s\n", ccHost) + } + } + repoAdd := exec.Command("helm", "repo", "add", doctor.Orkestra, doctor.OrkestraChartRepo) repoAdd.Stdout = os.Stdout @@ -353,7 +324,7 @@ to the cluster, and patch the CR to trigger a rolling deploy. if !doctor.OrkestraInstalled() || upgradeOrkestra { fmt.Println(" ⠸ Installing Orkestra...") if err := doctor.InstallOrUpgradeOrkestra( - orkestraVersion, resolvedValues, upgradeOrkestra, + orkestraVersion, helmValues, upgradeOrkestra, ); err != nil { return err } @@ -377,7 +348,8 @@ to the cluster, and patch the CR to trigger a rolling deploy. return fmt.Errorf("orkestra runtime is not healthy") } - if doctor.KatalogChanged(dir) { + deployDir, _ := doctor.StateDir() + if doctor.CentralKatalogChanged(state, deployDir) { fmt.Println(" Katalog changed — restarting Orkestra runtime") if err := doctor.RestartOrkestra(); err != nil { return fmt.Errorf("restarting Orkestra: %w", err) @@ -386,7 +358,7 @@ to the cluster, and patch the CR to trigger a rolling deploy. fmt.Println(" Katalog unchanged — Orkestra restart not required") } - state.RecordDeploy(appName, ns, absKatalogPath, image) + state.RecordDeploy(appName, ns, filepath.Join(deployDir, "katalog.yaml"), image) if err := state.Save(); err != nil { fmt.Printf(" ~ State save failed: %v\n", err) } @@ -630,11 +602,13 @@ func deployMultiApp(dc deployContext) error { fmt.Printf(" %s All bundles applied", utils.SuccessMark()) // Install Orkestra once - resolvedValues := dc.values - if resolvedValues == "" { + var helmValues []string + if dc.values != "" { + helmValues = append(helmValues, dc.values) + } else { localValues := filepath.Join(dc.dir, orkDir, "values.yaml") if fileExistsAtPath(localValues) { - resolvedValues = localValues + helmValues = append(helmValues, localValues) } } @@ -645,7 +619,7 @@ func deployMultiApp(dc deployContext) error { if !doctor.OrkestraInstalled() || dc.upgradeOrkestra { fmt.Println(" ⠸ Installing Orkestra...") - if err := doctor.InstallOrUpgradeOrkestra(dc.orkestraVersion, resolvedValues, dc.upgradeOrkestra); err != nil { + if err := doctor.InstallOrUpgradeOrkestra(dc.orkestraVersion, helmValues, dc.upgradeOrkestra); err != nil { return err } fmt.Printf(" %s Orkestra installed", utils.SuccessMark()) @@ -891,16 +865,47 @@ func init() { // appName is the bare project name (without -orkestra suffix) used for state // lookup. state may be nil (e.g. when called from rollbackCmd). func watchUntilReady(crName, ns, appName string, state *doctor.DeployState) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() + // Total budget: 2 min waiting for Orkestra to create the Deployment + + // 5 min for the rollout itself. Orkestra's default resync is 30 s so a + // new app can take 30–60 s before its Deployment object appears. + const deploymentAppearTimeout = 2 * time.Minute + const rolloutTimeout = 5 * time.Minute spin := spinner.Start(" → Waiting for rollout...") - cmd := exec.CommandContext(ctx, + // Phase 1: poll until the Deployment exists. kubectl rollout status exits + // immediately with a non-zero code when the object is missing, so we must + // not call it before Orkestra has reconciled the ConfigMap CR. + appearCtx, appearCancel := context.WithTimeout(context.Background(), deploymentAppearTimeout) + defer appearCancel() + + for { + check := exec.CommandContext(appearCtx, + "kubectl", "get", "deployment", crName, "-n", ns) + check.Stdout = io.Discard + check.Stderr = io.Discard + if check.Run() == nil { + break // Deployment exists + } + if appearCtx.Err() != nil { + spin.Failure() + return fmt.Errorf( + "timed out waiting for Deployment %s to appear in %s — Orkestra may still be starting\n"+ + " check: kubectl get deployment %s -n %s", + crName, ns, crName, ns) + } + time.Sleep(5 * time.Second) + } + + // Phase 2: standard rollout watch now that the Deployment exists. + rollCtx, rollCancel := context.WithTimeout(context.Background(), rolloutTimeout) + defer rollCancel() + + cmd := exec.CommandContext(rollCtx, "kubectl", "rollout", "status", "deployment/"+crName, "-n", ns, - "--timeout=5m", + fmt.Sprintf("--timeout=%s", rolloutTimeout), ) cmd.Stdout = io.Discard cmd.Stderr = io.Discard @@ -908,31 +913,25 @@ func watchUntilReady(crName, ns, appName string, state *doctor.DeployState) erro if err := cmd.Run(); err != nil { spin.Failure() - // Fetch recent pod logs to help the developer diagnose the failure. logOut, logErr := exec.Command("kubectl", "logs", - "deployment/"+crName, "-n", ns, - "--tail=20", - ).CombinedOutput() + "deployment/"+crName, "-n", ns, "--tail=20").CombinedOutput() if logErr == nil && len(bytes.TrimSpace(logOut)) > 0 { fmt.Printf("\n--- %s logs (last 20 lines) ---\n%s\n---\n", crName, string(logOut)) } - // Rollback hint — only when this is not the first deploy. if state != nil && state.PreviousImage(appName) != "" { fmt.Printf("\n A previous good image is available.\n") fmt.Printf(" Roll back with: ork deploy rollback\n") } - if ctx.Err() == context.DeadlineExceeded { + if rollCtx.Err() == context.DeadlineExceeded { return fmt.Errorf("timed out waiting for %s — check: kubectl get pods -n %s", crName, ns) } - return fmt.Errorf("rollout failed for %s — check: kubectl describe deployment %s -n %s", crName, crName, ns) } spin.Success() - printReadySummary(crName, ns, state) return nil } @@ -1030,6 +1029,135 @@ func fileExistsAtPath(path string) bool { return err == nil && !info.IsDir() } +// devPathArgs bundles parameters for deployDeveloperPath. +type devPathArgs struct { + dir string + appName string + ns string + image string + port string + language string + bundleDir string + secrets []orktypes.EnvVar + config []orktypes.EnvVar + dryRun bool + opts doctor.GenerateOptions +} + +// deployDeveloperPath implements the developer deploy flow: +// 1. Verify .orkestra/motif.yaml exists +// 2. Collect all deployed app namespaces (current + previously deployed) for allowedNamespaces +// 3. Write ~/.orkestra/deploy/katalog.yaml with ONE platform CRD — resources +// from motif.yaml embedded directly in operatorBox.onReconcile (no file imports) +// 4. Generate bundle (RBAC + ConfigMap) from the self-contained central katalog +func deployDeveloperPath(a devPathArgs) error { + motifPath := filepath.Join(a.dir, orkDir, "motif.yaml") + if !fileExistsAtPath(motifPath) { + return fmt.Errorf(".orkestra/motif.yaml not found — run 'ork doctor init --name %s' first", a.appName) + } + + fmt.Println("\nUsing developer path...") + + deployDir, err := doctor.StateDir() + if err != nil { + return fmt.Errorf("resolving deploy dir: %w", err) + } + + if !a.dryRun { + if err := os.MkdirAll(deployDir, 0o755); err != nil { + return fmt.Errorf("creating deploy dir: %w", err) + } + + // Build the apps list: current app first, then all previously deployed apps. + // We resolve metadata (name, namespace) here — the Katalog gets concrete resource + // entries per app so Orkestra manages them independently without cross-app collision. + apps := []doctor.AppDeployInfo{{ + Name: a.appName, + Namespace: a.ns, + Port: a.port, + Language: a.language, + Image: a.image, + }} + state, _ := doctor.LoadState() + if state != nil { + for name, p := range state.Projects { + if name != a.appName && p.Namespace != "" { + apps = append(apps, doctor.AppDeployInfo{ + Name: name, + Namespace: p.Namespace, + Port: p.Port, + Language: p.Language, + Image: p.CurrentImage, + }) + } + } + } + + // Generate the central katalog with per-app concrete resources. + if err := doctor.GenerateDeveloperKatalog(deployDir, motifPath, apps, a.opts); err != nil { + return fmt.Errorf("generating developer katalog: %w", err) + } + fmt.Printf(" %s Developer katalog updated\n", utils.SuccessMark()) + + // Persist port/language to state for future re-deploys. + if state != nil { + if p := state.Projects[a.appName]; p != nil { + p.Port = a.port + p.Language = a.language + } else { + state.Projects[a.appName] = &doctor.ProjectState{ + Name: a.appName, + Namespace: a.ns, + Port: a.port, + Language: a.language, + } + } + // state.Save() is called by the caller after RecordDeploy — no double save needed. + } + + // Validate the generated katalog. + validateCmd := exec.Command("ork", "validate", "-f", filepath.Join(deployDir, "katalog.yaml")) + if out, err := validateCmd.CombinedOutput(); err != nil { + fmt.Printf(" ~ katalog validation warning: %s\n", strings.TrimSpace(string(out))) + } + + // Ensure each app's namespace exists. Namespaces are infrastructure — + // we create them directly via kubectl (idempotent, no bundle dependency). + for _, app := range apps { + nsYAML := fmt.Sprintf("apiVersion: v1\nkind: Namespace\nmetadata:\n name: %s\n", app.Namespace) + nsCmd := exec.Command("kubectl", "apply", "-f", "-") + nsCmd.Stdin = strings.NewReader(nsYAML) + nsCmd.Stdout = os.Stdout + nsCmd.Stderr = os.Stderr + if err := nsCmd.Run(); err != nil { + fmt.Printf(" ~ could not ensure namespace %s: %v\n", app.Namespace, err) + } + } + + // Generate env Secret/ConfigMap for the current app. + if err := os.MkdirAll(a.bundleDir, 0o755); err != nil { + return fmt.Errorf("creating bundle dir: %w", err) + } + if err := doctor.GenerateBundle(a.appName, a.ns, a.secrets, a.config, a.bundleDir); err != nil { + return fmt.Errorf("generating env bundle: %w", err) + } + + // Generate the RBAC + Katalog ConfigMap bundle from the central katalog. + centralKatalogPath := filepath.Join(deployDir, "katalog.yaml") + genCmd := exec.Command("ork", "generate", "bundle", "-f", centralKatalogPath, "-w", a.ns, "-o", a.bundleDir) + genCmd.Stdout = os.Stdout + genCmd.Stderr = os.Stderr + if err := genCmd.Run(); err != nil { + return fmt.Errorf("generating bundle: %w", err) + } + fmt.Printf(" %s RBAC + Katalog ConfigMap\n", utils.SuccessMark()) + } else { + fmt.Printf(" ~ dry-run: would generate %s/katalog.yaml and bundle\n", deployDir) + } + + return nil +} + // exposeApp starts a tunnel for the given app and prints the public URL. // It port-forwards to the app's K8s service (-orkestra-svc) so the // tunnel survives after the deploy command exits regardless of ingress setup. diff --git a/cmd/cli/doctor.go b/cmd/cli/doctor.go index fb1e1bb4..806c0997 100644 --- a/cmd/cli/doctor.go +++ b/cmd/cli/doctor.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/orkspace/orkestra/pkg/buildx" "github.com/orkspace/orkestra/pkg/doctor" @@ -350,7 +351,7 @@ var doctorInitCmd = &cobra.Command{ return fmt.Errorf("detection failed: %w", err) } - if err := doctor.Init(info, opts); err != nil { + if err := doctor.InitDeveloper(info, opts); err != nil { return err } @@ -359,23 +360,16 @@ var doctorInitCmd = &cobra.Command{ return fmt.Errorf("writing init config: %v", err) } - crName := name + "-orkestra" - ns := name + "-orkestra-ns" - fmt.Println() fmt.Printf("App: %s\n", name) - fmt.Printf("AppConfig: %s\n", crName) - fmt.Printf("Namespace: %s\n", ns) + fmt.Printf("Namespace: %s-ns\n", name) fmt.Println() - fmt.Println("Generated .orkestra/katalog.yaml") + fmt.Println("Generated .orkestra/motif.yaml") fmt.Println("Generated .orkestra/app.yaml") - if addIngress && !info.HasFrontend { - fmt.Println(" (Ingress included via --add-ingress)") - } fmt.Println() fmt.Println("Next steps:") - fmt.Println(" 1. Review .orkestra/katalog.yaml (edit freely)") - fmt.Println(" 2. Fill in .orkestra/app.yaml (replicas, host, controlCenterHost, etc.)") + fmt.Println(" 1. Review .orkestra/motif.yaml (resource template — edit freely)") + fmt.Println(" 2. Fill in .orkestra/app.yaml (port, replicas, etc.)") fmt.Println(" 3. Run 'ork deploy --registry '") return nil @@ -580,6 +574,100 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate return nil } +// doctorDeployCmd is `ork doctor deploy` — the developer-path deploy command. +// It runs the same flow as `ork deploy` but is discoverable as a subcommand of +// `ork doctor`, making it natural for developers who start with `ork doctor init`. +var doctorDeployCmd = &cobra.Command{ + Use: "deploy", + Short: "Build, push, and deploy the current project (developer path)", + Long: `Build the Docker image, push it, write the central developer katalog, +generate the bundle, apply it to the cluster, and patch the ConfigMap CR. + + ork doctor deploy --registry docker.io/myorg + ork doctor deploy --registry docker.io/myorg --dev + ork doctor deploy --registry docker.io/myorg --dry-run`, + RunE: deployCmd.RunE, +} + +// doctorStatusCmd is `ork doctor status` — prints the current deploy state. +var doctorStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show current deploy state (cluster, katalog, apps)", + Long: `Read ~/.orkestra/deploy/state.json and print a concise summary of +what is deployed, which cluster it targets, and whether the central katalog +has changed since the last deploy. + + ork doctor status`, + RunE: func(cmd *cobra.Command, args []string) error { + state, err := doctor.LoadState() + if err != nil { + return fmt.Errorf("reading state: %w", err) + } + + deployDir, err := doctor.StateDir() + if err != nil { + return fmt.Errorf("resolving deploy dir: %w", err) + } + + // Cluster context + clusterCtx := state.ClusterContext + if clusterCtx == "" { + clusterCtx = doctor.CurrentContext() + } + fmt.Printf("Cluster: %s\n", clusterCtx) + + // Katalog path + changed/unchanged status + katalogPath := filepath.Join(deployDir, "katalog.yaml") + katalogChanged := doctor.CentralKatalogChanged(state, deployDir) + changeLabel := "unchanged" + if katalogChanged { + changeLabel = "changed" + } + home, _ := os.UserHomeDir() + displayPath := katalogPath + if home != "" { + displayPath = strings.Replace(katalogPath, home, "~", 1) + } + fmt.Printf("Katalog: %s (%s)\n", displayPath, changeLabel) + + // Apps + fmt.Printf("\nApps (%d):\n", len(state.Projects)) + for _, name := range state.DeployedAppNames() { + p := state.Projects[name] + relTime := relativeTime(p.DeployedAt) + fmt.Printf(" %-20s %-25s %-50s deployed %s\n", + p.Name, p.Namespace, p.CurrentImage, relTime) + } + + // Orkestra runtime health + fmt.Println() + health := doctor.CheckRuntimeHealth() + if health.Running { + fmt.Println("Orkestra: running") + } else { + fmt.Printf("Orkestra: not healthy (%s)\n", health.Reason) + } + + return nil + }, +} + +// relativeTime formats a time.Time as a human-friendly relative string ("2h ago", "5m ago"). +func relativeTime(t time.Time) string { + if t.IsZero() { + return "unknown" + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + default: + return fmt.Sprintf("%dh ago", int(d.Hours())) + } +} + func init() { doctorCmd.PersistentFlags().Bool("no-ha", false, "Skip HPA and PDB (single replica)") doctorCmd.PersistentFlags().Bool("no-secure", false, "Skip deletion protection and protection labels") @@ -593,7 +681,22 @@ func init() { doctorInitCmd.Flags().Bool("notify-me", false, "Auto-enable notifications using SMTP_*/SLACK_* from .env and your Git author") doctorInitCmd.Flags().String("use-compose", "", "Path to docker-compose.yaml — deploy all buildable services as separate apps") + doctorDeployCmd.Flags().StringP("registry", "r", "", "Container registry (e.g. docker.io/myorg)") + doctorDeployCmd.Flags().StringP("tag", "t", "", "Image tag (default: git commit SHA)") + doctorDeployCmd.Flags().String("name", "", "App name override (default: read from .orkestra/app.yaml)") + doctorDeployCmd.Flags().Bool("dry-run", false, "Show what would be applied without making changes") + doctorDeployCmd.Flags().Bool("upgrade-orkestra", false, "Upgrade Orkestra to latest version before deployment") + doctorDeployCmd.Flags().String("orkestra-version", "", "Version of Orkestra operator to install") + doctorDeployCmd.Flags().String("values", "", "Path to Helm values.yaml for Orkestra installation") + doctorDeployCmd.Flags().Bool("dev", false, "Create a local kind cluster (orkestra-playground) for development") + doctorDeployCmd.Flags().Bool("enable-metrics", false, "Install metrics server to the cluster") + doctorDeployCmd.Flags().Bool("expose", false, "Expose the app via a public HTTPS tunnel") + doctorDeployCmd.Flags().String("tunnel-provider", "", "Tunnel provider: cloudflared (default) or ngrok") + doctorDeployCmd.Flags().String("tunnel-token", "", "Auth token for ngrok tunnels") + doctorCmd.AddCommand(doctorInitCmd) + doctorCmd.AddCommand(doctorDeployCmd) + doctorCmd.AddCommand(doctorStatusCmd) rootCmd.AddCommand(doctorCmd) // Shadow global flags so they don't appear under `ork init` diff --git a/cmd/controlcenter/cc/assets/templates/dev_apps.html b/cmd/controlcenter/cc/assets/templates/dev_apps.html new file mode 100644 index 00000000..87d579f3 --- /dev/null +++ b/cmd/controlcenter/cc/assets/templates/dev_apps.html @@ -0,0 +1,148 @@ + + + + + + Your Applications – Orkestra + + + + + + + + +
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+
+
+

Your Applications

+ + + managed by Orkestra + +
+

+ {{ len .Apps }} app{{ if ne (len .Apps) 1 }}s{{ end }} deployed via ork doctor deploy +

+
+
+
+ + {{ if eq (len .Apps) 0 }} +
+ + + +
+
No applications deployed yet
+
Run ork doctor deploy --registry <your-registry> to deploy your first app.
+
+
+ {{ else }} + + +
+ {{ range .Apps }} +
+ +
+

{{ .Name }}

+ {{ if .Language }} + {{ .Language }} + {{ end }} +
+ + +
+ + {{ .Namespace }} +
+ + + {{ if .ImageTag }} +
+ + :{{ .ImageTag }} +
+ {{ end }} + + + {{ if .ServiceURL }} +
+
Internal URL
+ {{ .ServiceURL }} +
+ {{ end }} + + +
+ Redeploy: ork doctor deploy --registry <registry> +
+
+ {{ end }} +
+ {{ end }} + +
+
+ + + + diff --git a/cmd/controlcenter/cc/controlcenter.go b/cmd/controlcenter/cc/controlcenter.go index bc2837cc..9ef91b9e 100644 --- a/cmd/controlcenter/cc/controlcenter.go +++ b/cmd/controlcenter/cc/controlcenter.go @@ -575,6 +575,12 @@ func (cc *ControlCenter) handleKatalogPanel(w http.ResponseWriter, r *http.Reque kat := inst.Katalog + // Developer path: render a simplified app-focused view. + if kat.CreatedBy == "orkdoctor" { + cc.renderDevApps(w, r, kat) + return + } + // Sort CRDs by name for consistent display sortedCRDs := make([]CRDSummary, len(kat.CRDs)) copy(sortedCRDs, kat.CRDs) @@ -602,6 +608,42 @@ func (cc *ControlCenter) handleKatalogPanel(w http.ResponseWriter, r *http.Reque }) } +// renderDevApps renders the developer-path view for a katalog created by ork doctor. +func (cc *ControlCenter) renderDevApps(w http.ResponseWriter, _ *http.Request, kat *KatalogResponse) { + var apps []DevAppSummary + for _, proj := range kat.Projects { + imageTag := proj.CurrentImage + if idx := strings.LastIndex(imageTag, ":"); idx >= 0 { + imageTag = imageTag[idx+1:] + } + port := proj.Port + if port == "" { + port = "8080" + } + svcURL := "" + if proj.Name != "" && proj.Namespace != "" { + svcURL = fmt.Sprintf("http://%s-svc.%s.svc.cluster.local:%s", proj.Name, proj.Namespace, port) + } + apps = append(apps, DevAppSummary{ + Name: proj.Name, + Namespace: proj.Namespace, + Port: port, + Language: proj.Language, + CurrentImage: proj.CurrentImage, + ImageTag: imageTag, + ServiceURL: svcURL, + }) + } + // Sort apps by name for stable output. + sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name }) + + cc.renderTemplate(w, "dev_apps.html", DevAppsData{ + KatalogName: kat.Name, + Apps: apps, + RuntimeVersion: kat.RuntimeVersion, + }) +} + func (cc *ControlCenter) handleCRDDetail(w http.ResponseWriter, r *http.Request, katalogName, crdName string) { inst, ok := cc.instanceByKatalogName(katalogName) if !ok { diff --git a/cmd/controlcenter/cc/types.go b/cmd/controlcenter/cc/types.go index 662bacc7..b0433617 100644 --- a/cmd/controlcenter/cc/types.go +++ b/cmd/controlcenter/cc/types.go @@ -83,21 +83,51 @@ type RBACRule struct { // KatalogResponse is the response from the /katalog endpoint type KatalogResponse struct { - Total int `json:"total"` - TotalEnabled int `json:"totalEnabled"` - Healthy bool `json:"healthy"` - Status int `json:"status"` - OrkReady bool `json:"OrkReady"` - DeletionProtection bool `json:"deletionProtection"` - CRDs []CRDSummary `json:"crds"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - Author string `json:"author,omitempty"` - Description string `json:"description,omitempty"` - DegradedReason string `json:"degradedReason,omitempty"` - StatusCounts StatusCounts `json:"statusCounts"` - License string `json:"license,omitempty"` - RuntimeVersion string `json:"runtimeVersion,omitempty"` + Total int `json:"total"` + TotalEnabled int `json:"totalEnabled"` + Healthy bool `json:"healthy"` + Status int `json:"status"` + OrkReady bool `json:"OrkReady"` + DeletionProtection bool `json:"deletionProtection"` + CRDs []CRDSummary `json:"crds"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + DegradedReason string `json:"degradedReason,omitempty"` + StatusCounts StatusCounts `json:"statusCounts"` + License string `json:"license,omitempty"` + RuntimeVersion string `json:"runtimeVersion,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + Projects map[string]ProjectInfoSummary `json:"projects,omitempty"` +} + +// ProjectInfoSummary is a lightweight per-app summary carried in KatalogResponse.Projects. +type ProjectInfoSummary struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Port string `json:"port,omitempty"` + Language string `json:"language,omitempty"` + CurrentImage string `json:"currentImage,omitempty"` +} + +// DevAppsData is passed to the developer-view template. +type DevAppsData struct { + KatalogName string + Apps []DevAppSummary + CCVersion string + RuntimeVersion string +} + +// DevAppSummary holds display data for one app in the developer view. +type DevAppSummary struct { + Name string + Namespace string + Port string + Language string + CurrentImage string + ImageTag string // last part after ":" + ServiceURL string // internal cluster URL } // CRDSummary is a summary of a CRD diff --git a/docs/design-documents/one-katalog-developer-path.md b/docs/design-documents/one-katalog-developer-path.md new file mode 100644 index 00000000..dd1d6300 --- /dev/null +++ b/docs/design-documents/one-katalog-developer-path.md @@ -0,0 +1,430 @@ +# One Katalog — The Developer Path + +*Orkestra Project — May 2026* + +--- + +## The model + +One Katalog. One CRD entry (ConfigMap). One operatorBox. + +Each application contributes its resources — Deployment, Service, HPA, PDB, +ServiceAccount, Role, RoleBinding — as concrete entries inside the single +`onReconcile` block. Each app has its own ConfigMap CR in its own namespace. +The one CRD entry watches all registered app namespaces. + +``` +~/.orkestra/deploy/ + state.json ← deployed apps, images, namespaces, katalog hash + katalog.yaml ← one Katalog, one CRD, all app resources inline + +app/.orkestra/ + motif.yaml ← app's resource template (has {{ .metadata.* }} etc.) + app.yaml ← app's ConfigMap CR in app-ns + +frontend/.orkestra/ + motif.yaml ← frontend's template + app.yaml ← frontend's ConfigMap CR in frontend-ns +``` + +There is no `motifs/` directory. The central Katalog carries all resolved +resources directly in `onReconcile`. No imports. No file references. + +--- + +## Why inline — not imports + +Orkestra bundles the Katalog as a ConfigMap in the cluster. File `imports:` +are resolved at parse time on the host, not at runtime inside the cluster. +A Katalog that imports local paths cannot survive a restart because the +cluster-side runtime has no access to `~/.orkestra/`. Resources are therefore +resolved and embedded verbatim when `ork doctor deploy` writes the Katalog. + +--- + +## The central Katalog + +```yaml +# ~/.orkestra/deploy/katalog.yaml +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: orkestra-developer + createdBy: orkdoctor + projects: + app: + language: python + port: "8080" + namespace: app-ns + currentImage: docker.io/myorg/app:abc123 + frontend: + language: node + port: "3000" + namespace: frontend-ns + currentImage: docker.io/myorg/frontend:def456 + +security: + deletionProtection: + enabled: true + +spec: + crds: + platform: + apiTypes: + kind: ConfigMap + labelSelector: + ork.io/platform: developer + allowedNamespaces: ["app-ns", "frontend-ns"] + + operatorBox: + onReconcile: + serviceAccounts: + - name: "app" + namespace: "app-ns" + reconcile: true + - name: "frontend" + namespace: "frontend-ns" + reconcile: true + + roles: + - name: "app" + namespace: "app-ns" + rules: + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["app"] + verbs: ["get", "watch"] + reconcile: true + - name: "frontend" + namespace: "frontend-ns" + rules: + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["frontend"] + verbs: ["get", "watch"] + reconcile: true + + roleBindings: + - name: "app" + namespace: "app-ns" + roleRef: "app" + subjects: + - kind: ServiceAccount + name: "app" + namespace: "app-ns" + reconcile: true + - name: "frontend" + namespace: "frontend-ns" + roleRef: "frontend" + subjects: + - kind: ServiceAccount + name: "frontend" + namespace: "frontend-ns" + reconcile: true + + deployments: + - name: "app" + namespace: "app-ns" + image: "docker.io/myorg/app:abc123" + resourceProfile: "burst" + port: "8080" + serviceAccountName: "app" + envFrom: + - secretRef: "app-secrets" + - configMapRef: "app-config" + reconcile: true + - name: "frontend" + namespace: "frontend-ns" + image: "docker.io/myorg/frontend:def456" + resourceProfile: "small" + port: "3000" + serviceAccountName: "frontend" + reconcile: true + + services: + - name: "app-svc" + namespace: "app-ns" + port: "8080" + reconcile: true + - name: "frontend-svc" + namespace: "frontend-ns" + port: "3000" + reconcile: true + + hpas: + - name: "app-hpa" + namespace: "app-ns" + scaleTargetRef: + name: "app" + minReplicas: "2" + maxReplicas: "10" + reconcile: true + - name: "frontend-hpa" + namespace: "frontend-ns" + scaleTargetRef: + name: "frontend" + minReplicas: "1" + maxReplicas: "5" + reconcile: true + + pdbs: + - name: "app-pdb" + namespace: "app-ns" + minAvailable: "1" + reconcile: true + - name: "frontend-pdb" + namespace: "frontend-ns" + minAvailable: "1" + reconcile: true +``` + +One CRD. One `onReconcile` block. Each app's resources are adjacent entries. +`allowedNamespaces` grows as apps are added. `metadata.projects` carries +per-app display data for the Control Center. + +--- + +## Each app has its own ConfigMap CR + +```yaml +# app/.orkestra/app.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: app + namespace: app-ns + labels: + ork.io/platform: developer # watched by the CRD entry +``` + +```yaml +# frontend/.orkestra/app.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend + namespace: frontend-ns + labels: + ork.io/platform: developer +``` + +The ConfigMap is the trigger. When `ork doctor deploy` applies it, Orkestra +reconciles every resource in `onReconcile` whose name matches the ConfigMap's +`metadata.name`. Because `allowedNamespaces` scopes reconciliation, the `app` +ConfigMap only creates resources in `app-ns` and the `frontend` ConfigMap only +creates resources in `frontend-ns`. + +--- + +## How the reconciler works + +The one CRD entry watches all ConfigMaps labelled `ork.io/platform: developer` +across all `allowedNamespaces`. When the `app` ConfigMap is applied, the +reconciler fires for `app`. When the `frontend` ConfigMap is applied, the +reconciler fires for `frontend`. Each fires independently. + +Because all resources in `onReconcile` carry concrete values (no `{{ }}`), the +reconciler applies them directly. No template evaluation at runtime. The Katalog +is plain YAML. + +--- + +## Namespace management — outside the Katalog + +Namespaces are cluster-scoped. They cannot be owned by a namespaced +ConfigMap, so Kubernetes garbage collection never deletes them. They are +not inside the `onReconcile` block. + +`ork doctor deploy` creates namespaces directly with `kubectl`: + +```go +kubectl apply -f - <.port` and `projects..namespace`. +No API call needed — the data is in the Katalog response. + +**Route:** `/katalog/` → branches on `createdBy == "orkdoctor"` +→ renders `dev_apps.html` instead of the standard `katalog.html`. + +--- + +## --notify-me + +```bash +ork doctor init --notify-me +``` + +Adds a `notification:` block to the central Katalog using SMTP or Slack +credentials detected in `.env`. The developer's git `user.email` is the +default recipient. Notifications are a Katalog-level concern — not per-app. + +--- + +## What is not here + +- No Komposer. Deployment tracking uses `state.json` directly. +- No `motifs/` directory. Resolved resources are embedded in the Katalog. +- No status fields on the ConfigMap CR. Orkestra writes no status. +- No `{{ }}` in the written Katalog. All expressions are resolved before write. +- No custom CRD. The platform CRD is a standard Kubernetes ConfigMap. +- No imports in the Katalog. Import paths do not survive cluster-side restart. diff --git a/pkg/autoscaler/autoscale_semaphore.go b/pkg/autoscaler/autoscale_semaphore.go index b67e69cb..f68f6dad 100644 --- a/pkg/autoscaler/autoscale_semaphore.go +++ b/pkg/autoscaler/autoscale_semaphore.go @@ -163,5 +163,9 @@ func (s *ResizableSemaphore) IdlePercent() float64 { if s.cap == 0 { return 0 } - return 100 - s.BusyPercent() + idle := s.cap - s.current + if idle < 0 { + idle = 0 + } + return float64(idle) / float64(s.cap) * 100 } diff --git a/pkg/doctor/generate.go b/pkg/doctor/generate.go index 9b3f0493..aae2e0d6 100644 --- a/pkg/doctor/generate.go +++ b/pkg/doctor/generate.go @@ -113,6 +113,106 @@ func Init(info *orktypes.ProjectInfo, opts GenerateOptions) error { return nil } +// InitDeveloper generates the developer-path artifacts for a project: +// - .orkestra/motif.yaml — fully-templated resource blueprint +// - .orkestra/app.yaml — developer-style ConfigMap CR (ork.io/platform: developer) +// +// Unlike Init (operator path), no katalog.yaml is generated here. The central +// Katalog at ~/.orkestra/deploy/katalog.yaml is maintained by ork deploy as apps +// are registered. +func InitDeveloper(info *orktypes.ProjectInfo, opts GenerateOptions) error { + name := opts.Name + if name == "" { + name = info.Name + } + + orkDir := opts.OutDir + if orkDir == "" { + orkDir = filepath.Join(info.Dir, ".orkestra") + } + if err := os.MkdirAll(orkDir, 0o755); err != nil { + return fmt.Errorf("creating %s/: %w", orkDir, err) + } + + // motif.yaml — the app's resource template + motifContent := buildAppMotifTemplate(info, opts) + if err := os.WriteFile(filepath.Join(orkDir, "motif.yaml"), []byte(motifContent), 0o644); err != nil { + return fmt.Errorf("writing motif.yaml: %w", err) + } + + // app.yaml — developer-style ConfigMap (no -orkestra suffix, ork.io/platform label) + crContent := buildDeveloperCR(name, info, opts) + if err := os.WriteFile(filepath.Join(orkDir, ApplicationFile), []byte(crContent), 0o644); err != nil { + return fmt.Errorf("writing app.yaml: %w", err) + } + + if err := updateGitignore(info.Dir); err != nil { + return fmt.Errorf("updating .gitignore: %w", err) + } + if err := updateDockerignore(info.Dir); err != nil { + return fmt.Errorf("updating .dockerignore: %w", err) + } + + return nil +} + +// buildDeveloperCR builds the developer-style ConfigMap CR. Labels include +// ork.io/platform: developer (CRD watch selector) and ork.io/app: +// so individual apps can be identified. Image is left empty — ork deploy patches it. +func buildDeveloperCR(name string, info *orktypes.ProjectInfo, opts GenerateOptions) string { + replicas := "2" + if opts.NoHA { + replicas = "1" + } + + var b strings.Builder + b.WriteString("# This is the only Kubernetes object you manage.\n") + b.WriteString("# Run 'ork doctor deploy' to apply — do not apply this file manually.\n\n") + + b.WriteString("apiVersion: v1\n") + b.WriteString("kind: ConfigMap\n") + b.WriteString("metadata:\n") + b.WriteString(" name: " + name + "\n") + b.WriteString(" namespace: " + name + "-ns\n") + b.WriteString(" labels:\n") + b.WriteString(" ork.io/platform: developer\n") + b.WriteString(" ork.io/app: " + name + "\n") + if !opts.NoSecure { + b.WriteString(" " + deletionProtectionLabel + ": \"true\"\n") + b.WriteString(" " + createdBy + ": " + orkdoctor + "\n") + } + + b.WriteString("data:\n") + b.WriteString(" # ork deploy updates this automatically — do not edit\n") + b.WriteString(" image: \"\"\n") + b.WriteString(" # ───────────────────────────────────────────────────────────────\n\n") + + b.WriteString(" # Application port\n") + fmt.Fprintf(&b, " port: \"%s\"\n\n", info.Port) + + b.WriteString(" # How many copies of your app to run normally\n") + fmt.Fprintf(&b, " replicas: \"%s\"\n\n", replicas) + + b.WriteString(" # How much CPU and memory your app should get. Choose a profile:\n") + b.WriteString(" # docs.orkestra.sh/concepts/resource-profiles\n") + b.WriteString(" resourceProfile: \"burst\"\n\n") + + if !opts.NoHA { + b.WriteString(" # Maximum copies of your app to run when traffic increases\n") + b.WriteString(" maxReplicas: \"10\"\n\n") + } + + if info.HasFrontend || opts.AddIngress { + b.WriteString(" # This app's public hostname (e.g. myapp.example.com)\n") + b.WriteString(" host: \"\"\n\n") + } + + b.WriteString(" # Orkestra Control Center hostname (e.g. control.mycompany.com)\n") + b.WriteString(" controlCenterHost: \"\"\n\n") + + return b.String() +} + // injectStatefulServices appends Motif import blocks for each stateful service // to the generated Katalog YAML string. func injectStatefulServices(katalogYAML, appName string, services []StatefulService) string { @@ -167,12 +267,11 @@ func injectStatefulAppYAML(appYAML string, services []StatefulService, info *ork author, _ := LastCommitAuthor() if author == nil { - author = &GitAuthor{} - } - - if author.Notfound { - author.Email = "dev@orkestra.sh" - author.Name = "admin" + author = &GitAuthor{ + Notfound: true, + Email: "dev@orkestra.sh", + Name: "admin", + } } appUser := truncate(info.Name+"_"+"user", 15) @@ -282,6 +381,13 @@ func buildKatalog(name string, info *orktypes.ProjectInfo, opts GenerateOptions) if notifyME { author, _ := LastCommitAuthor() + if author == nil { + author = &GitAuthor{ + Notfound: true, + Email: "dev@orkestra.sh", + Name: "admin", + } + } b.WriteString("notification:\n") b.WriteString(" enabled: true\n") @@ -542,6 +648,323 @@ func buildCR(name string, info *orktypes.ProjectInfo, opts GenerateOptions) stri return b.String() } +// GenerateAppMotif writes .orkestra/motif.yaml — the app's resource template +// for the developer path. The file contains {{ .metadata.name }}, {{ .spec.image }} +// etc. that are fully resolved at ork deploy time via motif.ResolveAll. +// +// The developer Katalog at ~/.orkestra/deploy/katalog.yaml imports this file +// (after resolution) rather than per-app katalog.yaml files. No status fields +// are generated here — the Control Center reads resources directly from the cluster. +func GenerateAppMotif(dir string, info *orktypes.ProjectInfo, opts GenerateOptions) error { + orkDir := opts.OutDir + if orkDir == "" { + orkDir = filepath.Join(dir, ".orkestra") + } + if err := os.MkdirAll(orkDir, 0o755); err != nil { + return fmt.Errorf("creating %s/: %w", orkDir, err) + } + content := buildAppMotifTemplate(info, opts) + return os.WriteFile(filepath.Join(orkDir, "motif.yaml"), []byte(content), 0o644) +} + +// buildAppMotifTemplate returns a motif.yaml template string for the given app. +// Resources use {{ .metadata.* }} and {{ .data.* }} runtime expressions so that +// ONE platform CRD entry in the central katalog can serve all developer apps. +// Orkestra evaluates these expressions per-CR at reconcile time. +func buildAppMotifTemplate(info *orktypes.ProjectInfo, opts GenerateOptions) string { + name := info.Name + if info.AppName != "" { + name = info.AppName + } + + replicas := "2" + if opts.NoHA { + replicas = "1" + } + + var b strings.Builder + b.WriteString("# Generated by ork doctor init.\n") + b.WriteString("# Embedded verbatim into the central katalog by 'ork doctor deploy'.\n") + b.WriteString("# Runtime expressions ({{ .metadata.* }}, {{ .data.* }}) are evaluated\n") + b.WriteString("# by Orkestra per-CR — do not replace them with concrete values.\n") + b.WriteString("apiVersion: orkestra.orkspace.io/v1\n") + b.WriteString("kind: Motif\n") + b.WriteString("metadata:\n") + b.WriteString(" name: " + name + "\n\n") + + b.WriteString("resources:\n") + + b.WriteString(" serviceAccounts:\n") + b.WriteString(" - name: \"{{ .metadata.name }}\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" reconcile: true\n\n") + + b.WriteString(" roles:\n") + b.WriteString(" - name: \"{{ .metadata.name }}\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" reconcile: true\n") + b.WriteString(" rules:\n") + b.WriteString(" - apiGroups: [\"\"]\n") + b.WriteString(" resources: [\"configmaps\"]\n") + b.WriteString(" resourceNames: [\"{{ .metadata.name }}\"]\n") + b.WriteString(" verbs: [\"get\", \"watch\"]\n\n") + + b.WriteString(" roleBindings:\n") + b.WriteString(" - name: \"{{ .metadata.name }}\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" reconcile: true\n") + b.WriteString(" roleRef:\n") + b.WriteString(" name: \"{{ .metadata.name }}\"\n") + b.WriteString(" subjects:\n") + b.WriteString(" - kind: ServiceAccount\n") + b.WriteString(" name: \"{{ .metadata.name }}\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n\n") + + b.WriteString(" deployments:\n") + b.WriteString(" - name: \"{{ .metadata.name }}\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" image: \"{{ .data.image }}\"\n") + b.WriteString(" serviceAccountName: \"{{ .metadata.name }}\"\n") + fmt.Fprintf(&b, " replicas: \"{{ .data.replicas | default \\\"%s\\\" }}\"\n", replicas) + fmt.Fprintf(&b, " port: \"{{ .data.port | default \\\"%s\\\" }}\"\n", info.Port) + if info.HasCreds() { + b.WriteString(" envFrom:\n") + if info.HasSecrets() { + b.WriteString(" - secretRef: \"{{ .metadata.name }}-secrets\"\n") + } + if info.HasConfig() { + b.WriteString(" - configMapRef: \"{{ .metadata.name }}-config\"\n") + } + } + b.WriteString(" resourceProfile: \"{{ .data.resourceProfile | default \\\"burst\\\" }}\"\n") + b.WriteString(" reconcile: true\n") + b.WriteString(" when:\n") + b.WriteString(" - field: data.image\n") + b.WriteString(" exists: true\n\n") + + b.WriteString(" services:\n") + b.WriteString(" - name: \"{{ .metadata.name }}-svc\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + fmt.Fprintf(&b, " port: \"{{ .data.port | default \\\"%s\\\" }}\"\n", info.Port) + fmt.Fprintf(&b, " targetPort: \"{{ .data.port | default \\\"%s\\\" }}\"\n", info.Port) + b.WriteString(" reconcile: true\n\n") + + if !opts.NoHA { + b.WriteString(" hpa:\n") + b.WriteString(" - name: \"{{ .metadata.name }}-hpa\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" scaleTargetRef:\n") + b.WriteString(" apiVersion: apps/v1\n") + b.WriteString(" kind: Deployment\n") + b.WriteString(" name: \"{{ .metadata.name }}\"\n") + fmt.Fprintf(&b, " minReplicas: \"{{ .data.replicas | default \\\"%s\\\" }}\"\n", replicas) + b.WriteString(" maxReplicas: \"{{ .data.maxReplicas | default \\\"10\\\" }}\"\n") + b.WriteString(" targetCPUUtilizationPercentage: \"70\"\n") + b.WriteString(" reconcile: true\n") + b.WriteString(" when:\n") + b.WriteString(" - field: data.image\n") + b.WriteString(" exists: true\n\n") + + b.WriteString(" pdb:\n") + b.WriteString(" - name: \"{{ .metadata.name }}-pdb\"\n") + b.WriteString(" namespace: \"{{ .metadata.namespace }}\"\n") + b.WriteString(" minAvailable: \"1\"\n") + b.WriteString(" reconcile: true\n") + b.WriteString(" when:\n") + b.WriteString(" - field: data.image\n") + b.WriteString(" exists: true\n\n") + } + + return b.String() +} + +// ReadAppYAMLData reads the data block from .orkestra/app.yaml (a ConfigMap) +// and returns it as a flat string map. Returns an empty map when the file +// does not exist or has no data section. +func ReadAppYAMLData(appYAML string) (map[string]string, error) { + raw, err := os.ReadFile(appYAML) + if os.IsNotExist(err) { + return map[string]string{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", appYAML, err) + } + + var cm struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal(raw, &cm); err != nil { + return nil, fmt.Errorf("parsing %s: %w", appYAML, err) + } + if cm.Data == nil { + return map[string]string{}, nil + } + return cm.Data, nil +} + +// AppDeployInfo carries the per-app metadata needed to generate the central Katalog. +// Name and Namespace are always known at deploy time; they are resolved concretely +// into the Katalog so Orkestra creates independent resources for each app. +type AppDeployInfo struct { + Name string + Namespace string + Port string + Language string + Image string // current deployed image (may be empty on first deploy) +} + +// GenerateDeveloperKatalog writes ~/.orkestra/deploy/katalog.yaml — the single +// central Katalog for the developer path. +// +// It produces ONE 'platform' CRD entry that manages ALL deployed developer apps. +// The motif template resources are read from motifPath and, for each app in apps, +// the metadata placeholders ({{ .metadata.name }}, {{ .metadata.namespace }}) are +// substituted with the concrete appName/namespace known at deploy time. The data +// placeholders ({{ .data.image }}, {{ .data.port }}, etc.) are kept as-is so +// Orkestra evaluates them at runtime from the triggering ConfigMap CR. +// +// Resolving metadata up-front ensures each app gets its own independently managed +// set of resources — Orkestra reconciles each ConfigMap CR in its own namespace +// against the matching named resources in onReconcile, so apps never collide. +// +// No imports or file-path references are emitted — the bundle ConfigMap is +// fully self-contained and the runtime pod needs no filesystem access. +func GenerateDeveloperKatalog(deployDir, motifPath string, apps []AppDeployInfo, opts GenerateOptions) error { + if len(apps) == 0 { + return fmt.Errorf("at least one app is required") + } + + // ── Load motif resources block ────────────────────────────────────────── + motifRaw, err := os.ReadFile(motifPath) + if err != nil { + return fmt.Errorf("reading motif template %s: %w", motifPath, err) + } + var rawMotif map[string]interface{} + if err := yaml.Unmarshal(motifRaw, &rawMotif); err != nil { + return fmt.Errorf("parsing motif template: %w", err) + } + resourcesRaw, ok := rawMotif["resources"] + if !ok { + return fmt.Errorf("motif template has no 'resources' block") + } + + // ── Resolve metadata per-app and merge resource lists ─────────────────── + // For each app we substitute {{ .metadata.name }} and {{ .metadata.namespace }} + // with the concrete values we already know, then merge each resource type's + // list (serviceAccounts, deployments, services, …) across all apps. + merged := map[string][]interface{}{} + + for _, app := range apps { + // Marshal resources to a YAML string so we can do string substitution. + appYAML, err := yaml.Marshal(resourcesRaw) + if err != nil { + return fmt.Errorf("serialising motif resources for %s: %w", app.Name, err) + } + resolved := strings.ReplaceAll(string(appYAML), "{{ .metadata.name }}", app.Name) + resolved = strings.ReplaceAll(resolved, "{{ .metadata.namespace }}", app.Namespace) + + var appResources map[string]interface{} + if err := yaml.Unmarshal([]byte(resolved), &appResources); err != nil { + return fmt.Errorf("parsing resolved resources for %s: %w", app.Name, err) + } + for key, val := range appResources { + items, ok := val.([]interface{}) + if !ok { + continue + } + merged[key] = append(merged[key], items...) + } + } + + // ── Deduplicate + sort namespaces ─────────────────────────────────────── + seen := map[string]bool{} + var uniqueNS []string + for _, app := range apps { + if app.Namespace != "" && !seen[app.Namespace] { + seen[app.Namespace] = true + uniqueNS = append(uniqueNS, app.Namespace) + } + } + for i := 0; i < len(uniqueNS); i++ { + for j := i + 1; j < len(uniqueNS); j++ { + if uniqueNS[i] > uniqueNS[j] { + uniqueNS[i], uniqueNS[j] = uniqueNS[j], uniqueNS[i] + } + } + } + var quotedNS []string + for _, ns := range uniqueNS { + quotedNS = append(quotedNS, fmt.Sprintf("%q", ns)) + } + + // ── Marshal merged resources ──────────────────────────────────────────── + mergedYAML, err := yaml.Marshal(merged) + if err != nil { + return fmt.Errorf("serialising merged resources: %w", err) + } + + // ── Build katalog YAML ────────────────────────────────────────────────── + author, _ := LastCommitAuthor() + if author == nil { + author = &GitAuthor{Raw: "unknown"} + } + + var b strings.Builder + b.WriteString("# Generated by ork doctor deploy — do not edit\n") + b.WriteString("apiVersion: orkestra.orkspace.io/v1\n") + b.WriteString("kind: Katalog\n") + b.WriteString("metadata:\n") + b.WriteString(" name: orkestra-developer\n") + b.WriteString(" description: \"Orkestra developer-path managed deployments\"\n") + b.WriteString(" createdBy: orkdoctor\n") + b.WriteString(" author: " + author.Raw + "\n") + + // Emit the projects block so the Control Center can present a developer view. + b.WriteString(" projects:\n") + for _, app := range apps { + b.WriteString(" " + app.Name + ":\n") + b.WriteString(" name: " + app.Name + "\n") + b.WriteString(" namespace: " + app.Namespace + "\n") + if app.Port != "" { + b.WriteString(" port: \"" + app.Port + "\"\n") + } + if app.Language != "" { + b.WriteString(" language: \"" + app.Language + "\"\n") + } + if app.Image != "" { + b.WriteString(" currentImage: \"" + app.Image + "\"\n") + } + } + b.WriteString("\n") + + if !opts.NoSecure { + b.WriteString("security:\n") + b.WriteString(" deletionProtection:\n") + b.WriteString(" enabled: true\n") + if opts.Clean { + b.WriteString(" cleanupOnShutdown: true\n") + } + b.WriteString("\n") + } + + b.WriteString("spec:\n") + b.WriteString(" crds:\n") + b.WriteString(" platform:\n") + b.WriteString(" apiTypes:\n") + b.WriteString(" kind: ConfigMap\n") + b.WriteString(" labelSelector:\n") + b.WriteString(" ork.io/platform: developer\n") + if len(quotedNS) > 0 { + b.WriteString(" allowedNamespaces: [" + strings.Join(quotedNS, ", ") + "]\n") + } + b.WriteString("\n") + b.WriteString(" operatorBox:\n") + b.WriteString(" onReconcile:\n") + b.WriteString(indent(string(mergedYAML), 10)) + + return os.WriteFile(filepath.Join(deployDir, "katalog.yaml"), []byte(b.String()), 0o644) +} + // ReadCRName reads the ConfigMap name from .orkestra/app.yaml. // Returns the name (e.g. "my-app-orkestra") so callers don't need to re-derive it. func ReadCRName(appYAML string) (string, error) { diff --git a/pkg/doctor/helm.go b/pkg/doctor/helm.go index d9ea03c5..188184ce 100644 --- a/pkg/doctor/helm.go +++ b/pkg/doctor/helm.go @@ -6,7 +6,33 @@ import ( "os/exec" ) -func InstallOrUpgradeOrkestra(version, values string, upgrade bool) error { +// BuildControlCenterValues generates a temporary Helm values file that enables +// the Control Center ingress. Call only when controlCenterHost is non-empty. +// The caller is responsible for removing the returned file. +func BuildControlCenterValues(host string) (string, error) { + content := fmt.Sprintf(`controlCenter: + ingress: + enabled: true + hosts: + - host: %s + paths: + - path: / + pathType: Prefix +`, host) + tmp, err := os.CreateTemp("", "orkestra-cc-values-*.yaml") + if err != nil { + return "", err + } + if _, err := tmp.WriteString(content); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return "", err + } + tmp.Close() + return tmp.Name(), nil +} + +func InstallOrUpgradeOrkestra(version string, valueFiles []string, upgrade bool) error { // Always add repo (idempotent) repoAdd := exec.Command("helm", "repo", "add", Orkestra, OrkestraChartRepo) repoAdd.Stdout = os.Stdout @@ -42,8 +68,10 @@ func InstallOrUpgradeOrkestra(version, values string, upgrade bool) error { args = append(args, "--version", version) } - if values != "" { - args = append(args, "-f", values) + for _, f := range valueFiles { + if f != "" { + args = append(args, "-f", f) + } } // Run Helm diff --git a/pkg/doctor/notify.go b/pkg/doctor/notify.go index 74d74235..83d274eb 100644 --- a/pkg/doctor/notify.go +++ b/pkg/doctor/notify.go @@ -54,7 +54,7 @@ func BuildNotificationSecret(envMap map[string]string) string { b.WriteString(" namespace: " + OrkestraNamespace + "\n") b.WriteString(" labels:\n") b.WriteString(" app.kubernetes.io/managed-by: orkestra\n") - b.WriteString(" " + deletionProtectionLabel + ": true\n") + b.WriteString(" " + deletionProtectionLabel + ": \"true\"\n") b.WriteString("type: Opaque\n") b.WriteString("stringData:\n") diff --git a/pkg/doctor/state.go b/pkg/doctor/state.go index 8990bac4..07582a2a 100644 --- a/pkg/doctor/state.go +++ b/pkg/doctor/state.go @@ -1,6 +1,8 @@ package doctor import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "os/exec" @@ -13,6 +15,10 @@ import ( type DeployState struct { ClusterContext string `json:"clusterContext"` Projects map[string]*ProjectState `json:"projects"` + // KatalogHash is the SHA-256 of the last central developer katalog written. + // Used to detect changes without relying on git, since the katalog lives + // outside the project repo and is never committed. + KatalogHash string `json:"katalogHash,omitempty"` } // ProjectState tracks one deployed project. @@ -23,6 +29,17 @@ type ProjectState struct { PreviousImage string `json:"previousImage,omitempty"` KatalogPath string `json:"katalogPath"` DeployedAt time.Time `json:"deployedAt"` + + // Developer path — persisted so the central katalog can be rebuilt on re-deploy. + AppData map[string]string `json:"appData,omitempty"` + Port string `json:"port,omitempty"` + Language string `json:"language,omitempty"` + GitCommit string `json:"gitCommit,omitempty"` + HasDockerfile bool `json:"hasDockerfile,omitempty"` + SecretCount int `json:"secretCount,omitempty"` + ConfigCount int `json:"configCount,omitempty"` + HasSecrets bool `json:"hasSecrets,omitempty"` + HasConfig bool `json:"hasConfig,omitempty"` } // StateDir returns ~/.orkestra/deploy/ @@ -102,6 +119,22 @@ func (s *DeployState) PreviousImage(appName string) string { return "" } +// DeployedAppNames returns a sorted list of app names recorded in state. +func (s *DeployState) DeployedAppNames() []string { + names := make([]string, 0, len(s.Projects)) + for name := range s.Projects { + names = append(names, name) + } + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[i] > names[j] { + names[i], names[j] = names[j], names[i] + } + } + } + return names +} + // CurrentContext returns the active kubectl context name. func CurrentContext() string { out, err := exec.Command("kubectl", "config", "current-context").Output() @@ -110,3 +143,24 @@ func CurrentContext() string { } return strings.TrimSpace(string(out)) } + +// CentralKatalogChanged reads ~/.orkestra/deploy/katalog.yaml, hashes it, and +// compares with the hash stored in state. Returns true when the katalog is new +// or has changed since the last deploy. Persists the new hash to state so the +// next call returns false unless the content changes again. +// +// This replaces the git-diff-based KatalogChanged for the developer path, since +// the central katalog lives outside the project repo and is never committed. +func CentralKatalogChanged(state *DeployState, deployDir string) bool { + data, err := os.ReadFile(filepath.Join(deployDir, "katalog.yaml")) + if err != nil { + return true // assume changed if we can't read it + } + h := sha256.Sum256(data) + newHash := hex.EncodeToString(h[:]) + if state.KatalogHash == newHash { + return false + } + state.KatalogHash = newHash + return true +} diff --git a/pkg/katalog/cliMethods.go b/pkg/katalog/cliMethods.go index 3987e553..2baded7b 100644 --- a/pkg/katalog/cliMethods.go +++ b/pkg/katalog/cliMethods.go @@ -32,6 +32,12 @@ func (k *Katalog) ProjectInfo() orktypes.ProjectInfo { } } +// Projects returns the map of all project infos from the katalog metadata. +// Populated by the developer path (createdBy: orkdoctor) via ork doctor deploy. +func (k *Katalog) Projects() map[string]orktypes.ProjectInfo { + return k.metadata.Projects +} + // Exists returns true if a CRD with the given name exists in the katalog. func (k *Katalog) Exists(name string) bool { _, ok := k.Spec.CRDs[name] diff --git a/pkg/katalog/validation_autoscale.go b/pkg/katalog/validation_autoscale.go index c6241501..49a50fc8 100644 --- a/pkg/katalog/validation_autoscale.go +++ b/pkg/katalog/validation_autoscale.go @@ -38,8 +38,9 @@ import ( orktypes "github.com/orkspace/orkestra/pkg/types" ) -// validateAutoscaleProfile ensures that autoscale.profile is used correctly. -// Runs before profile expansion. Profiles must be the only autoscale input. +// validateAutoscaleProfile ensures that autoscale.profile is used correctly, +// then expands the named profile into a complete AutoscaleSpec so the runtime +// only ever sees a fully-formed spec (never a bare profile name). func (k *Katalog) validateAutoscaleProfile() error { for name, crd := range k.enabledCRDs { spec := crd.OperatorBox.Autoscale @@ -70,6 +71,19 @@ func (k *Katalog) validateAutoscaleProfile() error { return fmt.Errorf("unknown autoscale profile: %q", profile) } + // Expand the profile into a fully-formed AutoscaleSpec using the CRD's + // declared workers and queue depth as the baseline. + baseline := orktypes.AutoscaleBaseline{ + Workers: crd.Workers, + QueueDepth: crd.Queue.MaxQueueDepth, + Resync: crd.Resync, + } + expanded, err := ApplyAutoscalerProfile(profile, baseline) + if err != nil { + return fmt.Errorf("autoscale.profile %q expansion failed: %w", profile, err) + } + crd.OperatorBox.Autoscale = expanded + k.enabledCRDs[name] = crd } return nil diff --git a/pkg/kordinator/crd_health_handers.go b/pkg/kordinator/crd_health_handers.go index ba985916..3f3f5372 100644 --- a/pkg/kordinator/crd_health_handers.go +++ b/pkg/kordinator/crd_health_handers.go @@ -457,23 +457,23 @@ func BuildCRDInfoHandler( // ───────────────────────────────────────────────────────────────────────────── type KatalogResponse struct { - CRDs []CRDSummaryResponse `json:"crds"` - Total int `json:"total"` - TotalEnabled int `json:"totalEnabled"` - OrkReady bool `json:"OrkReady"` - DeletionProtection bool `json:"deletionProtection"` - Healthy bool `json:"healthy"` - Status int `json:"status"` - DegradedReason string `json:"degradedReason,omitempty"` - StatusCounts StatusCounts `json:"statusCounts"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - CreatedBy string `json:"createdBy,omitempty"` - Author string `json:"author,omitempty"` - Description string `json:"description,omitempty"` - License string `json:"license,omitempty"` - RuntimeVersion string `json:"runtimeVersion,omitempty"` - ProjectInfo orktypes.ProjectInfo `json:"projectInfo,omitempty"` + CRDs []CRDSummaryResponse `json:"crds"` + Total int `json:"total"` + TotalEnabled int `json:"totalEnabled"` + OrkReady bool `json:"OrkReady"` + DeletionProtection bool `json:"deletionProtection"` + Healthy bool `json:"healthy"` + Status int `json:"status"` + DegradedReason string `json:"degradedReason,omitempty"` + StatusCounts StatusCounts `json:"statusCounts"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + License string `json:"license,omitempty"` + RuntimeVersion string `json:"runtimeVersion,omitempty"` + Projects map[string]orktypes.ProjectInfo `json:"projects,omitempty"` } type CRDSummaryResponse struct { @@ -669,7 +669,7 @@ func BuildKatalogHandler( CreatedBy: kat.Meta().CreatedBy, License: kat.Meta().License, Description: kat.Meta().Description, - ProjectInfo: kat.ProjectInfo(), + Projects: kat.Projects(), RuntimeVersion: version.Short(), }) } diff --git a/pkg/orkestra-registry/common/methods.go b/pkg/orkestra-registry/common/methods.go index 806fd4a4..f62da5bb 100644 --- a/pkg/orkestra-registry/common/methods.go +++ b/pkg/orkestra-registry/common/methods.go @@ -2,6 +2,7 @@ package common import ( "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/logger" orktypes "github.com/orkspace/orkestra/pkg/types" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -32,6 +33,23 @@ func ResolveNamespace(owner domain.Object, namespace string) string { return "default" } +// ResolveResources returns explicit resource requirements when set, or expands +// a named profile. Returns nil when neither is declared. +func ResolveResources(explicit *orktypes.ResourceRequirements, profile string) *orktypes.ResourceRequirements { + if explicit != nil { + return explicit + } + if profile == "" { + return nil + } + r, err := ExpandResourceProfile(profile) + if err != nil { + logger.Warn().Str("profile", profile).Err(err).Msg("unknown resourceProfile — skipping") + return nil + } + return r +} + // ToPullSecrets converts a slice of string to a []corev1.LocalObjectReference // Acceptable as Pull secrets func ToPullSecrets(names []string) []corev1.LocalObjectReference { diff --git a/pkg/orkestra-registry/common/parse.go b/pkg/orkestra-registry/common/parse.go index ced1ca5a..16b96b66 100644 --- a/pkg/orkestra-registry/common/parse.go +++ b/pkg/orkestra-registry/common/parse.go @@ -2,6 +2,7 @@ package common import ( "fmt" + "strings" "time" orktypes "github.com/orkspace/orkestra/pkg/types" @@ -24,6 +25,56 @@ func ParsePort(s string) int { return p } +// ExpandResourceProfile converts a named resource profile into a +// ResourceRequirements struct. Returns an error for unknown profile names. +// Profiles: tiny, small, medium, large, burst, steady, compute-heavy, memory-heavy. +func ExpandResourceProfile(profile string) (*orktypes.ResourceRequirements, error) { + switch strings.ToLower(profile) { + case "tiny": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "25m", "memory": "64Mi"}, + Limits: map[string]string{"cpu": "100m", "memory": "128Mi"}, + }, nil + case "small": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "100m", "memory": "128Mi"}, + Limits: map[string]string{"cpu": "500m", "memory": "512Mi"}, + }, nil + case "medium": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "250m", "memory": "256Mi"}, + Limits: map[string]string{"cpu": "1", "memory": "1Gi"}, + }, nil + case "large": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "500m", "memory": "512Mi"}, + Limits: map[string]string{"cpu": "2", "memory": "2Gi"}, + }, nil + case "burst": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "200m", "memory": "256Mi"}, + Limits: map[string]string{"cpu": "2", "memory": "2Gi"}, + }, nil + case "steady": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "300m", "memory": "256Mi"}, + Limits: map[string]string{"cpu": "600m", "memory": "512Mi"}, + }, nil + case "compute-heavy": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "1", "memory": "512Mi"}, + Limits: map[string]string{"cpu": "2", "memory": "1Gi"}, + }, nil + case "memory-heavy": + return &orktypes.ResourceRequirements{ + Requests: map[string]string{"cpu": "250m", "memory": "1Gi"}, + Limits: map[string]string{"cpu": "500m", "memory": "2Gi"}, + }, nil + default: + return nil, fmt.Errorf("unknown resource profile: %q", profile) + } +} + // SleepIfNeeded parses an extended duration string and sleeps if non-zero. // Used by all operatorBox resources to inject artificial latency for // autoscaling tests, chaos engineering, and latency simulation. diff --git a/pkg/orkestra-registry/configmaps/configmap.go b/pkg/orkestra-registry/configmaps/configmap.go index 542a2f5d..4944071e 100644 --- a/pkg/orkestra-registry/configmaps/configmap.go +++ b/pkg/orkestra-registry/configmaps/configmap.go @@ -275,6 +275,7 @@ func Resolve(src orktypes.ConfigMapTemplateSource, ownerName string) ResolvedCon FromConfigMap: src.FromConfigMap, FromNamespace: src.FromNamespace, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/cronjobs/cronjob.go b/pkg/orkestra-registry/cronjobs/cronjob.go index 716bec98..e36c74b0 100644 --- a/pkg/orkestra-registry/cronjobs/cronjob.go +++ b/pkg/orkestra-registry/cronjobs/cronjob.go @@ -287,6 +287,7 @@ func Resolve(src orktypes.CronJobTemplateSource, ownerName string) ResolvedCronJ Command: src.Command, Args: src.Args, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/deployments/deployment.go b/pkg/orkestra-registry/deployments/deployment.go index 21576676..c6910449 100644 --- a/pkg/orkestra-registry/deployments/deployment.go +++ b/pkg/orkestra-registry/deployments/deployment.go @@ -189,11 +189,12 @@ func Resolve(src orktypes.DeploymentTemplateSource, staticReplicas int, ownerNam Name: src.Name, Image: src.Image, Namespace: src.Namespace, - Resources: src.Resources, + Resources: common.ResolveResources(src.Resources, src.ResourceProfile), Labels: make(map[string]string), Annotations: make(map[string]string), Env: make(map[string]orktypes.EnvVarSource), EnvFrom: src.EnvFrom, + Sleep: src.Sleep, } // Default name diff --git a/pkg/orkestra-registry/hpas/hpa.go b/pkg/orkestra-registry/hpas/hpa.go index 2e4cc55c..de99b075 100644 --- a/pkg/orkestra-registry/hpas/hpa.go +++ b/pkg/orkestra-registry/hpas/hpa.go @@ -176,6 +176,7 @@ func Resolve(src orktypes.HPATemplateSource, ownerName string) ResolvedHPASpec { MinReplicas: 1, MaxReplicas: 1, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/ingresses/ingress.go b/pkg/orkestra-registry/ingresses/ingress.go index 3b6096f1..73c49cde 100644 --- a/pkg/orkestra-registry/ingresses/ingress.go +++ b/pkg/orkestra-registry/ingresses/ingress.go @@ -196,6 +196,7 @@ func Resolve(src orktypes.IngressTemplateSource, ownerName string) ResolvedIngre IngressClass: src.IngressClass, Labels: make(map[string]string), Annotations: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/jobs/job.go b/pkg/orkestra-registry/jobs/job.go index 3affaae7..2566d072 100644 --- a/pkg/orkestra-registry/jobs/job.go +++ b/pkg/orkestra-registry/jobs/job.go @@ -141,6 +141,7 @@ func Resolve(src orktypes.JobTemplateSource, backoffLimit int, ownerName string) Args: src.Args, BackoffLimit: backoffLimit, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/namespaces/namespace.go b/pkg/orkestra-registry/namespaces/namespace.go index b10d5416..9ee5bd9a 100644 --- a/pkg/orkestra-registry/namespaces/namespace.go +++ b/pkg/orkestra-registry/namespaces/namespace.go @@ -130,6 +130,7 @@ func Resolve(src orktypes.NamespaceTemplateSource, ownerName string) ResolvedNam Name: src.Name, Labels: make(map[string]string), Finalizers: src.Finalizers, + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/pdbs/pdb.go b/pkg/orkestra-registry/pdbs/pdb.go index 219eb401..e4fd948e 100644 --- a/pkg/orkestra-registry/pdbs/pdb.go +++ b/pkg/orkestra-registry/pdbs/pdb.go @@ -176,6 +176,7 @@ func Resolve(src orktypes.PDBTemplateSource, ownerName string) ResolvedPDBSpec { MaxUnavailable: src.MaxUnavailable, Selector: make(map[string]string), Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/pods/pod.go b/pkg/orkestra-registry/pods/pod.go index c0c5be7d..63e17f38 100644 --- a/pkg/orkestra-registry/pods/pod.go +++ b/pkg/orkestra-registry/pods/pod.go @@ -176,6 +176,7 @@ func Resolve(src orktypes.PodTemplateSource, ownerName string) ResolvedPodSpec { spec.Image = src.Image spec.Namespace = src.Namespace spec.Resources = src.Resources + spec.Sleep = src.Sleep if src.Port != "" { spec.Port = common.ParsePort(src.Port) diff --git a/pkg/orkestra-registry/pvcs/pvc.go b/pkg/orkestra-registry/pvcs/pvc.go index 110488b0..b1cc6cba 100644 --- a/pkg/orkestra-registry/pvcs/pvc.go +++ b/pkg/orkestra-registry/pvcs/pvc.go @@ -129,6 +129,7 @@ func Resolve(src orktypes.PVCTemplateSource, ownerName string) ResolvedPVCSpec { VolumeMode: src.VolumeMode, VolumeName: src.VolumeName, Labels: make(map[string]string), + Sleep: src.Sleep, } if len(spec.AccessModes) == 0 { diff --git a/pkg/orkestra-registry/pvs/pv.go b/pkg/orkestra-registry/pvs/pv.go index b095f33e..9f93da6f 100644 --- a/pkg/orkestra-registry/pvs/pv.go +++ b/pkg/orkestra-registry/pvs/pv.go @@ -127,6 +127,7 @@ func Resolve(src orktypes.PVTemplateSource, ownerName string) ResolvedPVSpec { CSIDriver: src.CSIDriver, CSIVolumeHandle: src.CSIVolumeHandle, Labels: make(map[string]string), + Sleep: src.Sleep, } if len(spec.AccessModes) == 0 { diff --git a/pkg/orkestra-registry/replicasets/replicaset.go b/pkg/orkestra-registry/replicasets/replicaset.go index 404f46a8..52244493 100644 --- a/pkg/orkestra-registry/replicasets/replicaset.go +++ b/pkg/orkestra-registry/replicasets/replicaset.go @@ -183,11 +183,12 @@ func Resolve(src orktypes.ReplicaSetTemplateSource, staticReplicas int, ownerNam Name: src.Name, Image: src.Image, Namespace: src.Namespace, - Resources: src.Resources, + Resources: common.ResolveResources(src.Resources, src.ResourceProfile), Labels: make(map[string]string), Annotations: make(map[string]string), Env: make(map[string]orktypes.EnvVarSource), EnvFrom: src.EnvFrom, + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/rolebindings/rolebinding.go b/pkg/orkestra-registry/rolebindings/rolebinding.go index 9c645e50..87593cce 100644 --- a/pkg/orkestra-registry/rolebindings/rolebinding.go +++ b/pkg/orkestra-registry/rolebindings/rolebinding.go @@ -165,6 +165,7 @@ func Resolve(src orktypes.RoleBindingTemplateSource, ownerName string) ResolvedR Name: src.Name, Namespace: src.Namespace, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/roles/role.go b/pkg/orkestra-registry/roles/role.go index 26733572..17899a9d 100644 --- a/pkg/orkestra-registry/roles/role.go +++ b/pkg/orkestra-registry/roles/role.go @@ -155,6 +155,7 @@ func Resolve(src orktypes.RoleTemplateSource, ownerName string) ResolvedRoleSpec Name: src.Name, Namespace: src.Namespace, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/secrets/secret.go b/pkg/orkestra-registry/secrets/secret.go index a406c5bf..f194fe3f 100644 --- a/pkg/orkestra-registry/secrets/secret.go +++ b/pkg/orkestra-registry/secrets/secret.go @@ -288,6 +288,7 @@ func Resolve(src orktypes.SecretTemplateSource, ownerName string) ResolvedSecret Type: src.Type, StringData: src.Data, // declared as strings in YAML Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/serviceaccounts/serviceaccount.go b/pkg/orkestra-registry/serviceaccounts/serviceaccount.go index 3f4c2610..bc96972a 100644 --- a/pkg/orkestra-registry/serviceaccounts/serviceaccount.go +++ b/pkg/orkestra-registry/serviceaccounts/serviceaccount.go @@ -136,6 +136,7 @@ func Resolve(src orktypes.ServiceAccountTemplateSource, ownerName string) Resolv Name: src.Name, Namespace: src.Namespace, Labels: make(map[string]string), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/services/services.go b/pkg/orkestra-registry/services/services.go index b447ce47..b9176b38 100644 --- a/pkg/orkestra-registry/services/services.go +++ b/pkg/orkestra-registry/services/services.go @@ -184,6 +184,7 @@ func Resolve(src orktypes.ServiceTemplateSource, ownerName string) ResolvedServi } spec.Namespace = src.Namespace + spec.Sleep = src.Sleep spec.Type = src.Type if spec.Type == "" { diff --git a/pkg/orkestra-registry/statefulsets/statefulset.go b/pkg/orkestra-registry/statefulsets/statefulset.go index f548c5cd..a5544fbd 100644 --- a/pkg/orkestra-registry/statefulsets/statefulset.go +++ b/pkg/orkestra-registry/statefulsets/statefulset.go @@ -146,7 +146,8 @@ func Resolve(src orktypes.StatefulSetTemplateSource, ownerName string) ResolvedS Annotations: make(map[string]string), Env: src.Env, EnvFrom: src.EnvFrom, - Resources: src.Resources, + Resources: common.ResolveResources(src.Resources, src.ResourceProfile), + Sleep: src.Sleep, } if spec.Name == "" { diff --git a/pkg/orkestra-registry/template/resolver.go b/pkg/orkestra-registry/template/resolver.go index 1e3e8639..e4cadf8a 100644 --- a/pkg/orkestra-registry/template/resolver.go +++ b/pkg/orkestra-registry/template/resolver.go @@ -185,8 +185,9 @@ func (r *Resolver) ResolvePodTemplate(src orktypes.PodTemplateSource) (orktypes. // directly to deployments.Resolve(). func (r *Resolver) ResolveDeploymentTemplate(src orktypes.DeploymentTemplateSource) (orktypes.DeploymentTemplateSource, error) { resolved := orktypes.DeploymentTemplateSource{ - Version: src.Version, - Resources: src.Resources, // static — not resolved + Version: src.Version, + Resources: src.Resources, // static — not resolved + ResourceProfile: src.ResourceProfile, // static — passed through for Resolve() } var err error @@ -290,8 +291,9 @@ func (r *Resolver) ResolveDeploymentTemplate(src orktypes.DeploymentTemplateSour // directly to replicasets.Resolve(). func (r *Resolver) ResolveReplicaSetTemplate(src orktypes.ReplicaSetTemplateSource) (orktypes.ReplicaSetTemplateSource, error) { resolved := orktypes.ReplicaSetTemplateSource{ - Version: src.Version, - Resources: src.Resources, // static — not resolved + Version: src.Version, + Resources: src.Resources, // static — not resolved + ResourceProfile: src.ResourceProfile, // static — passed through for Resolve() } var err error @@ -1069,8 +1071,9 @@ func (r *Resolver) ResolvePDBTemplate(src orktypes.PDBTemplateSource) (orktypes. // ResolveStatefulSetTemplate resolves all template expressions in a StatefulSetTemplateSource. func (r *Resolver) ResolveStatefulSetTemplate(src orktypes.StatefulSetTemplateSource) (orktypes.StatefulSetTemplateSource, error) { resolved := orktypes.StatefulSetTemplateSource{ - Version: src.Version, - Resources: src.Resources, + Version: src.Version, + Resources: src.Resources, + ResourceProfile: src.ResourceProfile, // static — passed through for Resolve() } var err error diff --git a/pkg/types/katalog.go b/pkg/types/katalog.go index 38ed4e5e..3714ecd4 100644 --- a/pkg/types/katalog.go +++ b/pkg/types/katalog.go @@ -127,6 +127,24 @@ type ProjectInfo struct { ConfigCount int `yaml:"configCount,omitempty" json:"configCount,omitempty"` } +// HasSecrets reports whether ork doctor discovered any secret +// environment variables in the project (.env where IsCfg == false). +func (p *ProjectInfo) HasSecrets() bool { + return len(p.Secrets) > 0 +} + +// HasConfig reports whether ork doctor discovered any config +// environment variables in the project (.env where IsCfg == true). +func (p *ProjectInfo) HasConfig() bool { + return len(p.Config) > 0 +} + +// HasCreds reports whether the project contains *either* secrets or config. +// Useful for high‑level checks (e.g., does this project need a ConfigMap or Secret?). +func (p *ProjectInfo) HasCreds() bool { + return p.HasSecrets() || p.HasConfig() +} + // KatalogMeta holds identifying metadata for a Katalog. type KatalogMeta struct { // Name is the required unique identifier of the Katalog. @@ -187,7 +205,7 @@ type KatalogMeta struct { // developer intent, project structure, and local context travel with the // Katalog — enabling richer automation, better defaults, and a more intuitive // experience across the entire lifecycle. - ProjectInfo ProjectInfo `yaml:"projectInfo,omitempty" json:"projectInfo,omitempty"` + // ProjectInfo ProjectInfo `yaml:"projectInfo,omitempty" json:"projectInfo,omitempty"` // Projects holds the aggregated ProjectInfo entries for every application // participating in this Komposer workspace. Each entry represents the From 1b37b7603a0149f9585dbe91afacdc12a0153898 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Mon, 11 May 2026 07:42:38 +0000 Subject: [PATCH 2/2] Add developer path changes and consolidate all commands to ork doctor - simpler mental model. Add additional validation Create documentation for profiles in orkestra --- .gitignore | 5 +- CHANGELOG.md | 18 +- Makefile | 25 ++ cmd/cli/deploy.go | 68 +++-- cmd/cli/doctor.go | 89 +++--- cmd/cli/generate.go | 2 +- cmd/cli/tunnel.go | 40 ++- cmd/cli/validate.go | 14 + .../cc/assets/templates/dev_app_detail.html | 224 ++++++++++++++++ .../cc/assets/templates/dev_apps.html | 97 +++---- .../cc/assets/templates/dev_docs.html | 225 ++++++++++++++++ .../cc/assets/templates/index.html | 38 ++- cmd/controlcenter/cc/controlcenter.go | 112 ++++++-- cmd/controlcenter/cc/docs_handlers.go | 34 +++ cmd/controlcenter/cc/types.go | 56 +++- .../one-katalog-developer-path.md | 14 +- docs/design-documents/ork-doctor.md | 38 +-- docs/design-documents/ork-doktor-expose.md | 52 ++-- docs/design-documents/ork-doktor-full.md | 48 ++-- .../orkestra-motifs-architecture.md | 4 +- docs/design-documents/registry-vs-motifs.md | 8 +- docs/profiles/__index.md | 124 +++++++++ docs/profiles/autoscale-profile.md | 168 ++++++++++++ docs/profiles/probe-profile.md | 253 ++++++++++++++++++ docs/profiles/resource-profile.md | 101 +++++++ docs/publications/look-prepare-go.md | 18 +- docs/reference/cli/developer/__index.md | 12 +- docs/reference/cli/developer/doktor.md | 8 +- docs/reference/cli/developer/rollback.md | 26 +- docs/reference/cli/index.md | 4 +- examples/advanced/15-any-language/README.md | 2 +- .../beginner/01-hello-website/katalog.yaml | 1 - examples/developer/01-one-project/README.md | 10 +- .../developer/02-frontend-backend/README.md | 2 +- .../developer/03-rollback-ingress/README.md | 16 +- examples/developer/04-notify/README.md | 12 +- .../05-deletion-protection/README.md | 4 +- .../06-docker-compose-with-postgres/README.md | 6 +- .../motifs/postgres/motif.yaml | 3 +- examples/developer/README.md | 16 +- new-frontiers/feedbacks/ork-deploy.md | 12 +- pkg/buildx/README.md | 4 +- pkg/buildx/compose_init.go | 2 +- pkg/buildx/docs/02-init-config.md | 8 +- pkg/doctor/bundle.go | 4 +- pkg/doctor/compose.go | 16 +- pkg/doctor/docs/01-detection.md | 2 +- pkg/doctor/docs/03-generation.md | 10 +- pkg/doctor/docs/04-bundle.md | 10 +- pkg/doctor/docs/05-deploy.md | 20 +- pkg/doctor/generate.go | 179 +++++++------ pkg/doctor/kind.go | 2 +- pkg/doctor/komposer.go | 4 +- pkg/doctor/state.go | 38 ++- pkg/katalog/parser.go | 7 + pkg/katalog/probe_profile.go | 45 ++++ pkg/katalog/validate.go | 11 +- pkg/katalog/validate_resource.go | 137 +++++----- pkg/konfig/katalog.go | 14 +- .../deployment-stack/motif.yaml | 142 ---------- pkg/motif/orkestra-motifs/redis/motif.yaml | 83 ------ .../docs/04-developer-notifications.md | 8 +- pkg/orkestra-registry/common/methods.go | 56 +++- pkg/orkestra-registry/common/probes.go | 112 ++++++++ pkg/orkestra-registry/cronjobs/cronjob.go | 32 ++- .../deployments/deployment.go | 24 +- pkg/orkestra-registry/deployments/types.go | 3 + pkg/orkestra-registry/jobs/job.go | 7 + pkg/orkestra-registry/pods/pod.go | 19 +- pkg/orkestra-registry/pods/types.go | 3 + .../replicasets/replicaset.go | 19 +- pkg/orkestra-registry/replicasets/types.go | 3 + .../statefulsets/example_katalog.yaml | 92 +++++++ .../statefulsets/statefulset.go | 124 ++++++--- pkg/orkestra-registry/statefulsets/types.go | 18 +- pkg/orkestra-registry/template/resolver.go | 46 ++-- pkg/reconciler/run_foreach.go | 11 +- pkg/tunnel/README.md | 2 +- pkg/tunnel/cloudflare.go | 4 +- pkg/tunnel/docs/01-providers.md | 4 +- pkg/tunnel/expose.go | 62 ++++- pkg/tunnel/ngrok.go | 4 +- pkg/tunnel/state.go | 6 +- pkg/types/hooks_probes.go | 103 +++++++ pkg/types/hooks_resources.go | 88 ++++++ pkg/types/katalog.go | 10 + pkg/types/methods.go | 10 - pkg/types/types.go | 166 ++++++++---- 88 files changed, 2880 insertions(+), 903 deletions(-) create mode 100644 cmd/controlcenter/cc/assets/templates/dev_app_detail.html create mode 100644 cmd/controlcenter/cc/assets/templates/dev_docs.html create mode 100644 docs/profiles/__index.md create mode 100644 docs/profiles/autoscale-profile.md create mode 100644 docs/profiles/probe-profile.md create mode 100644 docs/profiles/resource-profile.md create mode 100644 pkg/katalog/probe_profile.go delete mode 100644 pkg/motif/orkestra-motifs/deployment-stack/motif.yaml delete mode 100644 pkg/motif/orkestra-motifs/redis/motif.yaml create mode 100644 pkg/orkestra-registry/common/probes.go create mode 100644 pkg/orkestra-registry/statefulsets/example_katalog.yaml create mode 100644 pkg/types/hooks_probes.go create mode 100644 pkg/types/hooks_resources.go diff --git a/.gitignore b/.gitignore index 0948f0f5..4125a29e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ DEPLOY.md node_modules/ # Added by ork doctor init .orkestra/bundle/ -.orkestra/ \ No newline at end of file +.orkestra/ + +# Local ork-registry (published as OCI artifacts) +ork-registry/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 233983be..2e31e81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## Changelog – Orkestra v0.3.8 -### ork doctor + ork deploy — Local to production in minutes +### ork doctor + ork doctor deploy — Local to production in minutes Developers can now deploy any project to Kubernetes with three commands, no operator knowledge required. @@ -19,14 +19,14 @@ ork doctor init --name my-api --notify-me --add-ingress - `.orkestra/app.yaml` — the ConfigMap CR the developer owns - `.orkestra/values.yaml` — Helm values for the Orkestra operator -#### ork deploy +#### ork doctor deploy Builds the Docker image, pushes it, runs `ork kompose` to merge all registered katalogs, generates the cluster bundle, installs or verifies the Orkestra operator via Helm, patches the image in the CR, and watches the rollout. ```bash -ork deploy --registry ghcr.io/myorg -ork deploy --registry ghcr.io/myorg --dev # spins up a local kind cluster -ork deploy --registry ghcr.io/myorg --dry-run +ork doctor deploy --registry ghcr.io/myorg +ork doctor deploy --registry ghcr.io/myorg --dev # spins up a local kind cluster +ork doctor deploy --registry ghcr.io/myorg --dry-run ``` Key behaviours: @@ -38,18 +38,18 @@ Key behaviours: - **Internal service URL checklist** printed after every deploy so developers can wire projects together (`export MY_API_URL=...`) - **Control Center fallback**: when `controlCenterHost` is empty, prints the `kubectl port-forward` command for local access -#### ork deploy rollback +#### ork doctor deploy rollback Restores the previous image by reading `~/.orkestra/deploy/state.json` (annotation fallback for backward compatibility). Swaps current and previous before patching so every rollback is reversible. ```bash -ork deploy rollback -ork deploy rollback --image ghcr.io/myorg/my-api:v1.2.0 +ork doctor deploy rollback +ork doctor deploy rollback --image ghcr.io/myorg/my-api:v1.2.0 ``` #### Out-of-the-box developer notifications -Every katalog generated by `ork doctor init` ships with a `notify:` block on the deployment readiness condition. When replicas are not ready within the notification interval (default 15 minutes), Orkestra sends the `developer` team the exact `kubectl logs` command and a `ork deploy rollback` hint. +Every katalog generated by `ork doctor init` ships with a `notify:` block on the deployment readiness condition. When replicas are not ready within the notification interval (default 15 minutes), Orkestra sends the `developer` team the exact `kubectl logs` command and a `ork doctor deploy rollback` hint. Wire the `developer` team with `ork doctor init --notify-me`: - Reads the developer's Git author email from `git log -1` diff --git a/Makefile b/Makefile index 15465d4d..93203498 100644 --- a/Makefile +++ b/Makefile @@ -164,6 +164,31 @@ runtime-reload: docker @echo "✔ Runtime updated to image: $(ORK_IMAGE)-$(RUNTIME_TAG)" +CONTROL_CENTER_DEPLOYMENT ?= orkestra-cc +CONTROL_CENTER_CONTAINER_NAME ?= controlcenter +CONTROL_CENTER_NAMESPACE ?= orkestra-system + +controlcenter-reload: docker-cc + @echo "Generating unique tag..." + $(eval CC_TAG := $(shell date +%s)) + @echo "Tag: $(CC_TAG)" + + @echo "Retagging image..." + docker tag $(ORK_CC_IMAGE) $(ORK_CC_IMAGE)-$(CC_TAG) + + @echo "Loading image into kind cluster: $(KIND_CLUSTER)" + kind load docker-image $(ORK_CC_IMAGE)-$(CC_TAG) --name $(KIND_CLUSTER) + @echo "✔ Image loaded" + + @echo "Updating deployment $(CONTROL_CENTER_DEPLOYMENT) in namespace $(CONTROL_CENTER_NAMESPACE)..." + kubectl -n $(CONTROL_CENTER_NAMESPACE) set image deploy/$(CONTROL_CENTER_DEPLOYMENT) \ + $(CONTROL_CENTER_CONTAINER_NAME)=$(ORK_CC_IMAGE)-$(CC_TAG) + + @echo "✔ Control Center updated to image: $(ORK_CC_IMAGE)-$(CC_TAG)" + +orkestra-reload: runtime-reload controlcenter-reload + @echo "✔ Orkestra runtime and Control Center reloaded successfully" + # ── Primary targets ─────────────────────────────────────────────────────────── # Default: vet + unit tests. Fast, no external dependencies. diff --git a/cmd/cli/deploy.go b/cmd/cli/deploy.go index 8627e6d1..8f4ca92e 100644 --- a/cmd/cli/deploy.go +++ b/cmd/cli/deploy.go @@ -34,9 +34,9 @@ var deployCmd = &cobra.Command{ Long: `Build the Docker image, push it, generate the Orkestra bundle, apply it to the cluster, and patch the CR to trigger a rolling deploy. - ork deploy --registry ghcr.io/myorg - ork deploy --registry ghcr.io/myorg --tag v1.2.0 - ork deploy --registry ghcr.io/myorg --dry-run`, + ork doctor deploy --registry ghcr.io/myorg + ork doctor deploy --registry ghcr.io/myorg --tag v1.2.0 + ork doctor deploy --registry ghcr.io/myorg --dry-run`, RunE: func(cmd *cobra.Command, args []string) error { registry, _ := cmd.Flags().GetString("registry") tag, _ := cmd.Flags().GetString("tag") @@ -89,8 +89,8 @@ to the cluster, and patch the CR to trigger a rolling deploy. } } - // Load init config (bridges doctor init → deploy). - initCfg, _ := buildx.LoadInitConfig(dir) + // Load init config from state. + initCfg := loadInitCfgFromState(state, dir) // ── Multi-app path ──────────────────────────────────────────────────────── // --use-compose init writes Apps entries but never writes app.yaml, so the @@ -117,7 +117,6 @@ to the cluster, and patch the CR to trigger a rolling deploy. clusterCtx: doctor.CurrentContext(), initCfg: initCfg, }) - _ = buildx.CleanupInitConfig(dir) return err } @@ -740,8 +739,8 @@ var rollbackCmd = &cobra.Command{ Long: `Restore the previous image instantly by patching the ConfigMap. No rebuild or push required — the image is stored in ~/.orkestra/deploy/state.json. - ork deploy rollback # restore previous image - ork deploy rollback --image ghcr.io/x:v1 # restore a specific image`, + ork doctor deploy rollback # restore previous image + ork doctor deploy rollback --image ghcr.io/x:v1 # restore a specific image`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { targetImage, _ := cmd.Flags().GetString("image") @@ -921,7 +920,7 @@ func watchUntilReady(crName, ns, appName string, state *doctor.DeployState) erro if state != nil && state.PreviousImage(appName) != "" { fmt.Printf("\n A previous good image is available.\n") - fmt.Printf(" Roll back with: ork deploy rollback\n") + fmt.Printf(" Roll back with: ork doctor deploy rollback\n") } if rollCtx.Err() == context.DeadlineExceeded { @@ -1029,6 +1028,32 @@ func fileExistsAtPath(path string) bool { return err == nil && !info.IsDir() } +// loadInitCfgFromState reads init settings (useCompose, apps) from state.json, +// avoiding the need for .orkestra/bundle/.init.ork on re-deploy. +// Returns an empty InitConfig when the state has no matching entry. +func loadInitCfgFromState(state *doctor.DeployState, dir string) buildx.InitConfig { + if state == nil { + return buildx.InitConfig{} + } + // Look for any project whose Dir matches this directory. + for _, p := range state.Projects { + if p.Dir == dir { + cfg := buildx.InitConfig{ + UseCompose: p.UseCompose, + ComposeFile: p.ComposeFile, + } + // Reconstruct Apps from DirApps if present. + if names, ok := state.DirApps[dir]; ok { + for _, name := range names { + cfg.Apps = append(cfg.Apps, buildx.AppEntry{Name: name, Dir: dir}) + } + } + return cfg + } + } + return buildx.InitConfig{} +} + // devPathArgs bundles parameters for deployDeveloperPath. type devPathArgs struct { dir string @@ -1045,15 +1070,24 @@ type devPathArgs struct { } // deployDeveloperPath implements the developer deploy flow: -// 1. Verify .orkestra/motif.yaml exists +// 1. Load the motif template from ~/.orkestra/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 -// from motif.yaml embedded directly in operatorBox.onReconcile (no file imports) +// embedded directly in operatorBox.onReconcile (no file imports) // 4. Generate bundle (RBAC + ConfigMap) from the self-contained central katalog func deployDeveloperPath(a devPathArgs) error { - motifPath := filepath.Join(a.dir, orkDir, "motif.yaml") + motifPath, err := doctor.MotifPath(a.appName) + if err != nil { + return fmt.Errorf("resolving motif path: %w", err) + } + // Legacy fallback: accept motif.yaml from .orkestra/ if the global one is missing. if !fileExistsAtPath(motifPath) { - return fmt.Errorf(".orkestra/motif.yaml not found — run 'ork doctor init --name %s' first", a.appName) + legacyPath := filepath.Join(a.dir, orkDir, "motif.yaml") + if fileExistsAtPath(legacyPath) { + motifPath = legacyPath + } else { + return fmt.Errorf("motif not found — run 'ork doctor init --name %s' first", a.appName) + } } fmt.Println("\nUsing developer path...") @@ -1093,8 +1127,12 @@ func deployDeveloperPath(a devPathArgs) error { } } - // Generate the central katalog with per-app concrete resources. - if err := doctor.GenerateDeveloperKatalog(deployDir, motifPath, apps, a.opts); err != nil { + // Read the motif template content and generate the central katalog. + motifContent, err := os.ReadFile(motifPath) + if err != nil { + return fmt.Errorf("reading motif template: %w", err) + } + if err := doctor.GenerateDeveloperKatalog(deployDir, string(motifContent), apps, a.opts); err != nil { return fmt.Errorf("generating developer katalog: %w", err) } fmt.Printf(" %s Developer katalog updated\n", utils.SuccessMark()) diff --git a/cmd/cli/doctor.go b/cmd/cli/doctor.go index 806c0997..c18bc9ad 100644 --- a/cmd/cli/doctor.go +++ b/cmd/cli/doctor.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/orkspace/orkestra/pkg/buildx" "github.com/orkspace/orkestra/pkg/doctor" orktypes "github.com/orkspace/orkestra/pkg/types" "github.com/spf13/cobra" @@ -198,11 +197,11 @@ Please add a Dockerfile to the project root. missing := 0 fmt.Println("Missing dependencies:") if !doctor.KubectlAvailable() { - fmt.Println(" kubectl (will be installed during 'ork deploy')") + fmt.Println(" kubectl (will be installed during 'ork doctor deploy')") missing++ } if !doctor.HelmAvailable() { - fmt.Println(" helm (will be installed during 'ork deploy')") + fmt.Println(" helm (will be installed during 'ork doctor deploy')") missing++ } if missing == 0 { @@ -342,7 +341,7 @@ var doctorInitCmd = &cobra.Command{ // ── Single-app init (legacy) ── if name == "" { - return fmt.Errorf("--name is required") + name = filepath.Base(dir) } opts.Name = name @@ -351,26 +350,38 @@ var doctorInitCmd = &cobra.Command{ return fmt.Errorf("detection failed: %w", err) } - if err := doctor.InitDeveloper(info, opts); err != nil { + motifContent, err := doctor.InitDeveloper(info, opts) + if err != nil { return err } - shouldUseCompose := useCompose != "" - if err := buildx.WriteInitConfig(dir, shouldUseCompose, useCompose); err != nil { - return fmt.Errorf("writing init config: %v", err) + // Store the motif in ~/.orkestra/apps// — outside the project tree. + if err := doctor.SaveMotif(name, motifContent); err != nil { + return fmt.Errorf("saving motif: %w", err) } + // Persist init settings to state so ork doctor deploy never needs .init.ork. + state, _ := doctor.LoadState() + if state == nil { + state = &doctor.DeployState{Projects: make(map[string]*doctor.ProjectState)} + } + if state.Projects[name] == nil { + state.Projects[name] = &doctor.ProjectState{Name: name} + } + state.Projects[name].Dir = dir + state.Projects[name].UseCompose = useCompose != "" + state.Projects[name].ComposeFile = useCompose + _ = state.Save() + fmt.Println() fmt.Printf("App: %s\n", name) fmt.Printf("Namespace: %s-ns\n", name) fmt.Println() - fmt.Println("Generated .orkestra/motif.yaml") fmt.Println("Generated .orkestra/app.yaml") fmt.Println() fmt.Println("Next steps:") - fmt.Println(" 1. Review .orkestra/motif.yaml (resource template — edit freely)") - fmt.Println(" 2. Fill in .orkestra/app.yaml (port, replicas, etc.)") - fmt.Println(" 3. Run 'ork deploy --registry '") + fmt.Println(" 1. Fill in .orkestra/app.yaml (port, replicas, etc.)") + fmt.Println(" 2. Run 'ork doctor deploy --registry '") return nil }, @@ -469,7 +480,8 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate fmt.Println("Generating multi-app config...") fmt.Println() - var initApps []buildx.AppEntry + type initApp struct{ Name, Dir, Dockerfile string } + var initApps []initApp // Cache augmented info per app so URL hints use the same resolved port. appInfoCache := make(map[string]*orktypes.ProjectInfo, len(apps)) @@ -481,10 +493,10 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate if perAppStateful != nil { // Multi-app compose path: supply only the stateful services this - // specific app depends on. Clearing UseCompose prevents doctor.Init - // from re-parsing the compose file and injecting everything again. + // specific app depends on. Clearing UseCompose prevents re-parsing + // the compose file and injecting everything again. appOpts.UseCompose = "" - appOpts.InjectStateful = perAppStateful[app.name] // nil = no stateful for this app + appOpts.InjectStateful = perAppStateful[app.name] } // Wire dep conditions into the deployment when: block. @@ -504,31 +516,47 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate appInfoCache[app.name] = info - if err := doctor.Init(info, appOpts); err != nil { + motifContent, err := doctor.InitDeveloper(info, appOpts) + if err != nil { return fmt.Errorf("init %s: %w", app.name, err) } + if err := doctor.SaveMotif(app.name, motifContent); err != nil { + return fmt.Errorf("saving motif for %s: %w", app.name, err) + } + fmt.Printf(" %s:\n", app.name) - fmt.Printf(" Generated .orkestra/%s/katalog.yaml\n", app.name) fmt.Printf(" Generated .orkestra/%s/app.yaml\n", app.name) - initApps = append(initApps, buildx.AppEntry{ + initApps = append(initApps, initApp{ Name: app.name, Dir: app.dir, Dockerfile: app.dockerfile, }) } - // Persist init config - cfg := buildx.InitConfig{ - UseCompose: useCompose != "", - ComposeFile: useCompose, - Apps: initApps, + // Persist init config to state + state, _ := doctor.LoadState() + if state == nil { + state = &doctor.DeployState{Projects: make(map[string]*doctor.ProjectState)} } - if err := buildx.WriteInitConfigFull(baseDir, cfg); err != nil { - return fmt.Errorf("writing init config: %w", err) + if state.DirApps == nil { + state.DirApps = make(map[string][]string) + } + appNames := make([]string, len(initApps)) + for i, a := range initApps { + appNames[i] = a.Name + if state.Projects[a.Name] == nil { + state.Projects[a.Name] = &doctor.ProjectState{Name: a.Name} + } + state.Projects[a.Name].Dir = a.Dir + state.Projects[a.Name].UseCompose = useCompose != "" + state.Projects[a.Name].ComposeFile = useCompose + } + state.DirApps[baseDir] = appNames + if err := state.Save(); err != nil { + return fmt.Errorf("saving state: %w", err) } - // Print internal URLs — use cached augmented info so ports reflect compose ports:. fmt.Println() fmt.Println("Internal service URLs (set these in each app's .env before deploying):") @@ -544,8 +572,7 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate } fmt.Println() fmt.Println("Next steps:") - fmt.Println(" 1. Review .orkestra//katalog.yaml for each app") - fmt.Println(" 2. Fill in .orkestra//app.yaml for each app") + fmt.Println(" 1. Fill in .orkestra//app.yaml for each app") // When stateful services were wired to specific apps, tell the user exactly // where to find and configure the dependency keys. @@ -569,13 +596,13 @@ func doctorInitMultiApp(baseDir string, cmd *cobra.Command, opts doctor.Generate } fmt.Println() - fmt.Println(" 3. Run 'ork deploy --registry '") + fmt.Println(" 3. Run 'ork doctor deploy --registry '") _ = projectName return nil } // doctorDeployCmd is `ork doctor deploy` — the developer-path deploy command. -// It runs the same flow as `ork deploy` but is discoverable as a subcommand of +// It runs the same flow as `ork doctor deploy` but is discoverable as a subcommand of // `ork doctor`, making it natural for developers who start with `ork doctor init`. var doctorDeployCmd = &cobra.Command{ Use: "deploy", diff --git a/cmd/cli/generate.go b/cmd/cli/generate.go index e23ef455..666d3775 100644 --- a/cmd/cli/generate.go +++ b/cmd/cli/generate.go @@ -338,7 +338,7 @@ func init() { cmd.Flags().Bool("dry-run", false, "Print generated output to stdout without writing files") cmd.Flags().StringP("output", "o", "", "Write generated output to file") cmd.Flags().StringP("namespace", "n", defaultNamespace(), "Namespace for the ServiceAccount") - cmd.Flags().StringP("workload-namespace", "w", "", "Namespace for the Deployment Workloads. Used by 'ork deploy'") + cmd.Flags().StringP("workload-namespace", "w", "", "Namespace for the Deployment Workloads. Used by 'ork doctor deploy'") } // Shadow global flags so they don't appear under `ork generate` diff --git a/cmd/cli/tunnel.go b/cmd/cli/tunnel.go index 929f8cc9..421f0080 100644 --- a/cmd/cli/tunnel.go +++ b/cmd/cli/tunnel.go @@ -18,7 +18,7 @@ const controlCenterTunnelName = "controlcenter" var tunnelCmd = &cobra.Command{ Use: "tunnel", Short: "Manage Orkestra tunnel daemons", - Long: `Manage the public HTTPS tunnels started by ork deploy --expose. + Long: `Manage the public HTTPS tunnels started by ork doctor deploy --expose. ork tunnel expose Start a tunnel for an app or Control Center ork tunnel status Show all running tunnels @@ -59,16 +59,21 @@ Note: exposing orkestra-runtime is not supported.`, PortForward: true, } } else { - ns, err := resolveAppNamespace(name) + ns, port, err := resolveAppNamespaceAndPort(name) if err != nil { return err } + if ns == "" { + fmt.Fprintf(os.Stderr, " ✗ App %q is not deployed. Run 'ork doctor deploy' first.\n", name) + fmt.Fprintf(os.Stderr, " To see deployed apps: ork doctor status\n") + return fmt.Errorf("app not deployed: %s", name) + } opts = tunnel.ExposeOptions{ Name: name, Provider: provider, Token: token, - ServiceName: name + "-orkestra-svc", - ServicePort: "8080", + ServiceName: name + "-svc", + ServicePort: port, Namespace: ns, } } @@ -93,7 +98,7 @@ var tunnelStatusCmd = &cobra.Command{ } if len(states) == 0 { fmt.Println(" No tunnels running") - fmt.Println(" Start one with: ork deploy --expose or ork tunnel expose ") + fmt.Println(" Start one with: ork doctor deploy --expose or ork tunnel expose ") return nil } @@ -181,16 +186,23 @@ var tunnelRestartCmd = &cobra.Command{ }, } -// resolveAppNamespace looks up the namespace for an app from deploy state, -// falling back to the conventional -orkestra-ns pattern. -func resolveAppNamespace(appName string) (string, error) { - state, err := doctor.LoadState() - if err == nil && state != nil { - if p, ok := state.Projects[appName]; ok && p.Namespace != "" { - return p.Namespace, nil - } +// resolveAppNamespaceAndPort looks up the namespace and port for an app from +// deploy state. Returns an empty ns when the app is not in state (not deployed). +func resolveAppNamespaceAndPort(appName string) (ns, port string, err error) { + state, loadErr := doctor.LoadState() + if loadErr != nil || state == nil { + return "", "", nil + } + p, ok := state.Projects[appName] + if !ok { + return "", "", nil + } + ns = p.Namespace + port = p.Port + if port == "" { + port = "8080" } - return appName + "-orkestra-ns", nil + return ns, port, nil } func init() { diff --git a/cmd/cli/validate.go b/cmd/cli/validate.go index 780811d8..be3c94c5 100644 --- a/cmd/cli/validate.go +++ b/cmd/cli/validate.go @@ -28,6 +28,20 @@ var validateCmd = &cobra.Command{ return fmt.Errorf("reading %s: %w", path, err) } + // Validate document type + if !konfig.IsValidDocumentKind(kind) { + if kind == "" { + return fmt.Errorf( + "not an Orkestra document — expected a 'kind' field (allowed kinds: %s)", + konfig.ValidKindsString(), + ) + } + return fmt.Errorf( + "invalid Orkestra document kind %q (allowed kinds: %s)", + kind, konfig.ValidKindsString(), + ) + } + if konfig.IsMotifKind(kind) { return validateMotifFile(path) } diff --git a/cmd/controlcenter/cc/assets/templates/dev_app_detail.html b/cmd/controlcenter/cc/assets/templates/dev_app_detail.html new file mode 100644 index 00000000..111d1043 --- /dev/null +++ b/cmd/controlcenter/cc/assets/templates/dev_app_detail.html @@ -0,0 +1,224 @@ + + + + + + {{ .App.Name }} – Orkestra + + + + + + + + +
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+
+
+

{{ .App.Name }}

+ {{ if .App.Language }} + {{ .App.Language }} + {{ end }} + + Deployed + +
+ {{ if .App.Namespace }} +

+ + {{ .App.Namespace }} +

+ {{ end }} +
+
+
+ + {{ $hasSignals := or .App.HasDockerfile (or .App.HasFrontend (or .App.HasCompose (or .App.HasSMTP (or .App.HasSlack (ne .App.License ""))))) }} +
+ + +
+
Deployment
+ + {{ if .App.CurrentImage }} +
+
Image
+ {{ .App.CurrentImage }} +
+ {{ end }} + + {{ if .App.GitCommit }} +
+
Commit
+ {{ .App.GitCommit }} +
+ {{ end }} + + {{ if .App.Port }} +
+
Port
+ {{ .App.Port }} +
+ {{ end }} + + {{ if .App.ServiceURL }} +
+
Internal URL
+ {{ .App.ServiceURL }} +
+ {{ end }} +
+ + + {{ if $hasSignals }} +
+
Project
+ +
+ + {{ if .App.HasDockerfile }} +
+ + Dockerfile present +
+ {{ end }} + + {{ if .App.HasFrontend }} +
+ + Frontend detected +
+ {{ end }} + + {{ if .App.HasCompose }} +
+ + Docker Compose present +
+ {{ end }} + + {{ if .App.HasSMTP }} +
+ + SMTP configured +
+ {{ end }} + + {{ if .App.HasSlack }} +
+ + Slack webhook configured +
+ {{ end }} + + {{ if .App.License }} +
+ + {{ .App.License }} +
+ {{ end }} + +
+
+ {{ end }} + +
+ + + {{ if or (gt .App.SecretCount 0) (gt .App.ConfigCount 0) }} +
+
Environment
+
+ {{ if gt .App.SecretCount 0 }} +
+
{{ .App.SecretCount }}
+
secret{{ if gt .App.SecretCount 1 }}s{{ end }}
+
+ {{ end }} + {{ if gt .App.ConfigCount 0 }} +
+
{{ .App.ConfigCount }}
+
config var{{ if gt .App.ConfigCount 1 }}s{{ end }}
+
+ {{ end }} +
+
+ {{ end }} + + +
+
+ To redeploy: ork doctor deploy +  ·  + ← Back to Applications +
+
+ +
+
+ + + + diff --git a/cmd/controlcenter/cc/assets/templates/dev_apps.html b/cmd/controlcenter/cc/assets/templates/dev_apps.html index 87d579f3..7d526e3f 100644 --- a/cmd/controlcenter/cc/assets/templates/dev_apps.html +++ b/cmd/controlcenter/cc/assets/templates/dev_apps.html @@ -16,12 +16,11 @@ @@ -31,11 +30,15 @@ All Katalogs -
Your Apps
+
{{ .KatalogName }}
Applications + + + Docs + {{ if gt (len .Katalogs) 0 }} @@ -158,6 +175,16 @@

{{ .Name }}