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/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..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") @@ -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. @@ -95,13 +89,14 @@ 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 // 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, @@ -122,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 } @@ -152,9 +146,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 +179,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 +191,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 +238,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 +284,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 +323,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 +347,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 +357,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 +601,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 +618,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()) @@ -766,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") @@ -891,16 +864,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 +912,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") + fmt.Printf(" Roll back with: ork doctor 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 +1028,174 @@ 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 + 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. 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 +// 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, 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) { + 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...") + + 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, + }) + } + } + } + + // 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()) + + // 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..c18bc9ad 100644 --- a/cmd/cli/doctor.go +++ b/cmd/cli/doctor.go @@ -7,8 +7,8 @@ import ( "os" "path/filepath" "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" @@ -197,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 { @@ -341,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 @@ -350,33 +350,38 @@ var doctorInitCmd = &cobra.Command{ return fmt.Errorf("detection failed: %w", err) } - if err := doctor.Init(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) } - crName := name + "-orkestra" - ns := name + "-orkestra-ns" + // 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("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/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(" 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 }, @@ -475,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)) @@ -487,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. @@ -510,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):") @@ -550,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. @@ -575,11 +596,105 @@ 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 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", + 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 +708,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/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 new file mode 100644 index 00000000..7d526e3f --- /dev/null +++ b/cmd/controlcenter/cc/assets/templates/dev_apps.html @@ -0,0 +1,155 @@ + + + + + + Your Applications – Orkestra + + + + + + + + +
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+
+
+

Applications

+ + + Deployed + +
+

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

+
+
+
+ + {{ if eq (len .Apps) 0 }} +
+ + + +
+
No applications deployed yet
+
Run ork doctor deploy from your project directory to deploy your first app.
+
+
+ {{ else }} + + + {{ end }} + +
+
+ + + + diff --git a/cmd/controlcenter/cc/assets/templates/dev_docs.html b/cmd/controlcenter/cc/assets/templates/dev_docs.html new file mode 100644 index 00000000..9d576073 --- /dev/null +++ b/cmd/controlcenter/cc/assets/templates/dev_docs.html @@ -0,0 +1,225 @@ + + + + + + Docs – {{ .KatalogName }} + + + + + + + +
+ +
+
+ + +
+ +
+
+ +
+ + +
+

Developer Guide

+

+ Your apps run inside an isolated cluster managed by Orkestra. + Each app gets its own namespace, service, and deployment — all created automatically when you run ork doctor deploy. + This page summarises what was detected and how to work with each app. +

+
+
+ Deployork doctor deploy +
+
+ Statusork doctor status +
+
+ Tunnelork tunnel expose +
+
+
+ + {{ range .Apps }} + +
+
+

{{ .Name }}

+ {{ if .Language }}{{ .Language }}{{ end }} +
+ + +
+ + {{ if .Namespace }} +
+
Namespace
+ {{ .Namespace }} +
+ {{ end }} + + {{ if .Port }} +
+
Port
+ {{ .Port }} +
+ {{ end }} + + {{ if .GitCommit }} +
+
Commit
+ {{ .GitCommit }} +
+ {{ end }} + + {{ if .License }} +
+
License
+ {{ .License }} +
+ {{ end }} + +
+ + + {{ if .ServiceURL }} +
+
Reach this app from inside the cluster
+
{{ .ServiceURL }}
+
+ {{ end }} + + +
+
Redeploy after changes
+
+ cd /path/to/{{ .Name }}
+ ork doctor deploy +
+
+ + +
+
Expose publicly for testing
+
+ ork tunnel expose --name {{ .Name }} --port {{ .Port }} +
+
+ + + {{ $hasSignals := or .HasDockerfile .HasFrontend .HasSMTP .HasSlack .HasCompose }} + {{ if $hasSignals }} +
+
Detected during setup
+
+ {{ if .HasDockerfile }} +
+ + Dockerfile found — your image is built automatically on deploy +
+ {{ end }} + {{ if .HasFrontend }} +
+ + Frontend detected — a separate service is configured for the UI +
+ {{ end }} + {{ if .HasCompose }} +
+ + Docker Compose found — services declared there are available in the cluster +
+ {{ end }} + {{ if .HasSMTP }} +
+ + SMTP credentials found in .env — email sending should work out of the box +
+ {{ end }} + {{ if .HasSlack }} +
+ + Slack webhook found — deploy notifications are wired +
+ {{ end }} +
+
+ {{ end }} + + + {{ if or (gt .SecretCount 0) (gt .ConfigCount 0) }} +
+ {{ if gt .SecretCount 0 }} + {{ .SecretCount }} secret{{ if gt .SecretCount 1 }}s{{ end }} from .env + {{ end }} + {{ if gt .ConfigCount 0 }} + {{ .ConfigCount }} config var{{ if gt .ConfigCount 1 }}s{{ end }} from .env + {{ end }} +
+ {{ end }} + +
+ {{ end }} + +
+
+ + + + diff --git a/cmd/controlcenter/cc/assets/templates/index.html b/cmd/controlcenter/cc/assets/templates/index.html index ac8a6610..f75a3964 100644 --- a/cmd/controlcenter/cc/assets/templates/index.html +++ b/cmd/controlcenter/cc/assets/templates/index.html @@ -36,12 +36,14 @@ All Katalogs + {{ if .HasOperatorKatalogs }} Metrics + {{ end }} {{ if .EnableRuntimeManager }} @@ -51,6 +53,7 @@ Manage Runtimes {{ end }} + {{ if .HasOperatorKatalogs }} @@ -61,6 +64,7 @@ Documentation + {{ end }} + {{ if gt .TotalApps 0 }}
-
Total CRDs
+
Applications
+
{{ .TotalApps }}
+
via ork doctor
+
+ {{ end }} + {{ if gt .TotalCRDs 0 }} +
+
CRDs
{{ .TotalCRDs }}
-
Across all Katalogs
+
Operator resources
+ {{ end }} + {{ if gt .TotalWorkers 0 }}
-
Total Workers
+
Workers
{{ .TotalWorkers }}
-
All reconcilers combined
+
Reconcilers running
+ {{ end }} + {{ if gt .TotalResources 0 }}
Live Resources
{{ .TotalResources }}
CRs in cache
+ {{ end }} {{ if gt (len .Katalogs) 0 }} @@ -158,6 +175,16 @@

{{ .Name }}