diff --git a/README.md b/README.md index 9106163..77ffe32 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,23 @@ hops validate --help hops xr --help ``` +## Local workbench (happy path) + +Multi-workspace local GitOps on the laptop control plane: + +```bash +# once +hops local start + +# daily +hops local up ./gitops/env/local +hops local status +hops local open +hops local down +``` + +Use `--name` for concurrent worktrees (`` namespaces). Full guide: [skills/claude/references/local-workbench.md](skills/claude/references/local-workbench.md). + ## Command Areas `hops-cli` is organized into a few command groups: @@ -593,7 +610,7 @@ Notes: - Source-build mode intended for a local control plane because it depends on the local registry flow - Runs `up project build` in `PATH` (defaults to current directory) - Loads generated `.uppkg` artifacts from `/_output` - - Pushes package images to the registry exposed at `localhost:30500` + - Pushes package images to the registry exposed at `127.0.0.1:30500` (IPv4; avoids Docker's `localhost` → `[::1]` path) - Applies Crossplane `Configuration` resources pointing at `registry.crossplane-system.svc.cluster.local:5000/...` - Supports `--skip-dependency-resolution` - `config install --repo [--reload]` diff --git a/bootstrap/drc/helm.yaml b/bootstrap/providers/helm-drc.yaml similarity index 79% rename from bootstrap/drc/helm.yaml rename to bootstrap/providers/helm-drc.yaml index 0c5c6b0..c00d423 100644 --- a/bootstrap/drc/helm.yaml +++ b/bootstrap/providers/helm-drc.yaml @@ -1,10 +1,10 @@ -# Per-provider DeploymentRuntimeConfig for provider-helm (local dev). +# Per-provider DeploymentRuntimeConfig for local dev. +# Colocated with bootstrap/providers/helm.yaml (runtimeConfigRef: local-dev-helm). # # Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. # Providers must NOT share a DRC: a shared runtime config lets one provider's # image/SA silently clobber the other's pod (and makes drift impossible to -# attribute). This mirrors the per-provider DRCs the -# crossplane-helm-provider-stack composes for remote clusters. +# attribute). apiVersion: pkg.crossplane.io/v1beta1 kind: DeploymentRuntimeConfig metadata: diff --git a/bootstrap/providers/provider-helm.yaml b/bootstrap/providers/helm.yaml similarity index 100% rename from bootstrap/providers/provider-helm.yaml rename to bootstrap/providers/helm.yaml diff --git a/bootstrap/drc/kubernetes.yaml b/bootstrap/providers/kubernetes-drc.yaml similarity index 78% rename from bootstrap/drc/kubernetes.yaml rename to bootstrap/providers/kubernetes-drc.yaml index 7ce229d..a0bdedd 100644 --- a/bootstrap/drc/kubernetes.yaml +++ b/bootstrap/providers/kubernetes-drc.yaml @@ -1,10 +1,10 @@ -# Per-provider DeploymentRuntimeConfig for provider-kubernetes (local dev). +# Per-provider DeploymentRuntimeConfig for local dev. +# Colocated with bootstrap/providers/kubernetes.yaml (runtimeConfigRef: local-dev-kubernetes). # # Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. # Providers must NOT share a DRC: a shared runtime config lets one provider's # image/SA silently clobber the other's pod (and makes drift impossible to -# attribute). This mirrors the per-provider DRCs the -# crossplane-kubernetes-provider-stack composes for remote clusters. +# attribute). apiVersion: pkg.crossplane.io/v1beta1 kind: DeploymentRuntimeConfig metadata: diff --git a/bootstrap/providers/provider-kubernetes.yaml b/bootstrap/providers/kubernetes.yaml similarity index 100% rename from bootstrap/providers/provider-kubernetes.yaml rename to bootstrap/providers/kubernetes.yaml diff --git a/renovate.json b/renovate.json index 5db72dd..7ddc895 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,60 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "config:recommended", + ":dependencyDashboard" + ], + "labels": ["dependencies"], + "packageRules": [ + { + "description": "Automerge patch updates that pass checks", + "matchUpdateTypes": ["patch"], + "automerge": true, + "matchManagers": ["custom.regex"] + }, + { + "description": "Group local-start bootstrap Crossplane pins", + "groupName": "local-start-crossplane-bootstrap", + "matchManagers": ["custom.regex"], + "matchPackageNames": [ + "crossplane/crossplane", + "crossplane-contrib/provider-helm", + "crossplane-contrib/provider-kubernetes" + ] + }, + { + "description": "Conventional commit prefix for bootstrap pin bumps", + "matchManagers": ["custom.regex"], + "matchDatasources": ["github-releases"], + "semanticCommitType": "chore", + "semanticCommitScope": "deps" + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Bootstrap Provider package tags (hops local start)", + "managerFilePatterns": [ + "bootstrap/providers/**/*.yaml" + ], + "matchStrings": [ + "package:\\s*xpkg\\.crossplane\\.io/(?[^:]+):(?v?[0-9][^\\s]+)" + ], + "datasourceTemplate": "github-releases", + "packageNameTemplate": "{{depName}}" + }, + { + "customType": "regex", + "description": "Crossplane Helm chart version pin in hops local start", + "managerFilePatterns": [ + "src/commands/local/start.rs" + ], + "matchStrings": [ + "const CROSSPLANE_CHART_VERSION: &str = \"(?[^\"]+)\";" + ], + "datasourceTemplate": "github-releases", + "packageNameTemplate": "crossplane/crossplane", + "extractVersionTemplate": "^v(?.*)$" + } ] } diff --git a/skills/claude/SKILL.md b/skills/claude/SKILL.md index 091743e..653f2b7 100644 --- a/skills/claude/SKILL.md +++ b/skills/claude/SKILL.md @@ -2,21 +2,24 @@ name: hops description: | Hops CLI and Crossplane platform toolkit. Use when working with hops commands, - Crossplane configuration packages, XR lifecycle workflows (observe/adopt/manage), - secrets management (SOPS + AWS Secrets Manager), or local control plane setup. + Crossplane configuration packages, provider install (local source or published), + local control plane / gitops workbench, XR lifecycle (observe/adopt/manage), + secrets (SOPS + AWS SM), or iterating on stacks from source with + `hops config install --path` / `hops provider install --path` and `--gitops`. --- # Hops CLI `hops` is a CLI for Crossplane development and XR lifecycle workflows. It manages local -control planes, configuration packages, secrets, and live infrastructure adoption. +control planes, configuration packages, providers, secrets, and live infrastructure adoption. ## Quick Reference | Command area | Purpose | |-------------|---------| -| `hops local` | Local Colima-based control plane setup | -| `hops config` | Build, install, and uninstall Crossplane configuration packages | +| `hops local` | Local CP (dory/colima/kind), gitops cluster/worktree, workbench | +| `hops config` | Install Configuration packages (published **or** source + gitops) | +| `hops provider` | Install/patch Providers (published **or** source + SemVer-safe tags + gitops) | | `hops secrets` | SOPS encrypt/decrypt, sync to AWS Secrets Manager or GitHub | | `hops vars` | Declarative non-secret config (e.g. GitHub Actions repo variables) | | `hops xr` | Observe/adopt/manage/orphan existing infrastructure | @@ -26,14 +29,87 @@ control planes, configuration packages, secrets, and live infrastructure adoptio For detailed reference on each area, see the bundled references: -- [Config install modes and local/published switching](references/config-install.md) +- **[Local source packages & providers](references/local-source-packages.md)** — **read this** when developing configs/providers on a laptop CP +- [Config install modes and gitops](references/config-install.md) +- [Local workbench (gitops cluster / worktree)](references/local-workbench.md) +- [Local control plane setup](references/local-setup.md) - [XR observe → adopt → manage workflow](references/xr-workflow.md) - [Secrets management](references/secrets.md) - [Vars management (declarative GH Actions variables)](references/vars.md) -- [Local control plane setup](references/local-setup.md) - [Available stacks and XRs](references/stacks-and-xrs.md) - [Debugging with kubectl](references/debugging.md) +--- + +## Local control plane + platform packages (dogfood) + +```bash +hops local start --backend dory --gitops ./gitops/cluster +# bootstrap writes helm/k8s providers + ProviderConfigs (default) into the tree, +# then runs cluster gitops (apply + watch) + +hops config install --repo hops-ops/psql-stack --version v0.9.1 \ + --gitops ./gitops/cluster --local +hops config install --repo hops-ops/auth-stack --version v1.6.0 \ + --gitops ./gitops/cluster --local + +hops local gitops cluster ./gitops/cluster # watches by default; --once for CI +hops local gitops worktree ./gitops/envs/local --name dogfood +``` + +- **`--gitops`** materializes pins under the cluster tree (not one-shot-only kubectl) +- **`--local`** on config install scaffolds stack XRs with ProviderConfig **`default`** +- Backend preference is **user-local** (`~/.hops/local/backend`), not `.hops.yaml` + +--- + +## Developing packages from source (must know) + +When changing XRDs/compositions or a provider implementation on the local CP: + +### Configuration packages (stacks) + +```bash +hops config install --path xrs/stacks/k8s/auth --gitops ./gitops/cluster --local +# or --watch for rebuild-on-save +``` + +| Mode | Gitops writes | +|------|----------------| +| Published `--repo --version` | `packages/*.yaml` only | +| Source `--path` | `packages/*.yaml` **+** `imageconfigs/*` (required) | + +Without ImageConfigs, source function packages will not pull from the local registry on gitops re-apply. + +### Providers + +```bash +# Cluster must already have the upstream Provider (e.g. from start) +hops provider install --path /path/to/provider-helm --gitops ./gitops/cluster +``` + +| Mode | `spec.package` | Gitops | +|------|----------------|--------| +| Published | real tag `v1.3.0` | `providers/` + `runtime/` | +| Source | **upstream URL** + **`vMAJOR.999.N`** (not bare `dev-sha`) | + `imageconfigs/` | + +**Why `v1.999.N`:** Configuration deps use `>=vMAJOR`. Masterminds/semver **excludes prereleases**, so `dev-abc` or `v1.999.1-dev-sha` break dep resolution. ImageConfig rewrites **fetch** only; Lock source stays the upstream package path. + +Optional: `--version-prefix v1` to force major for the 999 scheme. + +### Auth-stack XR surface + +| XR | When | +|----|------| +| **AuthStack** | Platform Zitadel install | +| **MachineUser** | Machine identity + optional PAT | +| **Grant** | Roles on a project (same/cross-org) — prefer over raw grant MRs | +| Provider MRs | Project, HumanUser, Oidc app — no thin 1:1 hops HumanUser wrapper | + +Full detail: [local-source-packages.md](references/local-source-packages.md). + +--- + ## Crossplane Conventions - **Crossplane 2+**: Use `managementPolicies`, never `deletionPolicy` on managed resources diff --git a/skills/claude/references/config-install.md b/skills/claude/references/config-install.md index dc23615..02f52cb 100644 --- a/skills/claude/references/config-install.md +++ b/skills/claude/references/config-install.md @@ -1,8 +1,12 @@ # Config Install Reference +> **Developing stacks or providers from source on a local CP?** Start with +> [local-source-packages.md](./local-source-packages.md) — published vs source, +> ImageConfigs, and the provider `vMAJOR.999.N` SemVer rule. + ## Two Install Modes -### Source-build mode (`--path` or `--repo`) +### Source-build mode (`--path` or `--repo` without `--version`) Builds an Upbound-format XRD project locally and pushes through the local registry. Intended for local control planes started with `hops local start`. @@ -35,7 +39,7 @@ hops config install --path /path/to/project --watch **What happens:** 1. Runs `up project build` to create `.uppkg` artifacts 2. Loads images via `docker load` -3. Pushes render function images to `localhost:30500` (local registry) +3. Ensures registry **host access** the same way workbench/gitops exposes services: map-mode `kubectl port-forward -n crossplane-system svc/registry 30500:5000` on `127.0.0.1` (PID under `~/.hops/local/`). Then pushes images to `127.0.0.1:30500`. No dory fork / NodePort publish required. 4. Creates ImageConfig rewrites so Crossplane pulls from the in-cluster registry 5. Patches the configuration package metadata with local render digests 6. Applies the Configuration resource @@ -54,6 +58,101 @@ hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 3. Deletes inactive ConfigurationRevisions pointing at the local registry 4. Applies Configuration with `ghcr.io//:` +## Cluster gitops: `--gitops` (+ `--local`) + +Prefer this when dogfooding a **local control plane** so package pins live in +gitops (not only as one-shot kubectl applies). + +```bash +# 1. Bootstrap CP (creates helm/k8s ProviderConfigs named "default") +hops local start --backend dory + +# 2. Install published stacks + write package YAML under gitops/cluster +hops config install --repo hops-ops/psql-stack --version v0.9.1 \ + --gitops ./gitops/cluster --local + +hops config install --repo hops-ops/auth-stack --version v1.6.0 \ + --gitops ./gitops/cluster --local + +# 3. Day-to-day: apply/watch the tree (packages + XRs) +hops local gitops cluster ./gitops/cluster +# or: hops local start --backend dory --gitops ./gitops/cluster +``` + +| Flag | Effect | +|------|--------| +| `--gitops PATH` | After install, write package gitops under `PATH` (see modes below) | +| `--local` | Requires `--gitops`. Also scaffolds local XR YAMLs when known (e.g. `psql/stack.yaml`, `auth/stack.yaml`) with **`helmProviderConfigRef` / `kubernetesProviderConfigRef` → `name: default`**. Does **not** overwrite existing XR files. | + +### Published vs source under `--gitops` + +| Mode | Writes | +|------|--------| +| **`--repo --version`** (published) | `packages/.yaml` only (ghcr pin) | +| **`--path` / source** | `packages/.yaml` (local registry `dev-*` ref, `packagePullPolicy: Always`) **and** `imageconfigs/*.yaml` (ImageConfig rewrites so Crossplane pulls render functions from the in-cluster registry) | + +Source packages **need** those ImageConfigs in gitops. Without them, `hops local gitops cluster` re-apply cannot resolve function packages. Switching back to published: remove stale `imageconfigs/hops-local-rewrite-*` files (or re-run `--repo --version --gitops` and delete them manually for now). + +### Why `--local` ProviderConfigs? + +`hops local start` installs Helm + Kubernetes ProviderConfigs named **`default`**. +Stack XRs default provider config names from `spec.clusterName` (e.g. `dory`), which +does **not** exist unless you create matching ProviderConfigs. + +Local XR scaffolds therefore pin: + +```yaml +helmProviderConfigRef: + name: default +kubernetesProviderConfigRef: + name: default +``` + +`clusterName` remains a logical label (and may match your backend name). + +### What gets written + +```text +gitops/cluster/ + packages/psql-stack.yaml # Configuration (published or local registry) + packages/auth-stack.yaml + imageconfigs/hops-local-rewrite-….yaml # source builds only + psql/stack.yaml # only with --local, if missing + auth/stack.yaml # only with --local, if missing +``` + +Re-apply anytime with `hops local gitops cluster ./gitops/cluster` (watches by default). + +```bash +# Develop auth-stack XRs from source + keep gitops coherent +hops config install --path xrs/stacks/k8s/auth --gitops ./gitops/cluster --local +hops local gitops cluster ./gitops/cluster --once +``` + +## Provider install (`hops provider install`) + gitops + +Same published vs source idea. **Source builds use a SemVer-compatible tag** so +Configuration deps like `>=v1` keep resolving: + +| Mode | `spec.package` tag | Gitops extras | +|------|-------------------|---------------| +| **Published** `--repo --version` | real release (`v1.3.0`) | `providers/` + `runtime/` only | +| **Source** `--path` | **`vMAJOR.999.`** (auto-increment) on the **upstream** URL prefix | + `imageconfigs/` rewrite to local registry | + +Crossplane’s dep manager records the **upstream** package URL in the Lock; +ImageConfig only rewrites **fetch**. Never write a bare `dev-` tag into +gitops for providers that Configurations depend on — Masterminds/semver treats +prereleases specially and `>=v1` will not match. + +```bash +# Bootstrap first so an upstream Provider exists to patch +hops local start --gitops ./gitops/cluster + +# Iterate on provider-helm from source (preserves v1.999.N + ImageConfig in gitops) +hops provider install --path /path/to/provider-helm --gitops ./gitops/cluster +# or force major: --version-prefix v1 +``` + ## Switching Between Local and Published The CLI handles cleanup automatically when switching modes: @@ -67,7 +166,9 @@ The CLI handles cleanup automatically when switching modes: ## Configuration Naming Configurations are named `-`, e.g. `hops-ops-aws-secret-stack`. -This matches both local and published installs. +This matches both local and published installs. Gitops package **filenames** use +the short package name (`psql-stack.yaml`); `metadata.name` matches the applied +Configuration. ## Uninstall @@ -92,6 +193,8 @@ Functions, Providers) and ImageConfig rewrites. | `--path` | Source build | Path to local XRD project | | `--repo` | Both modes | GitHub `/` | | `--version` | Remote mode | Version tag (e.g. `v0.11.0`) | +| `--gitops PATH` | All modes | Write Configuration YAML under cluster gitops tree | +| `--local` | With `--gitops` | Scaffold local XRs using ProviderConfig `default` | | `--watch` | Source build | Re-run install on filesystem changes | | `--debounce` | Used with `--watch` | Quiet interval in seconds before rebuild (default 15) | | `--skip-dependency-resolution` | All modes | Set `spec.skipDependencyResolution=true` | diff --git a/skills/claude/references/local-setup.md b/skills/claude/references/local-setup.md index f59cda7..9b95ad8 100644 --- a/skills/claude/references/local-setup.md +++ b/skills/claude/references/local-setup.md @@ -3,33 +3,63 @@ ## Quick Start ```bash -# 1. Install Colima -hops local install +# 1. Start local k8s + Crossplane + providers + registry +# (backend preference is user-local: ~/.hops/local/backend) +hops local start --backend dory -# 2. Start local k8s + Crossplane + providers + registry -hops local start +# 2. Install platform packages into the CP *and* pin them in cluster gitops +hops config install --repo hops-ops/psql-stack --version v0.9.1 \ + --gitops ./gitops/cluster --local +hops config install --repo hops-ops/auth-stack --version v1.6.0 \ + --gitops ./gitops/cluster --local -# 3. Configure AWS provider auth from your AWS profile -hops local aws --profile hops +# 3. Watch/apply cluster gitops (packages + XRs). Or pass --gitops on start. +hops local gitops cluster ./gitops/cluster -# 4. Configure GitHub provider auth from gh CLI +# 4. Optional cloud provider auth (writes live Secrets; use --gitops for non-secret YAML) +hops local aws --profile hops hops local github --owner hops-ops - -# 5. Install a configuration package -hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 ``` +**Local CP note:** `hops local start` creates Helm/Kubernetes ProviderConfigs named +`default`. Stack XRs must pin `helmProviderConfigRef` / `kubernetesProviderConfigRef` +to `default` (scaffolded by `config install --gitops --local`). See +[config-install.md](./config-install.md). + ## Commands ### `hops local install` Installs Colima via Homebrew. ### `hops local start` -- Starts Colima with `--kubernetes --cpu 8 --memory 16 --disk 60` -- Installs Crossplane from `crossplane-stable/crossplane` -- Applies bootstrap manifests: runtime config, providers, provider configs, registry -- Configures Docker for insecure pulls from the in-cluster registry -- Adds host mapping for `registry.crossplane-system.svc.cluster.local` +- Starts the chosen backend (colima / kind / dory) +- Installs **pinned** Crossplane Helm chart (`CROSSPLANE_CHART_VERSION` in `start.rs`) +- Applies bootstrap Providers (pinned tags in `bootstrap/providers/`): + - `provider-helm` (needs ≥ v1.3.0 for Zitadel chart JSON-schema $ref fix) + - `provider-kubernetes` +- Applies ProviderConfigs named `default`, local registry, DRCs +- Configures node trust for the in-cluster registry + +With **`--gitops PATH`** (e.g. `./gitops/cluster`): +1. Writes the same helm/k8s bootstrap into the tree (`providers/`, `providerconfigs/`, `runtime/`) +2. Runs `hops local gitops cluster PATH` (apply + watch) so day-to-day CP state is gitops-owned + +```bash +hops local start --backend dory --gitops ./gitops/cluster +``` + +**Version bumps:** Renovate owns these pins (`cli/renovate.json` customManagers → +github-releases). Prefer merging Renovate PRs (`local-start-crossplane-bootstrap` +group) over hand-editing tags. + +**Replace a bootstrap provider with a local build** (keep SemVer deps working): + +```bash +hops provider install --path /path/to/provider-helm --gitops ./gitops/cluster +# writes providers/ + runtime/ + imageconfigs/ with vMAJOR.999.N package pin +``` + +See [local-source-packages.md](./local-source-packages.md). ### `hops local stop` / `hops local destroy` / `hops local uninstall` Stop, delete, or uninstall Colima respectively. @@ -77,7 +107,7 @@ Installs GitHub provider and bootstraps auth. │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ - localhost:30500 → registry:5000 + 127.0.0.1:30500 → registry:5000 (host push; use IPv4 not localhost) ``` ## Logging diff --git a/skills/claude/references/local-source-packages.md b/skills/claude/references/local-source-packages.md new file mode 100644 index 0000000..26e7e74 --- /dev/null +++ b/skills/claude/references/local-source-packages.md @@ -0,0 +1,164 @@ +# Local source: Configuration packages & Providers + +Use this when **developing or dogfooding** Crossplane packages on a laptop CP +(`hops local start`), not when consuming published tags only. + +Always pair installs with **`--gitops ./gitops/cluster`** so +`hops local gitops cluster` can re-apply the same pins (and ImageConfigs). + +## Mental model + +| | Configuration packages (`hops config install`) | Providers (`hops provider install`) | +|--|-----------------------------------------------|-------------------------------------| +| **Published** | `ghcr.io/…:vX.Y.Z` | same | +| **Source** | local registry `dev-` tag + **ImageConfig** rewrites for render functions | **upstream URL** + **`vMAJOR.999.N`** tag + **ImageConfig** rewrite + runtime image | +| **Why ImageConfig** | Crossplane must pull function packages from the in-cluster registry | Same; Lock still records **upstream** package URL | +| **SemVer / deps** | Configurations depend on providers with `>=vMAJOR` | Source tags **must** be real SemVer (`v1.999.3`), **not** bare `dev-sha` (prereleases fail `>=v1`) | + +ImageConfig **rewrites fetch only**. Package identity in the Lock stays the +upstream path — do not put `registry.crossplane-system…` in `spec.package` for +providers that Configurations depend on. + +## Prerequisites + +```bash +hops local start --backend dory --gitops ./gitops/cluster +# Creates: Crossplane, helm/k8s providers (pinned), ProviderConfigs named "default", +# local OCI registry, and writes bootstrap YAML under gitops/cluster/ +``` + +ProviderConfigs for stack XRs: always pin `helmProviderConfigRef` / +`kubernetesProviderConfigRef` to **`name: default`** on a local CP. + +## Configuration packages (XRD stacks) + +### Published (stable dogfood) + +```bash +hops config install --repo hops-ops/auth-stack --version v1.6.0 \ + --gitops ./gitops/cluster --local +hops config install --repo hops-ops/psql-stack --version v0.9.1 \ + --gitops ./gitops/cluster --local +``` + +Writes: `packages/.yaml` only (+ optional XR scaffolds with `--local`). + +### Source (edit compositions / XRDs) + +```bash +# From meta (or any checkout of the Upbound project) +hops config install --path xrs/stacks/k8s/auth --gitops ./gitops/cluster --local +# tight loop: +hops config install --path xrs/stacks/k8s/auth --watch --gitops ./gitops/cluster +``` + +Writes: + +```text +packages/auth-stack.yaml # local registry pull ref, packagePullPolicy: Always +imageconfigs/hops-local-rewrite-*.yaml # REQUIRED for source — do not omit +auth/stack.yaml # --local only, if missing +``` + +Then: + +```bash +hops local gitops cluster ./gitops/cluster --once # or leave watching +``` + +### Switch source ↔ published + +- **→ published:** `hops config install --repo … --version … --gitops …` + CLI cleans stale Functions / ImageConfigs on the cluster; **delete** stale + `gitops/cluster/imageconfigs/hops-local-rewrite-*` if left from source. +- **→ source:** `hops config install --path … --gitops …` rewrites package + imageconfigs. + +## Providers + +`hops provider install` **patches an existing Provider** (from bootstrap or +gitops). It does not create a brand-new Provider from nothing. + +### Published + +```bash +hops provider install --repo crossplane-contrib/provider-helm --version v1.3.0 \ + --gitops ./gitops/cluster +``` + +### Source (preserve SemVer for Configuration deps) + +```bash +# Existing cluster Provider must already be the upstream one (e.g. from start) +hops provider install --path /path/to/provider-helm --gitops ./gitops/cluster + +# Force major for >=v1 constraints (same 999 scheme): +hops provider install --path /path/to/provider-helm --version-prefix v1 \ + --gitops ./gitops/cluster +``` + +Source **`spec.package`** looks like: + +```text +xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.999.3 +``` + +not `…:dev-abc` and not `registry.crossplane-system…/…`. + +Writes: + +```text +providers/.yaml +runtime/.yaml # DRC + ClusterRoleBinding +imageconfigs/hops-local-rewrite-*.yaml # source only +``` + +## Auth-stack XR surface (while developing from source) + +| XR | Use | +|----|-----| +| **AuthStack** | Install Zitadel platform | +| **MachineUser** | Machine identity + optional PAT | +| **Grant** | User → project roles (same-org / cross-org) — prefer over raw grant MRs | +| Provider MRs | Project, Role, HumanUser, Oidc app — thin 1:1 types; no hops HumanUser XR | + +Thin wrappers that only compose one MR are misdirection; Grant is multi-path and worth it. + +## Cluster gitops tree (typical) + +```text +gitops/cluster/ + providers/helm.yaml kubernetes.yaml # start --gitops and/or provider install + providerconfigs/helm.yaml kubernetes.yaml + runtime/… + packages/… # config install + imageconfigs/… # source config or provider only + auth/stack.yaml psql/stack.yaml # platform XRs +``` + +Day-to-day: + +```bash +hops local gitops cluster ./gitops/cluster # watches by default +hops local gitops worktree ./gitops/envs/local --name dogfood +``` + +## Do / don’t + +**Do** + +- Use `--gitops` whenever installing for a shared cluster tree +- Keep source provider tags as `vMAJOR.999.N` +- Re-run install after source changes (`--watch` optional) +- Pin local XRs to ProviderConfig `default` + +**Don’t** + +- Commit bare `dev-` provider tags into gitops when stacks depend on `>=vN` +- Drop ImageConfigs for source packages/providers and expect gitops re-apply to work +- Put machine backend choice in project `.hops.yaml` (use `~/.hops/local/backend`) + +## See also + +- [config-install.md](./config-install.md) — flags and mode details +- [local-workbench.md](./local-workbench.md) — cluster vs worktree gitops +- [local-setup.md](./local-setup.md) — `hops local start` bootstrap diff --git a/skills/claude/references/local-workbench.md b/skills/claude/references/local-workbench.md new file mode 100644 index 0000000..6449a56 --- /dev/null +++ b/skills/claude/references/local-workbench.md @@ -0,0 +1,150 @@ +# Local Workbench (happy path) + +Develop against the laptop control plane without learning volumes, hostPath, or sync tools. + +## One-time prerequisite + +Start the local control plane (optional `--gitops` = bootstrap then full +`gitops cluster` apply + watch until Ctrl+C): + +```bash +hops local start --backend dory --gitops ./gitops/cluster +``` + +Also need `helm` and `kubectl` on your PATH. + +## Daily loop + +```bash +# Shared CP watch (if start did not use --gitops, or after Ctrl+C) +hops local gitops cluster ./gitops/cluster + +# Per-worktree apps (Application YAMLs → namespace = --name) — watches by default +hops local gitops worktree ./gitops/envs/local --name dogfood + +# See workspaces and app URLs +hops local status + +# Open the UI in a browser +hops local open + +# When finished +hops local down +# Optional: delete the namespace too +hops local down --purge +``` + +Watch is the default for both gitops commands. Use `--once` for a single reconcile (CI/scripts). + +## Concurrent worktrees + +Use a distinct name per worktree so namespaces and URLs stay isolated: + +```bash +# Terminal A +hops local gitops worktree ./gitops/envs/local --name alice + +# Terminal B +hops local gitops worktree ./gitops/envs/local --name bob + +hops local status +hops local down --name alice +hops local down --name bob +``` + +Each name maps to namespace `` and gets its own access URLs. + +## Dogfood: e2e-ui + +```bash +cd distributed/tests/e2e-ui +hops local start --backend dory --gitops ./gitops/cluster +hops local gitops cluster ./gitops/cluster +hops local gitops worktree ./gitops/envs/local --name dogfood +hops local status +hops local open +hops local down --name dogfood --purge +``` + +Charts live under `api/.gitops/deploy` and `ui/.gitops/deploy`. You can also render them without hops: + +```bash +helm template api ./api/.gitops/deploy --set local=true --set appRuntime=cluster-dev +helm template ui ./ui/.gitops/deploy --set local=true --set appRuntime=cluster-dev +``` + +### Agent rules (do not skip) + +Dogfood apps run as **`appRuntime: cluster-dev`** in namespace `= --name` with +source delivery into the pods. The site you must fix is **that** stack — not a +host `make run` you invent. + +**When the dogfood site is broken:** + +1. **Confirm runtime first** (`KUBECONFIG` = dory, e.g. `~/.kube/dory-config`): + ```bash + kubectl -n dogfood get pods + kubectl -n dogfood logs deploy/e2e-ui-api --tail=40 + kubectl -n dogfood logs deploy/e2e-ui-ui --tail=40 + ``` +2. **Compile before theorizing.** API is `cargo run` in a rust image; UI is + `npm install` + `js` build + `vite` in a node image. `rollout restart` alone + is not “fixed” until: + - API log shows `listening on http://0.0.0.0:8791` (not mid-`Compiling`) + - UI log shows Vite ready +3. **Hit the real URLs** (cluster DNS / host FQDN path), not only pod logs: + ```bash + UI=http://e2e-ui-ui.dogfood.svc.cluster.local:5180 + API=http://e2e-ui-api.dogfood.svc.cluster.local:8791 + curl -sS -o /dev/null -w '%{http_code}\n' "$UI/" "$UI/chat" + curl -sS -X POST "$API/graphql" -H 'content-type: application/json' \ + -d '{"query":"{ __typename }"}' + ``` +4. After service / GraphQL / pure / client changes on the host worktree, run + **`make gen-client`** in `tests/e2e-ui` (js build + wasm + client generate) + so delivery syncs artifacts the UI pod will load. Then restart pods and + **wait for compile**. +5. Symptoms that are usually **stale binary / mid-compile / stale clients**, not + a roles redesign: `replica artifact schema does not match…`, + `createWasmJsonPure is not a function`, surface open errors that unit tests + pass for. Recompile + re-hit the site first. +6. **Do not** paper over with more chart churn, host-only Makefile “watch” + experiments, or long GraphQL protocol essays when the pod never finished + building. **Do not** declare success without curling the live UI paths. + +**Kubeconfig:** prefer `~/.kube/dory-config` for dory; map host access uses +cluster FQDNs (`*.svc.cluster.local`), not `localhost` alone. + +## Layout + +```text +gitops/ + cluster/ # shared CP (one per machine) — hops local gitops cluster + envs/local/ # app Applications — hops local gitops worktree +``` + +- **cluster** — not per-worktree; packages + platform XRs on the local CP +- **worktree** — env Application YAMLs into namespace `= --name` + +## Developing configs & providers on this CP + +Iterate from **source** while keeping the cluster gitops tree coherent: + +```bash +# Stack XRDs / compositions +hops config install --path xrs/stacks/k8s/auth --gitops ./gitops/cluster --local + +# Provider implementation (SemVer-safe vMAJOR.999.N + ImageConfig in gitops) +hops provider install --path /path/to/provider-helm --gitops ./gitops/cluster +``` + +Source installs write **`imageconfigs/`** rewrites; published installs do not. +Provider source tags must stay **`vMAJOR.999.N`** (not bare `dev-sha`) so +Configuration deps like `>=v1` still resolve. + +See [local-source-packages.md](./local-source-packages.md). + +## Advanced + +- `--once` on either gitops subcommand for one-shot / CI +- Compose-style host run remains available via e2e-ui `make up` / `make run` diff --git a/skills/claude/references/secrets.md b/skills/claude/references/secrets.md index ace8643..3c83471 100644 --- a/skills/claude/references/secrets.md +++ b/skills/claude/references/secrets.md @@ -3,7 +3,7 @@ ## Overview `hops secrets` manages repo-level secrets using SOPS for encryption and syncs -to AWS Secrets Manager or GitHub repository secrets. +to AWS Secrets Manager, GitHub repository secrets, or HashiCorp Vault (KV). ## Setup @@ -20,9 +20,11 @@ secrets/ # Plaintext (gitignored) aws/ github/ _shared/ + vault/ # → hops secrets sync vault (KV paths) secrets-encrypted/ # SOPS-encrypted (committed) aws/ github/ + vault/ ``` ### Configuration (`.hops.yaml`) @@ -44,6 +46,18 @@ secrets: repos: - repo-a - repo-b + vault: + path: vault + address: http://127.0.0.1:8200 # or $VAULT_ADDR + mount: secret # KV mount + version: v2 + path_prefix: "" # optional prefix on every remote path + token_env: VAULT_TOKEN + kube: # port-forward when address is down + enabled: true + namespace: vault + service: vault + local_port: 8200 ``` ## Encrypt / Decrypt @@ -94,3 +108,25 @@ hops secrets sync github - `.env` files → one secret per `KEY=value` entry - Shared secrets fan out to all repos in `shared_secrets.repos` - Repo-specific values override shared values + +## Sync to HashiCorp Vault (KV) + +```bash +export VAULT_TOKEN=root # local SecretStack dev Vault; never commit +hops secrets sync vault +hops secrets sync vault --secret-path secrets/vault/e2e-ui/dogfood -y +hops secrets sync vault --port-forward # force kubectl tunnel to in-cluster Vault +``` + +### Vault naming rules (same roll-up as AWS) + +| Source | Vault KV path (mount `secret`) | +|--------|--------------------------------| +| `secrets/vault/e2e-ui/dogfood/oidc.json` | `e2e-ui/dogfood/oidc` (JSON object → properties) | +| `secrets/vault/e2e-ui/dogfood/human-passwords/{alice,bob}` | `e2e-ui/dogfood/human-passwords` | +| `secrets/vault/auth/zitadel-masterkey/masterkey` | `auth/zitadel-masterkey` property `masterkey` | + +- Paths match ExternalSecret `remoteRef.key` (no `secret/data/` prefix) +- Writer token is separate from ESO’s read-only kubernetes auth role +- Unchanged remote maps are skipped (compare-before-write) +- Local Vault chart is often in-memory: re-run sync after `vault-0` restarts; keep SOPS plaintext as the durable copy diff --git a/skills/claude/references/stacks-and-xrs.md b/skills/claude/references/stacks-and-xrs.md index 3f6e98a..b151c7d 100644 --- a/skills/claude/references/stacks-and-xrs.md +++ b/skills/claude/references/stacks-and-xrs.md @@ -53,11 +53,28 @@ All XRs share these conventions: ## Package Installation ```bash -# Published version +# Published version (apply only) hops config install --repo hops-ops/ --version +# Published + write into local cluster gitops (preferred for local CP dogfood) +hops config install --repo hops-ops/psql-stack --version v0.9.1 \ + --gitops ./gitops/cluster --local +hops config install --repo hops-ops/auth-stack --version v1.6.0 \ + --gitops ./gitops/cluster --local + # Local source build -hops config install --path xrs/aws/_stacks/observe +hops config install --path xrs/stacks/k8s/psql --gitops ./gitops/cluster ``` All packages are published to `ghcr.io/hops-ops/`. + +`--gitops` writes `packages/.yaml`. `--local` scaffolds XRs that pin +Helm/Kubernetes ProviderConfigs to `default` (from `hops local start`). Then: + +```bash +hops local gitops cluster ./gitops/cluster +``` + +See [config-install.md](./config-install.md) for the full local gitops flow, and +[local-source-packages.md](./local-source-packages.md) when iterating from +`--path` (source) instead of published tags. diff --git a/src/commands/local/aws.rs b/src/commands/local/aws.rs index e2c567e..26778a0 100644 --- a/src/commands/local/aws.rs +++ b/src/commands/local/aws.rs @@ -1,8 +1,10 @@ +use super::gitops_write::{log_written, write_gitops_files, GitopsFile}; use super::{kubectl_apply_stdin, run_cmd, run_cmd_output}; use clap::Args; use serde::Deserialize; use std::error::Error; use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; use std::thread; use std::time::Duration; @@ -52,6 +54,12 @@ pub struct AwsArgs { /// Refresh credentials and AWS runtime region; skips Provider and ProviderConfig apply #[arg(long)] pub refresh: bool, + + /// Write non-secret Provider / DeploymentRuntimeConfig / ProviderConfig YAML + /// under this directory (e.g. `./gitops/cluster`). Credential Secrets are + /// **not** written — still applied live only. + #[arg(long)] + pub gitops: Option, } #[derive(Debug, Deserialize)] @@ -103,25 +111,55 @@ pub fn run(args: &AwsArgs) -> Result<(), Box> { return Ok(()); } + let runtime_yaml = build_runtime_config_yaml(&args.runtime_config_name, ®ion); + let provider_yaml = build_provider_yaml( + &args.provider_name, + &args.provider_package, + &args.runtime_config_name, + ); + let provider_config_yaml = build_provider_config_yaml( + &args.namespace, + &args.provider_config_name, + &args.secret_name, + ); + + if let Some(gitops) = &args.gitops { + let written = write_gitops_files( + gitops, + &[ + GitopsFile { + rel_path: "runtime/aws.yaml".into(), + yaml: runtime_yaml.clone(), + }, + GitopsFile { + rel_path: "providers/aws.yaml".into(), + yaml: provider_yaml.clone(), + }, + GitopsFile { + rel_path: "providerconfigs/aws.yaml".into(), + yaml: provider_config_yaml.clone(), + }, + ], + )?; + log_written(&written); + log::info!( + "AWS non-secret manifests written under {} (Secret still applied live only)", + gitops.display() + ); + } + log::info!( "Applying AWS provider runtime '{}' for region '{}'...", args.runtime_config_name, region ); - kubectl_apply_stdin(&build_runtime_config_yaml( - &args.runtime_config_name, - ®ion, - ))?; + kubectl_apply_stdin(&runtime_yaml)?; log::info!( "Applying provider-family-aws package '{}'...", args.provider_package ); - kubectl_apply_stdin(&build_provider_yaml( - &args.provider_name, - &args.provider_package, - &args.runtime_config_name, - ))?; + kubectl_apply_stdin(&provider_yaml)?; wait_for_crd(PROVIDER_CONFIG_CRD)?; @@ -141,11 +179,7 @@ pub fn run(args: &AwsArgs) -> Result<(), Box> { args.namespace, args.provider_config_name ); - kubectl_apply_stdin(&build_provider_config_yaml( - &args.namespace, - &args.provider_config_name, - &args.secret_name, - ))?; + kubectl_apply_stdin(&provider_config_yaml)?; log::info!( "AWS provider configured from profile '{}' for region '{}' (ProviderConfig: {}/{})", diff --git a/src/commands/local/backend/dory.rs b/src/commands/local/backend/dory.rs index 7a81497..48efcc5 100644 --- a/src/commands/local/backend/dory.rs +++ b/src/commands/local/backend/dory.rs @@ -585,6 +585,8 @@ fn ensure_engine_env_only() { } /// Fallback when merge fails: point hops child processes at the side file. +/// Prefer `~/.kube/dory-config` first so its certs win over a stale `dory` +/// user that may still live in `~/.kube/config` after cluster recreate. pub fn export_kubeconfig_env() { let Some(dory_cfg) = kubeconfig_path() else { return; @@ -593,21 +595,22 @@ pub fn export_kubeconfig_env() { return; } let existing = std::env::var("KUBECONFIG").unwrap_or_default(); - if existing.split(':').any(|p| p == dory_cfg) { - return; - } - let rest = if existing.is_empty() { + let parts: Vec<&str> = existing + .split(':') + .filter(|p| !p.is_empty() && *p != dory_cfg.as_str()) + .collect(); + let rest = if parts.is_empty() { match home() { Ok(h) => h.join(".kube/config").to_string_lossy().into_owned(), Err(_) => String::new(), } } else { - existing + parts.join(":") }; if rest.is_empty() { std::env::set_var("KUBECONFIG", &dory_cfg); } else { - std::env::set_var("KUBECONFIG", format!("{}:{}", dory_cfg, rest)); + std::env::set_var("KUBECONFIG", format!("{dory_cfg}:{rest}")); } } diff --git a/src/commands/local/backend/kind.rs b/src/commands/local/backend/kind.rs index 005172d..93adb91 100644 --- a/src/commands/local/backend/kind.rs +++ b/src/commands/local/backend/kind.rs @@ -194,7 +194,7 @@ fn wait_for_api_after_restart() -> Result<(), Box> { } /// Alias both registry pull names to the registry Service's ClusterIP via -/// containerd certs.d files on the node. `localhost:30500` is what provider +/// containerd certs.d files on the node. `127.0.0.1:30500` is what provider /// runtime pods reference; aliasing it here means the name never depends on /// kube-proxy's localhost-NodePort behavior. Files live on the node's /// writable layer, so they survive docker stop/start (unlike /etc/hosts, diff --git a/src/commands/local/down.rs b/src/commands/local/down.rs new file mode 100644 index 0000000..26b6dd6 --- /dev/null +++ b/src/commands/local/down.rs @@ -0,0 +1,82 @@ +//! `hops local down` — stop workspace host access, delivery, and optionally purge namespace. + +use super::up::stop_delivery_runtime; +use super::workbench::net::stop_host_access; +use super::workbench::registry::{ + list_workspaces, load_workspace, namespace_for_name, remove_workspace, +}; +use super::{local_state_dir, run_cmd}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct DownArgs { + /// Workspace name (default: only workspace if exactly one registered). + #[arg(long)] + pub name: Option, + + /// Delete the workspace namespace and labeled resources. + #[arg(long, default_value_t = false)] + pub purge: bool, +} + +pub fn run(args: &DownArgs) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let name = match &args.name { + Some(n) => n.clone(), + None => { + let all = list_workspaces(&state_dir)?; + match all.as_slice() { + [only] => only.name.clone(), + [] => { + return Err( + "No workspaces registered. Pass --name or run hops local up first.".into(), + ) + } + _ => { + return Err(format!( + "Multiple workspaces registered ({}); pass --name .", + all.iter() + .map(|w| w.name.as_str()) + .collect::>() + .join(", ") + ) + .into()) + } + } + } + }; + + let record = load_workspace(&state_dir, &name)?; + let namespace = record + .as_ref() + .map(|r| r.namespace.clone()) + .unwrap_or_else(|| namespace_for_name(&name)); + + log::info!("Bringing down workspace `{name}` (namespace {namespace})"); + + // Stop host access processes started by `up` (recorded PIDs + pkill safety net) + if let Err(e) = stop_host_access(&state_dir, &name) { + log::warn!("host access stop: {e}"); + } + + // Stop mutagen / tar sync watchers + stop_delivery_runtime(&state_dir, &name); + + if args.purge { + log::info!("Purging namespace {namespace}..."); + match run_cmd( + "kubectl", + &["delete", "namespace", &namespace, "--wait=false"], + ) { + Ok(()) => log::info!("Namespace {namespace} delete requested."), + Err(e) => log::warn!("Namespace delete: {e}"), + } + } else { + log::info!("Leaving namespace {namespace} in place (pass --purge to delete)."); + } + + remove_workspace(&state_dir, &name)?; + log::info!("Workspace `{name}` unregistered."); + Ok(()) +} diff --git a/src/commands/local/github.rs b/src/commands/local/github.rs index 4c55b31..aa24aa7 100644 --- a/src/commands/local/github.rs +++ b/src/commands/local/github.rs @@ -1,8 +1,10 @@ +use super::gitops_write::{log_written, write_gitops_files, GitopsFile}; use super::{command_exists, kubectl_apply_stdin, run_cmd, run_cmd_output}; use clap::Args; use serde_json::json; use std::error::Error; use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; use std::thread; use std::time::Duration; @@ -43,6 +45,11 @@ pub struct GithubArgs { /// Refresh credentials in the secret only; skips Provider and ProviderConfig apply #[arg(long)] pub refresh: bool, + + /// Write non-secret Provider / ProviderConfig YAML under this directory + /// (e.g. `./gitops/cluster`). Credential Secrets are not written. + #[arg(long)] + pub gitops: Option, } pub fn run(args: &GithubArgs) -> Result<(), Box> { @@ -79,14 +86,39 @@ pub fn run(args: &GithubArgs) -> Result<(), Box> { return Ok(()); } + let provider_yaml = build_provider_yaml(&args.provider_name, &args.provider_package); + let provider_config_yaml = build_provider_config_yaml( + &args.namespace, + &args.provider_config_name, + &args.secret_name, + ); + + if let Some(gitops) = &args.gitops { + let written = write_gitops_files( + gitops, + &[ + GitopsFile { + rel_path: "providers/github.yaml".into(), + yaml: provider_yaml.clone(), + }, + GitopsFile { + rel_path: "providerconfigs/github.yaml".into(), + yaml: provider_config_yaml.clone(), + }, + ], + )?; + log_written(&written); + log::info!( + "GitHub non-secret manifests written under {} (Secret still applied live only)", + gitops.display() + ); + } + log::info!( "Applying provider-upjet-github package '{}'...", args.provider_package ); - kubectl_apply_stdin(&build_provider_yaml( - &args.provider_name, - &args.provider_package, - ))?; + kubectl_apply_stdin(&provider_yaml)?; wait_for_crd(PROVIDER_CONFIG_CRD)?; @@ -106,11 +138,7 @@ pub fn run(args: &GithubArgs) -> Result<(), Box> { args.namespace, args.provider_config_name ); - kubectl_apply_stdin(&build_provider_config_yaml( - &args.namespace, - &args.provider_config_name, - &args.secret_name, - ))?; + kubectl_apply_stdin(&provider_config_yaml)?; log::info!( "GitHub provider configured for owner '{}' (ProviderConfig: {}/{})", diff --git a/src/commands/local/gitops.rs b/src/commands/local/gitops.rs new file mode 100644 index 0000000..2dc3f0f --- /dev/null +++ b/src/commands/local/gitops.rs @@ -0,0 +1,351 @@ +//! `hops local gitops` — control-plane and worktree Application reconcile. +//! +//! ```text +//! hops local gitops cluster [PATH] # shared CP (meta gitops/cluster) +//! hops local gitops worktree # per-worktree apps (envs → namespace = --name) +//! ``` +//! +//! Both **watch by default**; pass `--once` for a single reconcile (CI/scripts). + +use super::workbench::application::{load_applications, resolve_delivery_host_path}; +use super::workbench::cluster_gitops::{ + reconcile_cluster_dir, resolve_cluster_path, should_reconcile_cluster_change, +}; +use super::workbench::reconcile::{ + reconcile_applications, ReconcileOptions, SystemHelm, SystemKubectl, +}; +use super::workbench::watch::{ + is_chart_or_env_path, should_ignore_watch_path, watch_roots_for_applications, WatchPathClass, +}; +use super::workbench::{namespace_for_name, slugify_name}; +use clap::{Args, Subcommand}; +use notify::{RecursiveMode, Watcher}; +use std::collections::BTreeMap; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +#[derive(Args, Debug)] +pub struct GitopsArgs { + #[command(subcommand)] + pub command: GitopsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum GitopsCommands { + /// Shared control-plane gitops (packages, PSQLStack, AuthStack → local CP) + Cluster(ClusterArgs), + /// Per-worktree app Applications (charts → namespace = --name) + Worktree(WorktreeArgs), +} + +#[derive(Args, Debug)] +pub struct ClusterArgs { + /// Path to cluster gitops directory (PSQLStack, AuthStack, packages, …). + /// Default: `$HOPS_LOCAL_CLUSTER`, else walk up from cwd for `gitops/cluster`. + #[arg(value_name = "PATH")] + pub path: Option, + + /// Run a single reconcile and exit (disables the default watch). + #[arg(long, default_value_t = false)] + pub once: bool, + + /// Watch and re-apply on YAML changes (default). Use `--once` to disable. + #[arg(long, default_value_t = false)] + pub watch: bool, + + /// Debounce seconds while watching. + #[arg(long, default_value_t = 1)] + pub debounce: u64, + + /// Server/client dry-run; do not persist to the cluster. + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +#[derive(Args, Debug)] +pub struct WorktreeArgs { + /// Path to env directory of Application YAMLs (e.g. ./gitops/envs/local). + #[arg(value_name = "PATH")] + pub path: PathBuf, + + /// Destination namespace override (workspace isolation). + #[arg(long, short = 'n')] + pub namespace: Option, + + /// Workspace name for labels (defaults from namespace / path). + #[arg(long)] + pub name: Option, + + /// Run a single reconcile and exit (disables the default watch). + #[arg(long, default_value_t = false)] + pub once: bool, + + /// Watch env + chart paths and re-reconcile (default). Use `--once` to disable. + #[arg(long, default_value_t = false)] + pub watch: bool, + + /// Debounce seconds while watching. + #[arg(long, default_value_t = 1)] + pub debounce: u64, + + /// Render only; do not apply to the cluster. + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +pub fn run(args: &GitopsArgs) -> Result<(), Box> { + match &args.command { + GitopsCommands::Cluster(a) => run_cluster(a), + GitopsCommands::Worktree(a) => run_worktree(a), + } +} + +/// Run cluster gitops (same as `hops local gitops cluster`). +/// Used by `hops local start --gitops` so start is not a special code path. +pub fn run_cluster(args: &ClusterArgs) -> Result<(), Box> { + if !args.dry_run { + if let Err(e) = super::run_cmd_output("kubectl", &["cluster-info"]) { + return Err(format!( + "Local control plane is not reachable ({e}).\n\ + Ensure Dory Kubernetes is Ready, then: hops local start --backend dory" + ) + .into()); + } + } + + let cluster = resolve_cluster_path(None, args.path.as_deref()).ok_or_else(|| { + "no cluster gitops directory found.\n\ + Pass a path: hops local gitops cluster ./gitops/cluster\n\ + Or set HOPS_LOCAL_CLUSTER, or create gitops/cluster at the meta repo root." + .to_string() + })?; + let cluster = cluster + .canonicalize() + .map_err(|e| format!("cluster path {}: {e}", cluster.display()))?; + + let dry_run = args.dry_run; + let do_once = || -> Result<(), Box> { + log::info!("cluster gitops → local CP: {}", cluster.display()); + let r = reconcile_cluster_dir(&cluster, dry_run)?; + log::info!( + "cluster gitops: {} applied, {} error(s)", + r.applied.len(), + r.errors.len() + ); + if !r.errors.is_empty() && r.applied.is_empty() { + return Err(format!( + "cluster gitops failed ({} error(s)); first: {}", + r.errors.len(), + r.errors.first().map(String::as_str).unwrap_or("") + ) + .into()); + } + Ok(()) + }; + + do_once()?; + if args.once || args.dry_run { + return Ok(()); + } + + run_cluster_watch(&cluster, args.debounce, do_once) +} + +// ── worktree ───────────────────────────────────────────────────────────────── + +fn run_worktree(args: &WorktreeArgs) -> Result<(), Box> { + let env_path = args + .path + .canonicalize() + .map_err(|e| format!("env path {}: {e}", args.path.display()))?; + + let workspace_name = args + .name + .clone() + .or_else(|| args.namespace.clone()) + .unwrap_or_else(|| { + env_path + .file_name() + .and_then(|s| s.to_str()) + .map(slugify_name) + .unwrap_or_else(|| "local".into()) + }); + + let namespace = args + .namespace + .clone() + .unwrap_or_else(|| namespace_for_name(&workspace_name)); + + let mut app_delivery_host_paths = BTreeMap::new(); + if let Ok(apps) = load_applications(&env_path) { + for (app_file, app) in apps { + if let Ok(host) = resolve_delivery_host_path(&app_file, &app) { + app_delivery_host_paths.insert(app.metadata.name, host); + } + } + } + + let opts = ReconcileOptions { + namespace: namespace.clone(), + workspace_name: workspace_name.clone(), + runtime_values: BTreeMap::new(), + app_delivery_host_paths, + delivery_mode: Some("sync".into()), + dry_run: args.dry_run, + }; + + let do_once = || -> Result<(), Box> { + log::info!( + "worktree gitops: Applications from {} → namespace {}", + env_path.display(), + opts.namespace + ); + let results = reconcile_applications(&env_path, &opts, &SystemHelm, &SystemKubectl)?; + for r in &results { + log::info!( + " {} (chart {}) {}", + r.app_name, + r.chart_path.display(), + if r.applied { "applied" } else { "rendered" } + ); + } + Ok(()) + }; + + do_once()?; + if args.once || args.dry_run { + return Ok(()); + } + + run_worktree_watch(&env_path, args.debounce, do_once) +} + +fn run_worktree_watch( + env_path: &Path, + debounce_secs: u64, + mut rebuild: F, +) -> Result<(), Box> +where + F: FnMut() -> Result<(), Box>, +{ + let roots = watch_roots_for_applications(env_path)?; + let env_canon = env_path + .canonicalize() + .unwrap_or_else(|_| env_path.to_path_buf()); + let chart_paths: Vec = roots + .iter() + .filter(|p| *p != &env_canon) + .cloned() + .collect(); + + let debounce = Duration::from_secs(debounce_secs); + let (tx, rx) = mpsc::channel(); + + let env_c = env_canon.clone(); + let charts = chart_paths.clone(); + let mut watcher = + notify::recommended_watcher(move |res: notify::Result| match res { + Ok(event) => { + for p in &event.paths { + if should_ignore_watch_path(p) { + continue; + } + if is_chart_or_env_path(p, &env_c, &charts) == WatchPathClass::ChartOrEnv { + let _ = tx.send(()); + break; + } + } + } + Err(e) => log::debug!("watch error: {e:?}"), + })?; + + for root in &roots { + if root.exists() { + watcher.watch(root, RecursiveMode::Recursive)?; + log::info!("Watching {}", root.display()); + } + } + log::info!( + "Worktree gitops watch active (debounce {}s). Env YAML + charts only. Ctrl+C to stop.", + debounce_secs + ); + + loop { + rx.recv().map_err(|_| "watcher channel closed")?; + wait_for_quiet(&rx, debounce)?; + log::info!("──────────────────────────────────────────────"); + log::info!("Worktree gitops change, reconciling..."); + match rebuild() { + Ok(()) => log::info!("Reconcile succeeded."), + Err(e) => log::error!("Reconcile failed: {e}"), + } + } +} + +// ── shared watch helpers ───────────────────────────────────────────────────── + +fn run_cluster_watch( + cluster: &Path, + debounce_secs: u64, + mut rebuild: F, +) -> Result<(), Box> +where + F: FnMut() -> Result<(), Box>, +{ + let debounce = Duration::from_secs(debounce_secs); + let (tx, rx) = mpsc::channel(); + let cluster_c = cluster.to_path_buf(); + + let mut watcher = + notify::recommended_watcher(move |res: notify::Result| match res { + Ok(event) => { + for p in &event.paths { + if should_ignore_watch_path(p) { + continue; + } + if should_reconcile_cluster_change(p, &cluster_c) { + let _ = tx.send(()); + break; + } + } + } + Err(e) => log::debug!("watch error: {e:?}"), + })?; + + watcher.watch(cluster, RecursiveMode::Recursive)?; + log::info!( + "Watching cluster gitops {} (debounce {}s). Crossplane reconciles XRs after apply. Ctrl+C to stop.", + cluster.display(), + debounce_secs + ); + + loop { + rx.recv().map_err(|_| "watcher channel closed")?; + wait_for_quiet(&rx, debounce)?; + log::info!("──────────────────────────────────────────────"); + log::info!("Cluster gitops change, applying to local CP..."); + match rebuild() { + Ok(()) => log::info!("Cluster reconcile succeeded."), + Err(e) => log::error!("Cluster reconcile failed: {e}"), + } + } +} + +fn wait_for_quiet(rx: &mpsc::Receiver<()>, debounce: Duration) -> Result<(), Box> { + let mut deadline = Instant::now() + debounce; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + match rx.recv_timeout(remaining) { + Ok(()) => deadline = Instant::now() + debounce, + Err(mpsc::RecvTimeoutError::Timeout) => return Ok(()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("watcher channel closed".into()); + } + } + } +} diff --git a/src/commands/local/gitops_write.rs b/src/commands/local/gitops_write.rs new file mode 100644 index 0000000..e4076e8 --- /dev/null +++ b/src/commands/local/gitops_write.rs @@ -0,0 +1,135 @@ +//! Write non-secret provider manifests under a gitops/cluster directory. +//! +//! Credential Secrets stay live-only until a local external-secrets story exists. +//! `--gitops` writers intentionally omit Secret data. + +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +/// One file to materialize under the gitops root. +#[derive(Debug, Clone)] +pub struct GitopsFile { + /// Path relative to the gitops root, e.g. `providers/aws.yaml`. + pub rel_path: String, + pub yaml: String, +} + +/// Resolve and create the gitops directory (creates parents). +pub fn ensure_gitops_dir(gitops: &Path) -> Result> { + let root = if gitops.is_absolute() { + gitops.to_path_buf() + } else { + std::env::current_dir()?.join(gitops) + }; + fs::create_dir_all(&root)?; + Ok(root) +} + +/// Write non-secret manifests. Overwrites existing files with the same path. +/// Returns absolute paths written. +pub fn write_gitops_files( + gitops: &Path, + files: &[GitopsFile], +) -> Result, Box> { + let root = ensure_gitops_dir(gitops)?; + let mut written = Vec::new(); + for f in files { + if f.rel_path.is_empty() || f.rel_path.contains("..") { + return Err(format!("invalid gitops relative path: {}", f.rel_path).into()); + } + let dest = root.join(&f.rel_path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + // Ensure trailing newline for clean git diffs. + let mut body = f.yaml.trim_end().to_string(); + body.push('\n'); + fs::write(&dest, body)?; + written.push(dest); + } + // Document the secrets gap next to written providers. + let readme = root.join("SECRETS.md"); + if !readme.exists() { + fs::write( + &readme, + SECRETS_README, + )?; + written.push(readme); + } + Ok(written) +} + +const SECRETS_README: &str = r#"# Secrets (local gitops) + +Provider and ProviderConfig YAML in this tree are **non-secret**. + +Credential `Secret` objects are **not** written here. Cloud environments use +External Secrets / SOPS; local workbench does not have that path yet. + +Until then: + +1. Apply this tree (or let `hops local up` grow a cluster phase). +2. Create live secrets with: + - `hops local aws` / `github` / `zitadel` (without relying on git for credentials) + - or a future `hops local secrets sync` + +ProviderConfig resources reference secret names/keys only — fill those secrets +on the cluster out-of-band. +"#; + +/// Log paths written for humans. +pub fn log_written(paths: &[PathBuf]) { + for p in paths { + log::info!("gitops wrote {}", p.display()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_creates_nested_files_and_secrets_readme() { + let dir = std::env::temp_dir().join(format!( + "hops-gitops-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::remove_dir_all(&dir); + let files = vec![ + GitopsFile { + rel_path: "providers/aws.yaml".into(), + yaml: "apiVersion: pkg.crossplane.io/v1\nkind: Provider\n".into(), + }, + GitopsFile { + rel_path: "providerconfigs/aws.yaml".into(), + yaml: "apiVersion: aws.m.upbound.io/v1beta1\nkind: ProviderConfig\n".into(), + }, + ]; + let written = write_gitops_files(&dir, &files).unwrap(); + assert!(dir.join("providers/aws.yaml").exists()); + assert!(dir.join("providerconfigs/aws.yaml").exists()); + assert!(dir.join("SECRETS.md").exists()); + assert!(written.len() >= 2); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_parent_path_escape() { + let dir = std::env::temp_dir().join(format!("hops-gitops-bad-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let err = write_gitops_files( + &dir, + &[GitopsFile { + rel_path: "../escape.yaml".into(), + yaml: "x: 1\n".into(), + }], + ); + assert!(err.is_err()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index 8cc2751..0974414 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -3,15 +3,22 @@ pub mod backend; mod cloudflare; mod destroy; mod doctor; +mod down; mod github; +mod gitops; +pub mod gitops_write; mod install; mod listmonk; +mod open; pub mod package_install; mod reset; mod resize; mod start; +mod status; mod stop; mod uninstall; +pub mod workbench; +mod up; mod zitadel; use clap::{Args, Subcommand}; @@ -127,6 +134,16 @@ pub enum LocalCommands { Resize(resize::ResizeArgs), /// Check what `hops local start` set up and report drift Doctor, + /// Bring up a local workbench workspace (env Applications + host access) + Up(up::UpArgs), + /// Bring down a local workbench workspace + Down(down::DownArgs), + /// Show local workbench workspace status and app URLs + Status(status::StatusArgs), + /// Open the workspace UI URL in a browser + Open(open::OpenArgs), + /// Local gitops: `cluster` (shared CP) or `worktree` (app namespaces) + Gitops(gitops::GitopsArgs), /// Configure crossplane-contrib provider-family-aws and AWS ProviderConfig Aws(aws::AwsArgs), /// Configure Wildbit Cloudflare DNS provider and ProviderConfig @@ -164,6 +181,11 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { LocalCommands::Start(start_args) => start::run(backend, start_args), LocalCommands::Resize(resize_args) => resize::run(backend, resize_args), LocalCommands::Doctor => doctor::run(), + LocalCommands::Up(up_args) => up::run(up_args), + LocalCommands::Down(down_args) => down::run(down_args), + LocalCommands::Status(status_args) => status::run(status_args), + LocalCommands::Open(open_args) => open::run(open_args), + LocalCommands::Gitops(gitops_args) => gitops::run(gitops_args), LocalCommands::Aws(aws_args) => aws::run(aws_args), LocalCommands::Cloudflare(cloudflare_args) => cloudflare::run(cloudflare_args), LocalCommands::Github(github_args) => github::run(github_args), @@ -286,50 +308,55 @@ pub(crate) fn wait_for_kubernetes() -> Result<(), Box> { /// Uses `--validate=false` so a slow/overloaded API server (common on nested /// virt CI while Crossplane is warming) does not fail the apply solely because /// OpenAPI schema download timed out. Retries a few times for transient -/// connection errors. +/// connection errors. Captures stderr on failure so callers can classify soft +/// errors (missing CRDs). pub fn kubectl_apply_stdin(yaml: &str) -> Result<(), Box> { let full = with_kube_context(&["apply", "--validate=false", "-f", "-"]); - let mut last_status = None; // Nested-virt CI (colima/GHA) can lose the apiserver for minutes after // Crossplane/provider install (TLS handshake timeouts). Retry with backoff // and re-probe the API between attempts. const ATTEMPTS: u32 = 12; + let mut last_err = String::from("unknown"); for attempt in 1..=ATTEMPTS { let mut child = Command::new("kubectl") .args(&full) .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn()?; if let Some(ref mut stdin) = child.stdin { stdin.write_all(yaml.as_bytes())?; } - let status = child.wait()?; - if status.success() { + let output = child.wait_with_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stdout.trim().is_empty() { + print!("{stdout}"); + } + if output.status.success() { + if !stderr.trim().is_empty() { + eprint!("{stderr}"); + } return Ok(()); } - last_status = Some(status); + last_err = format!("{}: {}", output.status, stderr.trim()); + if !stderr.trim().is_empty() { + eprint!("{stderr}"); + } log::warn!( - "kubectl apply failed (attempt {}/{}, status {}); waiting for API...", + "kubectl apply failed (attempt {}/{}, {}); waiting for API...", attempt, ATTEMPTS, - status + output.status ); - // Best-effort API recovery before the next apply. let _ = wait_for_kubernetes(); std::thread::sleep(std::time::Duration::from_secs(10)); } - Err(format!( - "kubectl apply exited with {} after retries", - last_status - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".into()) - ) - .into()) + Err(format!("kubectl apply exited with {last_err} after retries").into()) } /// Apply a JSON merge patch with `kubectl patch --type merge`. diff --git a/src/commands/local/open.rs b/src/commands/local/open.rs new file mode 100644 index 0000000..e8da13a --- /dev/null +++ b/src/commands/local/open.rs @@ -0,0 +1,103 @@ +//! `hops local open` — open the primary UI URL in a browser when possible. + +use super::workbench::net::{discover_workspace_endpoints, plan_host_access}; +use super::workbench::registry::{list_workspaces, load_workspace}; +use super::{command_exists, local_state_dir, run_cmd}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct OpenArgs { + /// Workspace name (default: only workspace if exactly one). + #[arg(long)] + pub name: Option, + + /// Service to open (default: first *ui* service, else first service). + #[arg(long)] + pub service: Option, +} + +pub fn run(args: &OpenArgs) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let ws = match &args.name { + Some(n) => load_workspace(&state_dir, n)?.ok_or_else(|| { + format!("Workspace `{n}` not found. Run hops local up first.") + })?, + None => { + let all = list_workspaces(&state_dir)?; + match all.as_slice() { + [only] => only.clone(), + [] => { + return Err( + "No workspaces registered. Run hops local up first.".into(), + ) + } + many => { + return Err(format!( + "Multiple workspaces ({}); pass --name.", + many.iter() + .map(|w| w.name.as_str()) + .collect::>() + .join(", ") + ) + .into()) + } + } + } + }; + + let services = discover_workspace_endpoints(&ws.namespace).unwrap_or_default(); + let plan = plan_host_access(&ws.namespace, &services); + + let url = pick_url(&plan.urls, args.service.as_deref()).ok_or_else(|| { + "No service URL available. Is the workspace up? Try hops local status.".to_string() + })?; + + println!("Opening {url}"); + open_browser(&url)?; + Ok(()) +} + +fn pick_url( + urls: &std::collections::BTreeMap, + service: Option<&str>, +) -> Option { + if let Some(svc) = service { + // Accept bare name, or ns/name key. + if let Some(u) = urls.get(svc) { + return Some(u.clone()); + } + for (key, url) in urls { + if key == svc || key.ends_with(&format!("/{svc}")) || key.contains(svc) { + return Some(url.clone()); + } + } + return None; + } + // Prefer UI-ish names in the workspace namespace first. + for (name, url) in urls { + if name.contains("ui") && !name.contains("login") { + return Some(url.clone()); + } + } + urls.values().next().cloned() +} + +fn open_browser(url: &str) -> Result<(), Box> { + // macOS open, Linux xdg-open; fall back to printing. + if cfg!(target_os = "macos") { + match run_cmd("open", &[url]) { + Ok(()) => return Ok(()), + Err(e) => log::warn!("open failed: {e}"), + } + } else if command_exists("xdg-open") { + match run_cmd("xdg-open", &[url]) { + Ok(()) => return Ok(()), + Err(e) => log::warn!("xdg-open failed: {e}"), + } + } + println!("Open this URL in your browser: {url}"); + Ok(()) +} + + diff --git a/src/commands/local/start.rs b/src/commands/local/start.rs index a5d5802..37c6711 100644 --- a/src/commands/local/start.rs +++ b/src/commands/local/start.rs @@ -6,11 +6,11 @@ use std::thread; use std::time::Duration; // Per-provider DRCs — never shared. Each pins its own cluster-admin SA so the -// providers can never clobber each other's runtime config. See bootstrap/drc/. -const DRC_K8S: &str = include_str!("../../../bootstrap/drc/kubernetes.yaml"); -const DRC_HELM: &str = include_str!("../../../bootstrap/drc/helm.yaml"); -const PROVIDER_HELM: &str = include_str!("../../../bootstrap/providers/provider-helm.yaml"); -const PROVIDER_K8S: &str = include_str!("../../../bootstrap/providers/provider-kubernetes.yaml"); +// providers can never clobber each other's runtime config. See bootstrap/providers/. +const DRC_K8S: &str = include_str!("../../../bootstrap/providers/kubernetes-drc.yaml"); +const DRC_HELM: &str = include_str!("../../../bootstrap/providers/helm-drc.yaml"); +const PROVIDER_HELM: &str = include_str!("../../../bootstrap/providers/helm.yaml"); +const PROVIDER_K8S: &str = include_str!("../../../bootstrap/providers/kubernetes.yaml"); const PC_HELM: &str = include_str!("../../../bootstrap/helm/pc.yaml"); const PC_K8S: &str = include_str!("../../../bootstrap/k8s/pc.yaml"); diff --git a/src/commands/local/status.rs b/src/commands/local/status.rs new file mode 100644 index 0000000..007112b --- /dev/null +++ b/src/commands/local/status.rs @@ -0,0 +1,238 @@ +//! `hops local status` — workspace health: pods, URLs, delivery, host access. +//! +//! Self-heals a dead DNS supervisor / port-forwards by default so status is usable truth. + +use super::workbench::net::{ + discover_workspace_endpoints, ensure_host_access, format_status_card_with_listen, + host_access_status_line, load_host_access_runtime, plan_host_access, url_listen_status, +}; +use super::workbench::registry::{list_workspaces, load_workspace}; +use super::{local_state_dir, run_cmd_output}; +use clap::Args; +use std::error::Error; +use std::path::Path; + +#[derive(Args, Debug)] +pub struct StatusArgs { + /// Show only this workspace. + #[arg(long)] + pub name: Option, + + /// Do not restart dead host-access processes (DNS supervisor / port-forwards). + /// By default status self-heals so FQDN URLs stay usable after pod rollouts. + #[arg(long, default_value_t = false)] + pub no_heal: bool, + + /// Exit 1 if any workspace is not usable (pods not Ready or URLs not listening). + #[arg(long, default_value_t = false)] + pub check: bool, +} + +pub fn run(args: &StatusArgs) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let workspaces = if let Some(name) = &args.name { + match load_workspace(&state_dir, name)? { + Some(r) => vec![r], + None => { + return Err(format!( + "Workspace `{name}` not found. Run hops local up first." + ) + .into()) + } + } + } else { + list_workspaces(&state_dir)? + }; + + if workspaces.is_empty() { + println!("No local workspaces registered."); + println!("Start one with: hops local up [--name ]"); + return Ok(()); + } + + let mut all_ok = true; + for (i, ws) in workspaces.iter().enumerate() { + if i > 0 { + println!(); + } + let services = discover_workspace_endpoints(&ws.namespace).unwrap_or_default(); + + let (plan, healed) = if !args.no_heal && !services.is_empty() { + match ensure_host_access(&ws.namespace, &services, &state_dir, &ws.name) { + Ok((plan, _rt, healed)) => { + if healed { + println!("note: host access restarted (self-heal)"); + } + (plan, healed) + } + Err(e) => { + log::warn!("host access heal failed: {e}"); + (plan_host_access(&ws.namespace, &services), false) + } + } + } else { + (plan_host_access(&ws.namespace, &services), false) + }; + let _ = healed; + + let listen = url_listen_status(&plan); + println!( + "{}", + format_status_card_with_listen(&ws.name, &plan, &listen) + ); + + // Pods + match discover_pods(&ws.namespace) { + Ok(pods) if !pods.is_empty() => { + println!("pods:"); + for p in &pods { + let mark = if p.ready { "ok" } else { "NOT READY" }; + println!( + " - {}: {} {}/{} [{mark}]", + p.name, p.phase, p.ready_containers, p.total_containers + ); + if !p.ready { + all_ok = false; + } + } + } + Ok(_) => { + println!("pods: (none in namespace)"); + all_ok = false; + } + Err(e) => { + println!("pods: (kubectl error: {e})"); + all_ok = false; + } + } + + if let Some(d) = &ws.delivery_mode { + println!("delivery: {d}"); + } + println!("{}", delivery_status_line(&state_dir, &ws.name)); + println!("env: {}", ws.env_path); + + if let Some(rt) = load_host_access_runtime(&state_dir, &ws.name)? { + println!("{}", host_access_status_line(&rt)); + } else if services.is_empty() { + println!("note: no services listed yet — is the workspace up?"); + } else { + println!("access processes: not recorded (re-run hops local up to start them)"); + } + + // URL listen summary for --check (cluster FQDN endpoints) + for (name, ok) in &listen { + if !ok { + all_ok = false; + println!( + "warn: {name} FQDN not listening (port-forward dead or app not ready)" + ); + } + } + } + + if args.check && !all_ok { + return Err("one or more workspaces are not ready (see above)".into()); + } + Ok(()) +} + +#[derive(Debug)] +struct PodStatus { + name: String, + phase: String, + ready: bool, + ready_containers: u32, + total_containers: u32, +} + +fn discover_pods(namespace: &str) -> Result, Box> { + let json = run_cmd_output("kubectl", &["get", "pods", "-n", namespace, "-o", "json"])?; + let value: serde_json::Value = serde_json::from_str(&json)?; + let mut out = Vec::new(); + if let Some(items) = value.get("items").and_then(|i| i.as_array()) { + for item in items { + let name = item + .pointer("/metadata/name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + continue; + } + let phase = item + .pointer("/status/phase") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let mut ready_containers = 0u32; + let mut total_containers = 0u32; + if let Some(cs) = item.pointer("/status/containerStatuses").and_then(|v| v.as_array()) + { + total_containers = cs.len() as u32; + for c in cs { + if c.get("ready").and_then(|v| v.as_bool()).unwrap_or(false) { + ready_containers += 1; + } + } + } + let ready = phase == "Running" && ready_containers == total_containers && total_containers > 0; + out.push(PodStatus { + name, + phase, + ready, + ready_containers, + total_containers, + }); + } + } + Ok(out) +} + +fn delivery_status_line(state_dir: &Path, workspace: &str) -> String { + let path = state_dir + .join("runtime") + .join(format!("{workspace}.delivery.json")); + if !path.exists() { + return "delivery processes: none recorded".into(); + } + let Ok(text) = std::fs::read_to_string(&path) else { + return "delivery processes: (unreadable state)".into(); + }; + let Ok(v) = serde_json::from_str::(&text) else { + return "delivery processes: (invalid state)".into(); + }; + let pids: Vec = v + .get("syncPids") + .and_then(|x| x.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_u64().map(|n| n as u32)) + .collect() + }) + .unwrap_or_default(); + let mutagen: usize = v + .get("mutagenSessions") + .and_then(|x| x.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let alive: Vec = pids + .iter() + .copied() + .filter(|p| super::workbench::net::pid_is_alive(*p)) + .collect(); + if mutagen > 0 { + format!("delivery processes: {mutagen} mutagen session(s); tar watchers alive={}", alive.len()) + } else if alive.is_empty() { + "delivery processes: watcher not running (re-run hops local up --delivery sync)".into() + } else { + format!( + "delivery processes: tar watcher alive (pids {})", + alive + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(",") + ) + } +} diff --git a/src/commands/local/up.rs b/src/commands/local/up.rs new file mode 100644 index 0000000..425bd55 --- /dev/null +++ b/src/commands/local/up.rs @@ -0,0 +1,628 @@ +//! `hops local up` — front-door: register workspace, reconcile, delivery, host access. + +use super::workbench::application::{ + find_worktree_root, load_applications, resolve_delivery_host_path, +}; +use super::workbench::cluster_gitops::{ + reconcile_cluster_dir, resolve_cluster_path, should_reconcile_cluster_change, +}; +use super::workbench::delivery::{ + attach_sync_delivery, discover_sync_targets, probe_node_path_visibility, + select_delivery_strategy, stop_mutagen_sessions, DeliveryStrategy, NodePathProber, + SystemNodeProber, +}; +use super::workbench::net::{ + discover_workspace_endpoints, format_status_card, host_access_status_line, plan_host_access, + start_host_access, ServiceEndpoint, +}; +use super::workbench::reconcile::{ + reconcile_applications, ReconcileOptions, SystemHelm, SystemKubectl, +}; +use super::workbench::registry::{ + default_name_from_cwd, namespace_for_name, save_workspace, WorkspaceRecord, +}; +use super::workbench::watch::{ + is_chart_or_env_path, should_ignore_watch_path, watch_roots_for_applications, WatchPathClass, +}; +use super::{local_state_dir, run_cmd_output}; +use clap::Args; +use notify::{RecursiveMode, Watcher}; +use std::collections::BTreeMap; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +#[derive(Args, Debug)] +pub struct UpArgs { + /// Path to env directory of Application YAMLs (e.g. ./gitops/envs/local). + pub env_path: PathBuf, + + /// Workspace name (isolates namespace). Defaults to cwd basename. + #[arg(long)] + pub name: Option, + + /// Path to **shared** control-plane gitops (PSQLStack, AuthStack, packages). + /// Not per-worktree: one tree per local CP, usually meta-repo `gitops/cluster`. + /// Default: `--cluster`, else `$HOPS_LOCAL_CLUSTER`, else walk up from env/cwd + /// for `gitops/cluster`. Project charts stay under each app's `.gitops/deploy`. + #[arg(long)] + pub cluster: Option, + + /// Skip applying/watching cluster gitops. + #[arg(long, default_value_t = false)] + pub no_cluster: bool, + + /// Run a single bring-up and exit (disables the default watch). + #[arg(long, default_value_t = false)] + pub once: bool, + + /// Watch env/chart/cluster paths after first reconcile (default). + /// Redundant unless scripting; use `--once` to disable. + #[arg(long, default_value_t = false)] + pub watch: bool, + + /// Debounce seconds while watching. + #[arg(long, default_value_t = 1)] + pub debounce: u64, + + /// Skip source delivery attach (still reconciles charts). + #[arg(long, default_value_t = false)] + pub no_delivery: bool, + + /// Force delivery strategy: hostPath | sync (default: auto probe). + #[arg(long)] + pub delivery: Option, + + /// Skip host access (Service FQDNs + port-forward supervisor). + #[arg(long, default_value_t = false)] + pub no_net: bool, + + /// Render only; do not apply. + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +pub fn run(args: &UpArgs) -> Result<(), Box> { + // CP readiness: plain-language error if kubectl cannot reach API. + if !args.dry_run { + match run_cmd_output("kubectl", &["cluster-info"]) { + Ok(_) => {} + Err(e) => { + return Err(format!( + "Local control plane is not reachable ({e}).\n\ + Start it once with: hops local start\n\ + Then re-run: hops local up {}", + args.env_path.display() + ) + .into()); + } + } + } + + let env_path = args.env_path.canonicalize().map_err(|e| { + format!( + "env path {} not found ({e}). Pass a directory of Application YAMLs, e.g. ./gitops/env/local", + args.env_path.display() + ) + })?; + + let cwd = std::env::current_dir()?; + let name = args + .name + .clone() + .unwrap_or_else(|| default_name_from_cwd(&cwd)); + let namespace = namespace_for_name(&name); + + let state_dir = local_state_dir()?; + + // Delivery host roots: default is the git **worktree root** for each app + // (shared monorepo/meta tree of *this* worktree's changes). Explicit + // `deliveryPath` overrides. Main checkout → namespace `main` can come later. + let app_delivery_hosts = collect_app_delivery_hosts(&env_path)?; + for (app, host) in &app_delivery_hosts { + log::info!("delivery host for `{app}`: {}", host.display()); + } + // Probe union: prefer hostPath only if EVERY delivery root is visible on the node. + let project_root = find_worktree_root(&env_path).or_else(|| infer_project_root(&env_path)); + + let (delivery_mode, probe_detail) = if args.no_delivery { + (None, None) + } else { + let (strategy, detail) = resolve_delivery_for_apps( + args.delivery.as_deref(), + &app_delivery_hosts, + &SystemNodeProber, + )?; + (Some(strategy), Some(detail)) + }; + + let mut runtime_values = BTreeMap::new(); + runtime_values.insert( + "appRuntime".into(), + serde_yaml::Value::String("cluster-dev".into()), + ); + + let opts = ReconcileOptions { + namespace: namespace.clone(), + workspace_name: name.clone(), + runtime_values, + app_delivery_host_paths: if matches!( + delivery_mode, + Some(DeliveryStrategy::HostPath) | Some(DeliveryStrategy::Sync) + ) { + app_delivery_hosts.clone() + } else { + BTreeMap::new() + }, + delivery_mode: delivery_mode.map(|d| d.as_str().to_string()), + dry_run: args.dry_run, + }; + + log::info!("Workspace `{name}` → namespace `{namespace}`"); + if let Some(d) = &probe_detail { + log::info!("delivery probe: {d}"); + } + + // Shared CP gitops first (one cluster tree for the whole local CP), then env apps. + let cluster_path = if args.no_cluster { + None + } else { + match resolve_cluster_path(Some(&env_path), args.cluster.as_deref()) { + Some(p) => Some(p.canonicalize().map_err(|e| { + format!( + "cluster path {}: {e} (pass --cluster or set HOPS_LOCAL_CLUSTER)", + p.display() + ) + })?), + None => None, + } + }; + if let Some(ref cluster) = cluster_path { + log::info!( + "cluster gitops (shared CP, not per-worktree): {}", + cluster.display() + ); + match reconcile_cluster_dir(cluster, args.dry_run) { + Ok(r) => { + log::info!( + "cluster gitops: {} applied, {} error(s)", + r.applied.len(), + r.errors.len() + ); + } + Err(e) => { + // Don't hard-fail app bring-up if packages aren't installed yet. + log::warn!("cluster gitops reconcile: {e}"); + } + } + } else if !args.no_cluster { + log::debug!( + "no cluster gitops found (tried --cluster, $HOPS_LOCAL_CLUSTER, walk-up gitops/cluster); skipping platform apply" + ); + } + + let results = reconcile_applications(&env_path, &opts, &SystemHelm, &SystemKubectl)?; + for r in &results { + log::info!( + " reconciled {} → {}", + r.app_name, + if r.applied { "applied" } else { "dry-run" } + ); + } + + // Attach real sync delivery when strategy is Sync (per-app host paths) + let mut sync_pids: Vec = Vec::new(); + let mut mutagen_sessions: Vec = Vec::new(); + if !args.dry_run && !args.no_delivery { + if let Some(DeliveryStrategy::Sync) = delivery_mode { + let targets = + wait_for_sync_targets(&namespace, &name, "/workspace", &app_delivery_hosts, 90); + match attach_sync_delivery(&targets, &name, wants_watch(args)) { + Ok(attach) => { + sync_pids = attach.sync_pids; + mutagen_sessions = attach.mutagen_sessions; + for m in attach.messages { + log::info!("delivery: {m}"); + } + } + Err(e) => log::warn!("source delivery attach failed: {e}"), + } + } else if let Some(DeliveryStrategy::HostPath) = delivery_mode { + log::info!( + "source delivery: hostPath (per-app node-visible paths; no tar sync)" + ); + } + } + + // Discover services for URL card (workspace + related in-cluster FQDNs) + let services = if args.dry_run { + default_service_stubs(&namespace, &results) + } else { + discover_workspace_endpoints(&namespace).unwrap_or_else(|e| { + log::debug!("service discovery deferred: {e}"); + default_service_stubs(&namespace, &results) + }) + }; + + // Workspace Services → cluster FQDNs + supervisor-kept port-forwards. + let mut plan = plan_host_access(&namespace, &services); + + if !args.dry_run && !args.no_net && !services.is_empty() { + match start_host_access(&namespace, &services, &state_dir, &name) { + Ok((live_plan, rt)) => { + plan = live_plan; + log::info!("{}", host_access_status_line(&rt)); + } + Err(e) => { + log::warn!("host access start failed: {e}"); + } + } + } else if services.is_empty() { + log::info!("host access: deferred until services exist"); + } + + // Persist delivery runtime pids alongside workspace record (in runtime dir via net helpers + // for host access; store sync info in a small sidecar file) + save_delivery_runtime(&state_dir, &name, &mutagen_sessions, &sync_pids)?; + + let record = WorkspaceRecord { + name: name.clone(), + namespace: namespace.clone(), + env_path: env_path.display().to_string(), + project_root: project_root.map(|p| p.display().to_string()), + delivery_mode: delivery_mode.map(|d| d.as_str().to_string()), + updated_at: Some(chrono_lite_now()), + }; + if !args.dry_run { + save_workspace(&state_dir, &record)?; + } + + println!(); + println!("{}", format_status_card(&name, &plan)); + if let Some(d) = delivery_mode { + println!("delivery: {} ({})", d.as_str(), probe_detail.as_deref().unwrap_or("auto")); + } + println!( + "access: cluster DNS (Service FQDNs; supervisor restarts port-forwards)" + ); + println!(); + println!("Useful commands:"); + println!(" hops local status"); + println!(" hops local open"); + println!(" hops local down --name {name}"); + + if wants_watch(args) { + let env_for_watch = env_path.clone(); + let opts_watch = opts.clone(); + let cluster_for_watch = cluster_path.clone(); + let dry = args.dry_run; + let cluster_arg = cluster_for_watch.clone(); + run_combined_gitops_watch( + &env_path, + cluster_arg.as_deref(), + args.debounce, + move |kind| { + match kind { + WatchRebuild::Cluster => { + if let Some(ref c) = cluster_for_watch { + reconcile_cluster_dir(c, dry)?; + } + } + WatchRebuild::Env => { + reconcile_applications( + &env_for_watch, + &opts_watch, + &SystemHelm, + &SystemKubectl, + )?; + } + WatchRebuild::Both => { + if let Some(ref c) = cluster_for_watch { + let _ = reconcile_cluster_dir(c, dry); + } + reconcile_applications( + &env_for_watch, + &opts_watch, + &SystemHelm, + &SystemKubectl, + )?; + } + } + Ok(()) + }, + )?; + } + + Ok(()) +} + +/// Watch by default; `--once` or dry-run for one-shot / CI. +fn wants_watch(args: &UpArgs) -> bool { + !args.once && !args.dry_run +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WatchRebuild { + Cluster, + Env, + Both, +} + +/// Watch env Applications + charts + optional cluster gitops tree. +fn run_combined_gitops_watch( + env_path: &Path, + cluster_path: Option<&Path>, + debounce_secs: u64, + mut rebuild: F, +) -> Result<(), Box> +where + F: FnMut(WatchRebuild) -> Result<(), Box>, +{ + let roots = watch_roots_for_applications(env_path)?; + let env_canon = env_path + .canonicalize() + .unwrap_or_else(|_| env_path.to_path_buf()); + let chart_paths: Vec = roots + .iter() + .filter(|p| *p != &env_canon) + .cloned() + .collect(); + let cluster_canon = cluster_path.map(|c| { + c.canonicalize().unwrap_or_else(|_| c.to_path_buf()) + }); + + let debounce = Duration::from_secs(debounce_secs); + let (tx, rx) = mpsc::channel::(); + + let env_c = env_canon.clone(); + let charts = chart_paths.clone(); + let cluster_c = cluster_canon.clone(); + let mut watcher = + notify::recommended_watcher(move |res: notify::Result| match res { + Ok(event) => { + let mut hit_cluster = false; + let mut hit_env = false; + for p in &event.paths { + if should_ignore_watch_path(p) { + continue; + } + if let Some(ref cp) = cluster_c { + if should_reconcile_cluster_change(p, cp) { + hit_cluster = true; + continue; + } + } + if is_chart_or_env_path(p, &env_c, &charts) == WatchPathClass::ChartOrEnv { + hit_env = true; + } + } + if hit_cluster && hit_env { + let _ = tx.send(WatchRebuild::Both); + } else if hit_cluster { + let _ = tx.send(WatchRebuild::Cluster); + } else if hit_env { + let _ = tx.send(WatchRebuild::Env); + } + } + Err(e) => log::debug!("watch error: {e:?}"), + })?; + + for root in &roots { + if root.exists() { + watcher.watch(root, RecursiveMode::Recursive)?; + log::info!("Watching {}", root.display()); + } + } + if let Some(ref cp) = cluster_canon { + if cp.exists() { + watcher.watch(cp, RecursiveMode::Recursive)?; + log::info!("Watching cluster gitops {}", cp.display()); + } + } + log::info!( + "GitOps watch active (debounce {}s): env/charts + cluster → local CP. Ctrl+C to stop.", + debounce_secs + ); + + loop { + let first = rx.recv().map_err(|_| "watcher channel closed")?; + let mut kind = first; + // Debounce and merge events + let mut deadline = Instant::now() + debounce; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining) { + Ok(next) => { + kind = match (kind, next) { + (WatchRebuild::Both, _) | (_, WatchRebuild::Both) => WatchRebuild::Both, + (WatchRebuild::Cluster, WatchRebuild::Env) + | (WatchRebuild::Env, WatchRebuild::Cluster) => WatchRebuild::Both, + (a, _) => a, + }; + deadline = Instant::now() + debounce; + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("watcher channel closed".into()); + } + } + } + log::info!("──────────────────────────────────────────────"); + log::info!("GitOps change ({kind:?}), reconciling..."); + match rebuild(kind) { + Ok(()) => log::info!("Reconcile succeeded."), + Err(e) => log::error!("Reconcile failed: {e}"), + } + } +} + +fn collect_app_delivery_hosts( + env_path: &Path, +) -> Result, Box> { + let apps = load_applications(env_path)?; + let mut map = BTreeMap::new(); + for (app_file, app) in apps { + let host = resolve_delivery_host_path(&app_file, &app)?; + map.insert(app.metadata.name, host); + } + Ok(map) +} + +fn resolve_delivery_for_apps( + override_mode: Option<&str>, + app_hosts: &BTreeMap, + prober: &dyn NodePathProber, +) -> Result<(DeliveryStrategy, String), Box> { + if let Some(m) = override_mode { + let strategy = match m { + "hostPath" | "hostpath" => DeliveryStrategy::HostPath, + "sync" | "mutagen" => DeliveryStrategy::Sync, + other => { + return Err(format!("unknown --delivery {other} (use hostPath|sync)").into()) + } + }; + return Ok((strategy, format!("forced via --delivery {m}"))); + } + // HostPath only if every per-app path is visible on the node. + let mut details = Vec::new(); + let mut all_visible = !app_hosts.is_empty(); + for (app, host) in app_hosts { + let probe = prober.probe(host)?; + details.push(format!("{app}: {}", probe.detail)); + if !probe.host_path_visible { + all_visible = false; + } + } + if app_hosts.is_empty() { + all_visible = false; + details.push("no apps".into()); + } + let strategy = if all_visible { + DeliveryStrategy::HostPath + } else { + DeliveryStrategy::Sync + }; + let _ = select_delivery_strategy; // strategy already chosen from multi-path rule + Ok((strategy, details.join("; "))) +} + +fn infer_project_root(env_path: &Path) -> Option { + let mut p = env_path.to_path_buf(); + loop { + if p.file_name().and_then(|s| s.to_str()) == Some("gitops") { + return p.parent().map(|x| x.to_path_buf()); + } + if !p.pop() { + break; + } + } + env_path.parent().map(|x| x.to_path_buf()) +} + +/// Poll for Running pods labeled for this workspace, with per-app host paths. +fn wait_for_sync_targets( + namespace: &str, + workspace: &str, + mount_path: &str, + app_hosts: &BTreeMap, + timeout_secs: u64, +) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + loop { + match discover_sync_targets(namespace, workspace, mount_path, app_hosts) { + Ok(t) if !t.is_empty() => return t, + Ok(_) => {} + Err(e) => log::debug!("pod discovery: {e}"), + } + if std::time::Instant::now() >= deadline { + return discover_sync_targets(namespace, workspace, mount_path, app_hosts) + .unwrap_or_default(); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } +} + +fn default_service_stubs( + namespace: &str, + results: &[super::workbench::reconcile::ReconcileResult], +) -> Vec { + results + .iter() + .map(|r| ServiceEndpoint { + namespace: namespace.to_string(), + name: r.app_name.clone(), + port: if r.app_name.contains("ui") { + 5180 + } else { + 8791 + }, + protocol: "TCP".into(), + }) + .collect() +} + +fn chrono_lite_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("{secs}") +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct DeliveryRuntime { + mutagen_sessions: Vec, + sync_pids: Vec, +} + +fn delivery_runtime_path(state_dir: &Path, workspace: &str) -> PathBuf { + state_dir + .join("runtime") + .join(format!("{workspace}.delivery.json")) +} + +fn save_delivery_runtime( + state_dir: &Path, + workspace: &str, + sessions: &[String], + pids: &[u32], +) -> Result<(), Box> { + let dir = state_dir.join("runtime"); + std::fs::create_dir_all(&dir)?; + let rt = DeliveryRuntime { + mutagen_sessions: sessions.to_vec(), + sync_pids: pids.to_vec(), + }; + std::fs::write( + delivery_runtime_path(state_dir, workspace), + serde_json::to_string_pretty(&rt)?, + )?; + Ok(()) +} + +pub(crate) fn stop_delivery_runtime(state_dir: &Path, workspace: &str) { + let path = delivery_runtime_path(state_dir, workspace); + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(rt) = serde_json::from_str::(&text) { + stop_mutagen_sessions(&rt.mutagen_sessions); + for pid in rt.sync_pids { + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + } + } + let _ = std::fs::remove_file(path); +} + +// Re-export probe for tests that want the production path +#[allow(dead_code)] +pub fn probe_for_tests(path: &Path) -> Result> { + probe_node_path_visibility(path) +} diff --git a/src/commands/local/workbench/application.rs b/src/commands/local/workbench/application.rs new file mode 100644 index 0000000..699a728 --- /dev/null +++ b/src/commands/local/workbench/application.rs @@ -0,0 +1,398 @@ +//! Parse hops.local Application documents and resolve chart source paths. + +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const APPLICATION_API_VERSION: &str = "hops.local/v1alpha1"; +pub const APPLICATION_KIND: &str = "Application"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Application { + pub api_version: String, + pub kind: String, + pub metadata: ApplicationMetadata, + pub spec: ApplicationSpec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplicationMetadata { + pub name: String, + #[serde(default)] + pub labels: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplicationSpec { + pub source: Source, + #[serde(default)] + pub destination: Destination, + #[serde(default)] + pub sync_policy: SyncPolicy, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Source { + /// Path to chart directory, relative to the Application YAML file. + pub path: String, + /// Optional override for the host directory to mount/sync into the pod + /// (relative to this Application file, or absolute). + /// + /// **Default (recommended):** git **worktree root** containing this file + /// (`git rev-parse --show-toplevel`). Every app in a workspace shares that + /// root so monorepo/meta links and codegen stay coherent; each worktree + /// keeps its own changes (feature branch ≠ main). + /// + /// Override only for rare cases (e.g. a chart that must not see the full tree). + #[serde(default)] + pub delivery_path: Option, + #[serde(default)] + pub helm: HelmSource, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HelmSource { + #[serde(default)] + pub values: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Destination { + #[serde(default)] + pub namespace: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncPolicy { + #[serde(default)] + pub prune: bool, +} + +impl Default for SyncPolicy { + fn default() -> Self { + Self { prune: false } + } +} + +/// Parse a single Application document from YAML text. +pub fn parse_application_yaml(yaml: &str) -> Result> { + let app: Application = serde_yaml::from_str(yaml) + .map_err(|e| format!("failed to parse Application YAML: {e}"))?; + if app.api_version != APPLICATION_API_VERSION { + return Err(format!( + "unsupported apiVersion {:?} (expected {})", + app.api_version, APPLICATION_API_VERSION + ) + .into()); + } + if app.kind != APPLICATION_KIND { + return Err(format!( + "unsupported kind {:?} (expected {})", + app.kind, APPLICATION_KIND + ) + .into()); + } + if app.metadata.name.trim().is_empty() { + return Err("Application metadata.name is required".into()); + } + if app.spec.source.path.trim().is_empty() { + return Err("Application spec.source.path is required".into()); + } + Ok(app) +} + +/// Resolve `spec.source.path` relative to the Application file's directory. +pub fn resolve_source_path(app_file: &Path, source_path: &str) -> Result> { + let base = app_file + .parent() + .ok_or_else(|| format!("Application path has no parent: {}", app_file.display()))?; + let joined = base.join(source_path); + // Do not require canonicalize (charts may be created in tests before write completes). + Ok(normalize_path(&joined)) +} + +/// Resolve the host directory to mount/sync into the container. +/// +/// Precedence: +/// 1. `spec.source.deliveryPath` — explicit override (relative to Application file) +/// 2. **Git worktree root** for this Application (`git rev-parse --show-toplevel`) +/// 3. Service root that owns the chart (directory containing `.gitops`) +/// +/// Worktree root is the default so multi-container monorepos share one tree of +/// changes; a separate git worktree (or the main checkout later) is a separate +/// workspace / namespace. +pub fn resolve_delivery_host_path( + app_file: &Path, + app: &Application, +) -> Result> { + if let Some(rel) = app + .spec + .source + .delivery_path + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + let p = resolve_source_path(app_file, rel)?; + return Ok(p.canonicalize().unwrap_or(p)); + } + if let Some(root) = find_worktree_root(app_file) { + return Ok(root); + } + let chart = resolve_source_path(app_file, &app.spec.source.path)?; + Ok(service_root_from_chart_path(&chart) + .canonicalize() + .unwrap_or_else(|_| service_root_from_chart_path(&chart))) +} + +/// Git worktree root for `path` (file or directory), via `git rev-parse --show-toplevel`. +pub fn find_worktree_root(path: &Path) -> Option { + let dir = if path.is_file() { + path.parent()?.to_path_buf() + } else { + path.to_path_buf() + }; + let dir = dir.canonicalize().unwrap_or(dir); + let output = std::process::Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["rev-parse", "--show-toplevel"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(PathBuf::from(s)) + } +} + +/// Given `…//.gitops/deploy`, return `…/`. +/// If `.gitops` is not in the path, return the chart path itself. +pub fn service_root_from_chart_path(chart_path: &Path) -> PathBuf { + let mut comps: Vec<_> = chart_path.components().collect(); + // Find `.gitops` and drop it and everything after. + if let Some(idx) = comps + .iter() + .position(|c| c.as_os_str() == std::ffi::OsStr::new(".gitops")) + { + comps.truncate(idx); + let mut out = PathBuf::new(); + for c in comps { + out.push(c.as_os_str()); + } + if out.as_os_str().is_empty() { + chart_path.to_path_buf() + } else { + out + } + } else { + chart_path.to_path_buf() + } +} + +/// Collapse `.` and `..` without requiring the path to exist. +fn normalize_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +/// Load all Application YAMLs from a directory (or a single file). +pub fn load_applications(env_path: &Path) -> Result, Box> { + if !env_path.exists() { + return Err(format!("env path does not exist: {}", env_path.display()).into()); + } + if env_path.is_file() { + let text = fs::read_to_string(env_path)?; + let app = parse_application_yaml(&text)?; + return Ok(vec![(env_path.to_path_buf(), app)]); + } + + let mut entries: Vec = fs::read_dir(env_path)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.extension() + .and_then(|e| e.to_str()) + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) + .collect(); + entries.sort(); + + let mut apps = Vec::new(); + for path in entries { + let text = fs::read_to_string(&path) + .map_err(|e| format!("read {}: {e}", path.display()))?; + // Skip non-Application docs quietly if kind mismatches after parse attempt. + match parse_application_yaml(&text) { + Ok(app) => apps.push((path, app)), + Err(e) => { + // Multi-doc or unrelated YAML: try first document only already failed. + return Err(format!("{}: {e}", path.display()).into()); + } + } + } + Ok(apps) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + const SAMPLE: &str = r#" +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-api +spec: + source: + path: ../../../api/.gitops/deploy + helm: + values: + local: true + appRuntime: cluster-dev + syncPolicy: + prune: false +"#; + + #[test] + fn parse_application_yaml_accepts_local_subset() { + let app = parse_application_yaml(SAMPLE).unwrap(); + assert_eq!(app.metadata.name, "e2e-ui-api"); + assert_eq!(app.spec.source.path, "../../../api/.gitops/deploy"); + // Destination ns is owned by hops --name at reconcile, not the YAML. + assert!(app.spec.destination.namespace.is_none()); + assert!(!app.spec.sync_policy.prune); + let values = app.spec.source.helm.values.unwrap(); + assert_eq!(values["local"], Value::Bool(true)); + assert_eq!(values["appRuntime"], Value::String("cluster-dev".into())); + } + + #[test] + fn parse_rejects_wrong_api_version() { + let bad = SAMPLE.replace(APPLICATION_API_VERSION, "v1"); + assert!(parse_application_yaml(&bad).is_err()); + } + + #[test] + fn resolve_source_path_relative_to_application_file() { + let app_file = Path::new("/proj/gitops/env/local/api.yaml"); + let resolved = resolve_source_path(app_file, "../../../api/.gitops/deploy").unwrap(); + assert_eq!(resolved, PathBuf::from("/proj/api/.gitops/deploy")); + } + + #[test] + fn service_root_from_chart_is_parent_of_gitops() { + assert_eq!( + service_root_from_chart_path(Path::new("/proj/ui/.gitops/deploy")), + PathBuf::from("/proj/ui") + ); + assert_eq!( + service_root_from_chart_path(Path::new("/proj/api/.gitops/deploy")), + PathBuf::from("/proj/api") + ); + } + + #[test] + fn resolve_delivery_host_path_honors_explicit_override() { + let app_file = Path::new("/proj/gitops/env/local/ui.yaml"); + let ui = parse_application_yaml( + r#" +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-ui +spec: + source: + path: ../../../ui/.gitops/deploy + deliveryPath: ../../../ui +"#, + ) + .unwrap(); + let host = resolve_delivery_host_path(app_file, &ui).unwrap(); + assert_eq!(host, PathBuf::from("/proj/ui")); + } + + #[test] + fn resolve_delivery_host_path_defaults_to_worktree_or_service_root() { + // When git worktree root is available (this CLI repo), default is that root + // for every app — not a per-service silo. + let app_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let ui = parse_application_yaml( + r#" +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-ui +spec: + source: + path: ../../../ui/.gitops/deploy +"#, + ) + .unwrap(); + let host = resolve_delivery_host_path(&app_file, &ui).unwrap(); + if let Some(wt) = find_worktree_root(&app_file) { + assert_eq!(host, wt); + } else { + // No git: falls back to service root of the (non-existent) chart path. + assert!(host.ends_with("ui") || host.components().count() > 0); + } + } + + #[test] + fn load_applications_from_directory() { + let dir = tempfile_dir("lwb-apps"); + write_file( + &dir.join("api.yaml"), + SAMPLE, + ); + write_file( + &dir.join("ui.yaml"), + &SAMPLE.replace("e2e-ui-api", "e2e-ui-ui"), + ); + let apps = load_applications(&dir).unwrap(); + assert_eq!(apps.len(), 2); + assert_eq!(apps[0].1.metadata.name, "e2e-ui-api"); + assert_eq!(apps[1].1.metadata.name, "e2e-ui-ui"); + } + + fn tempfile_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "{}-{}-{}", + prefix, + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_file(path: &Path, body: &str) { + let mut f = fs::File::create(path).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + } +} diff --git a/src/commands/local/workbench/cluster_dns.rs b/src/commands/local/workbench/cluster_dns.rs new file mode 100644 index 0000000..c130cdc --- /dev/null +++ b/src/commands/local/workbench/cluster_dns.rs @@ -0,0 +1,575 @@ +//! Loopback IPs + `/etc/hosts` so Service FQDNs resolve on the host. +//! +//! For each Service in a hops-local workspace namespace: +//! 1. Allocate a unique loopback IP in `127.53.0.0/16` +//! 2. (macOS) alias it on `lo0` (privileged) +//! 3. Maintain a managed block in `/etc/hosts` mapping the **real k8s FQDN** +//! `svc.namespace.svc.cluster.local` → that IP +//! 4. Callers port-forward with `--address ` on the **cluster service port** +//! +//! Privilege is expected: one admin elevation installs hosts + aliases so +//! in-cluster URLs work on the laptop. Daily `up`/`status` reuses state when +//! possible; elevation only when the OS config is missing/stale. +//! +//! Result: `curl http://e2e-ui-api.dogfood.svc.cluster.local:8791` + +use std::collections::BTreeMap; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Managed block markers in `/etc/hosts` (and the runtime mirror). +pub const HOSTS_BEGIN: &str = "# BEGIN hops-local-dns (managed by hops local — do not edit)"; +pub const HOSTS_END: &str = "# END hops-local-dns"; + +/// Loopback range reserved for hops (avoids common 127.0.0.1 tooling). +pub const DNS_IP_PREFIX: &str = "127.53"; + +/// macOS stub resolver for `*.svc.cluster.local` (bypasses mDNS 5s timeout). +pub const MACOS_LOCAL_DNS_PORT: u16 = 53535; +pub const MACOS_RESOLVER_PATH: &str = "/etc/resolver/svc.cluster.local"; + +/// Kubernetes-style FQDN used by in-cluster clients and our hosts entries. +pub fn cluster_dns_name(service: &str, namespace: &str) -> String { + format!("{service}.{namespace}.svc.cluster.local") +} + +/// Short form also useful in browsers / curl. +pub fn short_dns_name(service: &str, namespace: &str) -> String { + format!("{service}.{namespace}") +} + +/// URL using cluster DNS + real service port. +pub fn format_dns_url(service: &str, namespace: &str, port: u16) -> String { + format!("http://{}:{port}", cluster_dns_name(service, namespace)) +} + +/// Stable key for IP allocation: `namespace/service`. +pub fn alloc_key(namespace: &str, service: &str) -> String { + format!("{namespace}/{service}") +} + +/// Parse `127.53.X.Y` → (X, Y) for sequential allocation. +fn parse_hops_ip(ip: &str) -> Option<(u8, u8)> { + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 || parts[0] != "127" || parts[1] != "53" { + return None; + } + Some((parts[2].parse().ok()?, parts[3].parse().ok()?)) +} + +fn format_hops_ip(mid: u8, low: u8) -> String { + format!("{DNS_IP_PREFIX}.{mid}.{low}") +} + +/// Next free IP after existing bindings (skips 127.53.0.0 and 127.53.0.1). +pub fn next_hops_ip(used: &[&str]) -> String { + let mut max = (0u8, 1u8); // start after .0.1 + for ip in used { + if let Some(p) = parse_hops_ip(ip) { + if p > max { + max = p; + } + } + } + let (mut mid, mut low) = max; + if low == 255 { + mid = mid.saturating_add(1); + low = 2; + } else { + low += 1; + } + format_hops_ip(mid, low) +} + +/// Ensure each service has an IP; returns service → IP (sorted insertion order). +pub fn allocate_service_ips( + namespace: &str, + services: &[super::net::ServiceEndpoint], + existing: &BTreeMap, +) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut used: Vec = existing.values().cloned().collect(); + for svc in services { + let key = alloc_key(namespace, &svc.name); + if let Some(ip) = existing.get(&key) { + out.insert(svc.name.clone(), ip.clone()); + continue; + } + // Prefer reusing if this service name already mapped under same ns in existing values + let ip = next_hops_ip(&used.iter().map(|s| s.as_str()).collect::>()); + used.push(ip.clone()); + out.insert(svc.name.clone(), ip); + } + out +} + +/// Build hosts-file lines for one workspace (no markers). +/// +/// Each line maps one loopback IP to: +/// - full k8s FQDN (`svc.ns.svc.cluster.local`) — primary, matches in-cluster config +/// - mDNS-safe twin without trailing `.local` (`svc.ns.svc.cluster`) — macOS reliability +/// - short `svc.ns` +pub fn hosts_lines_for_workspace( + namespace: &str, + service_ips: &BTreeMap, +) -> Vec { + let mut lines = Vec::new(); + for (svc, ip) in service_ips { + let fqdn = cluster_dns_name(svc, namespace); + // Strip final ".local" for tools that send *.local to mDNS only. + let no_mdns = fqdn.trim_end_matches(".local"); + let short = short_dns_name(svc, namespace); + lines.push(format!("{ip} {fqdn} {no_mdns} {short}")); + } + lines +} + +/// Merge workspace lines into a full hosts file body, replacing our managed block. +pub fn merge_hosts_file(existing: &str, all_workspace_lines: &[String]) -> String { + let stripped = strip_managed_block(existing); + let mut out = stripped.trim_end().to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + out.push_str(HOSTS_BEGIN); + out.push('\n'); + if all_workspace_lines.is_empty() { + out.push_str("# (no hops local workspaces)\n"); + } else { + for line in all_workspace_lines { + out.push_str(line); + out.push('\n'); + } + } + out.push_str(HOSTS_END); + out.push('\n'); + out +} + +/// Remove the hops-managed block from hosts content. +pub fn strip_managed_block(existing: &str) -> String { + let mut out = String::new(); + let mut in_block = false; + for line in existing.lines() { + if line.trim() == HOSTS_BEGIN { + in_block = true; + continue; + } + if line.trim() == HOSTS_END { + in_block = false; + continue; + } + if !in_block { + out.push_str(line); + out.push('\n'); + } + } + out +} + +/// Global IP allocation state under ~/.hops/local/runtime/dns-ip-alloc.json +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DnsIpAlloc { + /// key `namespace/service` → `127.53.x.y` + #[serde(default)] + pub bindings: BTreeMap, +} + +fn alloc_path(state_dir: &Path) -> PathBuf { + state_dir.join("runtime").join("dns-ip-alloc.json") +} + +pub fn load_ip_alloc(state_dir: &Path) -> DnsIpAlloc { + let path = alloc_path(state_dir); + fs::read_to_string(path) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() +} + +pub fn save_ip_alloc(state_dir: &Path, alloc: &DnsIpAlloc) -> Result<(), Box> { + let path = alloc_path(state_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(alloc)?)?; + Ok(()) +} + +/// Update alloc bindings for this namespace's services; drop stale services in ns. +pub fn sync_alloc_for_namespace( + state_dir: &Path, + namespace: &str, + services: &[super::net::ServiceEndpoint], +) -> Result, Box> { + let mut alloc = load_ip_alloc(state_dir); + // Drop bindings for services no longer present in this namespace + let wanted: std::collections::BTreeSet = services + .iter() + .map(|s| alloc_key(namespace, &s.name)) + .collect(); + alloc.bindings.retain(|k, _| { + if let Some(ns) = k.split('/').next() { + if ns == namespace { + return wanted.contains(k); + } + } + true + }); + let service_ips = allocate_service_ips(namespace, services, &alloc.bindings); + for (svc, ip) in &service_ips { + alloc + .bindings + .insert(alloc_key(namespace, svc), ip.clone()); + } + save_ip_alloc(state_dir, &alloc)?; + Ok(service_ips) +} + +/// Rebuild /etc/hosts managed block from **all** workspace runtimes that use dns mode. +/// `workspace_blocks`: list of (namespace, service→ip). +pub fn rebuild_hosts_from_blocks( + workspace_blocks: &[(String, BTreeMap)], +) -> Result<(), Box> { + let mut lines = Vec::new(); + for (ns, ips) in workspace_blocks { + lines.extend(hosts_lines_for_workspace(ns, ips)); + } + let current = fs::read_to_string("/etc/hosts").unwrap_or_default(); + let merged = merge_hosts_file(¤t, &lines); + // Combined with aliases in apply_privileged_dns_config when possible. + write_etc_hosts(&merged)?; + Ok(()) +} + +/// True when `/etc/hosts` managed block + lo0 aliases already match desired config. +/// Used to skip admin prompts on subsequent `up`/`status`. +pub fn dns_os_config_present(hosts_body: &str, loopback_ips: &[String]) -> bool { + let current = fs::read_to_string("/etc/hosts").unwrap_or_default(); + let desired_block = extract_managed_block(hosts_body); + let current_block = extract_managed_block(¤t); + if desired_block.is_empty() || desired_block != current_block { + return false; + } + if !cfg!(target_os = "macos") { + return true; + } + if !macos_resolver_present() { + return false; + } + let lo0 = Command::new("ifconfig") + .arg("lo0") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + loopback_ips.iter().all(|ip| { + ip.is_empty() || ip == "127.0.0.1" || lo0.contains(ip.as_str()) + }) +} + +fn macos_resolver_present() -> bool { + if !cfg!(target_os = "macos") { + return true; + } + let Ok(text) = fs::read_to_string(MACOS_RESOLVER_PATH) else { + return false; + }; + text.contains("127.0.0.1") && text.contains(&MACOS_LOCAL_DNS_PORT.to_string()) +} + +fn extract_managed_block(hosts: &str) -> String { + let mut out = String::new(); + let mut in_block = false; + for line in hosts.lines() { + let t = line.trim(); + if t == HOSTS_BEGIN { + in_block = true; + continue; + } + if t == HOSTS_END { + break; + } + if in_block { + out.push_str(t); + out.push('\n'); + } + } + out +} + +/// Apply hosts file **and** macOS lo0 aliases in **one** elevation when needed. +/// +/// This is the main privileged entry for cluster DNS setup. +/// Skips elevation entirely when OS config already matches (no re-prompt). +pub fn apply_privileged_dns_config( + hosts_body: &str, + loopback_ips: &[String], +) -> Result<(), Box> { + if dns_os_config_present(hosts_body, loopback_ips) { + log::debug!("cluster DNS OS config already present; skipping admin prompt"); + return Ok(()); + } + + let tmp = std::env::temp_dir().join(format!("hops-hosts-{}.tmp", std::process::id())); + fs::write(&tmp, hosts_body)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)); + } + let tmp_s = tmp.to_string_lossy().into_owned(); + + let mut shell = String::new(); + shell.push_str(&format!("cp '{tmp_s}' /etc/hosts && chmod 644 /etc/hosts")); + if cfg!(target_os = "macos") { + for ip in loopback_ips { + if ip == "127.0.0.1" || ip.is_empty() { + continue; + } + // Idempotent: ignore failure if alias already exists. + shell.push_str(&format!( + " && (ifconfig lo0 | grep -q '{ip}' || ifconfig lo0 alias {ip} netmask 0xff000000)" + )); + } + // Bypass mDNS 5s timeout for *.svc.cluster.local via a local stub DNS. + let port = MACOS_LOCAL_DNS_PORT; + shell.push_str(&format!( + " && mkdir -p /etc/resolver && printf 'nameserver 127.0.0.1\\nport {port}\\n' > '{MACOS_RESOLVER_PATH}'" + )); + shell.push_str( + " ; dscacheutil -flushcache 2>/dev/null; killall -HUP mDNSResponder 2>/dev/null; true", + ); + } + + log::info!( + "Configuring cluster DNS on this machine (admin required once): /etc/hosts + loopback aliases" + ); + let result = run_privileged_shell(&shell, PrivilegedPrompt::InteractiveOnce); + let _ = fs::remove_file(&tmp); + result.map_err(|e| { + format!( + "cluster DNS needs admin privileges to write /etc/hosts (and lo0 aliases on macOS).\n\ + {e}\n\ + Re-run `hops local up` or `hops local status` and approve the **single** prompt,\n\ + or grant passwordless sudo for hops on this machine." + ) + .into() + }) +} + +/// Write /etc/hosts only (used when aliases already present). +pub fn write_etc_hosts(content: &str) -> Result<(), Box> { + apply_privileged_dns_config(content, &[]) +} + +/// Whether elevation may prompt the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivilegedPrompt { + /// `sudo -n` only — never open a password dialog (cleanup / best-effort). + Never, + /// At most one interactive elevation (sudo **or** macOS GUI, not both). + InteractiveOnce, +} + +/// Run a shell script with elevation. +/// +/// **Never** cascades multiple password prompts: passwordless sudo first, then +/// exactly one interactive path (TTY → `sudo`, else macOS → `osascript`). +pub fn run_privileged_shell( + script: &str, + prompt: PrivilegedPrompt, +) -> Result<(), Box> { + // 1) passwordless sudo (cached ticket after a recent successful elevation) + let status = Command::new("sudo") + .args(["-n", "sh", "-c", script]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + if status.as_ref().map(|s| s.success()).unwrap_or(false) { + return Ok(()); + } + + if matches!(prompt, PrivilegedPrompt::Never) { + return Err("admin elevation required (non-interactive; sudo -n failed)".into()); + } + + // 2) Exactly one interactive method — do not fall through to a second prompt. + use std::io::IsTerminal; + if std::io::stdin().is_terminal() { + let status = Command::new("sudo") + .args(["sh", "-c", script]) + .status() + .map_err(|e| format!("sudo failed to start: {e}"))?; + if status.success() { + return Ok(()); + } + return Err("admin elevation denied or failed (sudo)".into()); + } + + if cfg!(target_os = "macos") { + // GUI admin dialog (IDEs / agents without a TTY). Escape for AppleScript. + let escaped = script + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "; "); + let applescript = + format!("do shell script \"{escaped}\" with administrator privileges"); + let status = Command::new("osascript") + .args(["-e", &applescript]) + .status() + .map_err(|e| format!("osascript failed to start: {e}"))?; + if status.success() { + return Ok(()); + } + return Err("admin elevation denied or failed (macOS dialog)".into()); + } + + Err("admin elevation required but no TTY and no GUI helper available".into()) +} + +/// Ensure loopback aliases exist (macOS needs `ifconfig lo0 alias`; Linux /8 is fine). +pub fn ensure_loopback_aliases(ips: &[String]) -> Result<(), Box> { + if !cfg!(target_os = "macos") { + return Ok(()); + } + let missing: Vec = ips + .iter() + .filter(|ip| !ip.is_empty() && *ip != "127.0.0.1") + .filter(|ip| { + !Command::new("ifconfig") + .arg("lo0") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(ip.as_str())) + .unwrap_or(false) + }) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(()); + } + let mut shell = String::from("true"); + for ip in &missing { + shell.push_str(&format!( + " && ifconfig lo0 alias {ip} netmask 0xff000000" + )); + } + run_privileged_shell(&shell, PrivilegedPrompt::InteractiveOnce).map_err(|e| { + format!("could not create loopback aliases on lo0: {e}").into() + }) +} + +/// Best-effort remove loopback aliases (macOS). **Never prompts** — cleanup only. +pub fn remove_loopback_aliases(ips: &[String]) { + if !cfg!(target_os = "macos") { + return; + } + let mut shell = String::from("true"); + let mut any = false; + for ip in ips { + if ip.is_empty() || ip == "127.0.0.1" { + continue; + } + any = true; + shell.push_str(&format!(" ; ifconfig lo0 -alias {ip} 2>/dev/null")); + } + if any { + let _ = run_privileged_shell(&shell, PrivilegedPrompt::Never); + } +} + +/// Port-forward argv: bind cluster service port on a specific loopback IP. +pub fn build_dns_port_forward_args( + namespace: &str, + service: &str, + bind_ip: &str, + service_port: u16, +) -> Vec { + vec![ + "port-forward".into(), + "-n".into(), + namespace.into(), + format!("svc/{service}"), + format!("{service_port}:{service_port}"), + "--address".into(), + bind_ip.into(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::local::workbench::net::ServiceEndpoint; + + #[test] + fn cluster_names_look_like_k8s_dns() { + assert_eq!( + cluster_dns_name("e2e-ui-api", "dogfood"), + "e2e-ui-api.dogfood.svc.cluster.local" + ); + assert_eq!( + format_dns_url("e2e-ui-ui", "dogfood", 5180), + "http://e2e-ui-ui.dogfood.svc.cluster.local:5180" + ); + } + + #[test] + fn next_ip_increments() { + let a = next_hops_ip(&[]); + assert_eq!(a, "127.53.0.2"); + let b = next_hops_ip(&["127.53.0.2", "127.53.0.3"]); + assert_eq!(b, "127.53.0.4"); + } + + #[test] + fn allocate_stable_across_calls() { + let services = vec![ + ServiceEndpoint { + namespace: "x".into(), + name: "api".into(), + port: 8791, + protocol: "TCP".into(), + }, + ServiceEndpoint { + namespace: "x".into(), + name: "ui".into(), + port: 5180, + protocol: "TCP".into(), + }, + ]; + let mut existing = BTreeMap::new(); + existing.insert("x/api".into(), "127.53.0.5".into()); + let ips = allocate_service_ips("x", &services, &existing); + assert_eq!(ips.get("api").map(String::as_str), Some("127.53.0.5")); + assert!(ips.get("ui").unwrap().starts_with("127.53.")); + assert_ne!(ips.get("ui"), ips.get("api")); + } + + #[test] + fn merge_hosts_replaces_block() { + let existing = "127.0.0.1 localhost\n# BEGIN hops-local-dns (managed by hops local — do not edit)\nold\n# END hops-local-dns\n"; + let lines = hosts_lines_for_workspace( + "x", + &BTreeMap::from([("foo".into(), "127.53.0.2".into())]), + ); + let merged = merge_hosts_file(existing, &lines); + assert!(merged.contains("127.0.0.1 localhost")); + assert!(merged.contains("foo.x.svc.cluster.local")); + assert!(merged.contains("foo.x.svc.cluster")); // mDNS-safe twin + assert!(!merged.contains("\nold\n")); + assert_eq!(merged.matches(HOSTS_BEGIN).count(), 1); + } + + #[test] + fn dns_port_forward_binds_address() { + let args = build_dns_port_forward_args("x", "api", "127.53.0.2", 8791); + assert!(args.contains(&"--address".into())); + assert!(args.contains(&"127.53.0.2".into())); + assert!(args.contains(&"8791:8791".into())); + assert!(args.contains(&"svc/api".into())); + } +} diff --git a/src/commands/local/workbench/cluster_gitops.rs b/src/commands/local/workbench/cluster_gitops.rs new file mode 100644 index 0000000..2bb0e46 --- /dev/null +++ b/src/commands/local/workbench/cluster_gitops.rs @@ -0,0 +1,453 @@ +//! Reconcile a cluster gitops tree onto the **shared** local control plane. +//! +//! One local CP (dory/colima/kind) serves many worktrees/projects. Cluster +//! config is **not** per-worktree — it lives at the meta repo root (or any +//! path passed via `--cluster`): +//! +//! ```text +//! / # meta root +//! gitops/cluster/ # CP: PSQLStack, AuthStack, packages… +//! clients/foo/.gitops/deploy/ # per-project charts +//! platform/api/.gitops/deploy/ +//! gitops/envs/local/ # Application YAMLs → namespace = --name +//! ``` +//! +//! Env Applications only isolate **app** namespaces. Cluster YAML is applied +//! once to the CP and reconciled by Crossplane. +//! +//! Watched like env gitops: change a file → `kubectl apply` → CP. + +use crate::commands::local::kubectl_command; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub struct ClusterReconcileResult { + pub applied: Vec, + pub skipped: Vec, + pub errors: Vec, +} + +/// Resolve cluster gitops directory. +/// +/// Order: +/// 1. Explicit `override_path` (`--cluster`) +/// 2. Env var `HOPS_LOCAL_CLUSTER` +/// 3. Walk up from `env_path` looking for `gitops/cluster` or `cluster` +/// 4. Walk up from cwd looking for `gitops/cluster` +/// +/// Returns the first existing directory. Explicit override that does not exist +/// is left to the caller to error on canonicalize. +pub fn resolve_cluster_path( + env_path: Option<&Path>, + override_path: Option<&Path>, +) -> Option { + if let Some(p) = override_path { + return Some(p.to_path_buf()); + } + if let Ok(p) = std::env::var("HOPS_LOCAL_CLUSTER") { + let pb = PathBuf::from(p.trim()); + if !p.trim().is_empty() && pb.is_dir() { + return Some(pb); + } + } + if let Some(env) = env_path { + if let Some(found) = discover_cluster_path(env) { + return Some(found); + } + } + if let Ok(cwd) = std::env::current_dir() { + return walk_up_for_cluster(&cwd); + } + None +} + +/// Discover a cluster tree near an env path (or walk to meta root). +/// +/// ```text +/// gitops/envs/local → sibling gitops/cluster +/// gitops/env/local → sibling gitops/cluster +/// some/deep/project → walk up → /gitops/cluster +/// /gitops → /gitops/cluster +/// ``` +pub fn discover_cluster_path(env_path: &Path) -> Option { + let env = env_path + .canonicalize() + .unwrap_or_else(|_| env_path.to_path_buf()); + + // Tight layouts first (same gitops/ as envs) + if let Some(parent) = env.parent() { + let name = parent.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if name == "envs" || name == "env" { + if let Some(gitops) = parent.parent() { + let cluster = gitops.join("cluster"); + if cluster.is_dir() { + return Some(cluster); + } + } + } + } + if env.file_name().and_then(|s| s.to_str()) == Some("gitops") { + let cluster = env.join("cluster"); + if cluster.is_dir() { + return Some(cluster); + } + } + if let Some(parent) = env.parent() { + let cluster = parent.join("cluster"); + if cluster.is_dir() { + return Some(cluster); + } + } + + // Meta-root walk: any ancestor with gitops/cluster or cluster/ + walk_up_for_cluster(&env) +} + +/// Walk from `start` toward filesystem root for `gitops/cluster` or `cluster`. +fn walk_up_for_cluster(start: &Path) -> Option { + let mut cur = start.canonicalize().unwrap_or_else(|_| start.to_path_buf()); + loop { + for candidate in [ + cur.join("gitops").join("cluster"), + cur.join("cluster"), + ] { + if candidate.is_dir() { + return Some(candidate); + } + } + if !cur.pop() { + break; + } + } + None +} + +/// Collect YAML manifests under cluster_path (recursive). +/// Skips examples, docs, and non-manifest files. +/// +/// Order: `packages/` first (Configuration installs that establish CRDs), then +/// everything else. Alphabetical within each group. +pub fn collect_cluster_manifests(cluster_path: &Path) -> Result, Box> { + let mut out = Vec::new(); + collect_manifests_rec(cluster_path, &mut out)?; + out.sort_by(|a, b| { + let a_pkg = path_under_packages(cluster_path, a); + let b_pkg = path_under_packages(cluster_path, b); + match (a_pkg, b_pkg) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.cmp(b), + } + }); + Ok(out) +} + +fn path_under_packages(cluster_path: &Path, path: &Path) -> bool { + path.strip_prefix(cluster_path) + .ok() + .and_then(|rel| rel.components().next()) + .map(|c| c.as_os_str() == "packages") + .unwrap_or(false) +} + +fn collect_manifests_rec(dir: &Path, out: &mut Vec) -> Result<(), Box> { + if !dir.is_dir() { + return Ok(()); + } + for ent in fs::read_dir(dir)? { + let ent = ent?; + let path = ent.path(); + if path.is_dir() { + // Skip common non-manifest trees + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if matches!(name, ".git" | "node_modules" | "target" | "_output") { + continue; + } + collect_manifests_rec(&path, out)?; + continue; + } + if should_apply_manifest(&path) { + out.push(path); + } + } + Ok(()) +} + +/// Whether a file should be `kubectl apply`'d. +pub fn should_apply_manifest(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !(name.ends_with(".yaml") || name.ends_with(".yml")) { + return false; + } + if name.ends_with(".example") || name.ends_with(".example.yaml") || name.ends_with(".example.yml") + { + return false; + } + if name.contains(".example.") { + return false; + } + // *.yaml.example pattern: file name ends with .example already handled; + // also skip foo.yaml.example via ends_with .example above when full name is x.yaml.example + if name.ends_with(".yaml.example") || name.ends_with(".yml.example") { + return false; + } + // Skip docs-named files that sometimes are yaml + if name == "readme.yaml" || name == "secrets.yaml" { + return false; + } + // Must look like a k8s document + let Ok(text) = fs::read_to_string(path) else { + return false; + }; + let head = text.lines().take(30).collect::>().join("\n"); + head.contains("apiVersion:") && head.contains("kind:") +} + +/// Apply all cluster manifests to the current kube context. +pub fn reconcile_cluster_dir( + cluster_path: &Path, + dry_run: bool, +) -> Result> { + let cluster_path = cluster_path + .canonicalize() + .map_err(|e| format!("cluster path {}: {e}", cluster_path.display()))?; + if !cluster_path.is_dir() { + return Err(format!( + "cluster path is not a directory: {}", + cluster_path.display() + ) + .into()); + } + + let manifests = collect_cluster_manifests(&cluster_path)?; + let mut result = ClusterReconcileResult::default(); + + if manifests.is_empty() { + log::info!( + "cluster gitops: no applyable YAML under {}", + cluster_path.display() + ); + return Ok(result); + } + + log::info!( + "cluster gitops: reconciling {} manifest(s) from {}", + manifests.len(), + cluster_path.display() + ); + + for path in manifests { + match apply_one(&path, dry_run) { + Ok(()) => { + log::info!( + " {} {}", + path.strip_prefix(&cluster_path) + .unwrap_or(&path) + .display(), + if dry_run { "dry-run" } else { "applied" } + ); + result.applied.push(path); + } + Err(e) => { + let msg = format!("{}: {e}", path.display()); + log::error!(" {msg}"); + result.errors.push(msg); + } + } + } + + if !result.errors.is_empty() && result.applied.is_empty() { + return Err(format!( + "cluster gitops: all applies failed ({} error(s))", + result.errors.len() + ) + .into()); + } + if !result.errors.is_empty() { + log::warn!( + "cluster gitops: {} applied, {} failed (partial — Crossplane may still reconcile successes)", + result.applied.len(), + result.errors.len() + ); + } + Ok(result) +} + +fn apply_one(path: &Path, dry_run: bool) -> Result<(), Box> { + let path_s = path.to_string_lossy(); + let mut args = vec!["apply", "-f", path_s.as_ref()]; + if dry_run { + args.push("--dry-run=server"); + } + // Prefer server dry-run; fall back to client if CRDs missing on dry-run only + match run_kubectl(&args) { + Ok(()) => Ok(()), + Err(e) if dry_run => { + let client_args = vec!["apply", "-f", path_s.as_ref(), "--dry-run=client"]; + run_kubectl(&client_args).map_err(|_| e) + } + Err(e) => Err(e), + } +} + +fn run_kubectl(args: &[&str]) -> Result<(), Box> { + let output = kubectl_command(args) + .output() + .map_err(|e| format!("kubectl failed to start: {e}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!("{}{}", stdout.trim(), stderr.trim()).into()) +} + +/// Whether a path under cluster_path should trigger re-reconcile. +pub fn should_reconcile_cluster_change(changed: &Path, cluster_path: &Path) -> bool { + if crate::commands::local::workbench::watch::should_ignore_watch_path(changed) { + return false; + } + let cluster = cluster_path.canonicalize().unwrap_or_else(|_| cluster_path.to_path_buf()); + let changed_norm = changed.canonicalize().unwrap_or_else(|_| changed.to_path_buf()); + if !(changed_norm == cluster || changed_norm.starts_with(&cluster)) { + return false; + } + // Any yaml change under cluster, or delete events (path may not exist) + let name = changed + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + name.ends_with(".yaml") + || name.ends_with(".yml") + || !changed.exists() // deletion of a prior manifest +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn packages_sort_before_other_manifests() { + let dir = std::env::temp_dir().join(format!( + "hops-cg-sort-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let packages = dir.join("packages"); + let auth = dir.join("auth"); + fs::create_dir_all(&packages).unwrap(); + fs::create_dir_all(&auth).unwrap(); + fs::write( + packages.join("psql-stack.yaml"), + "apiVersion: pkg.crossplane.io/v1\nkind: Configuration\n", + ) + .unwrap(); + fs::write( + auth.join("stack.yaml"), + "apiVersion: hops.ops.com.ai/v1alpha1\nkind: AuthStack\n", + ) + .unwrap(); + let manifests = collect_cluster_manifests(&dir).unwrap(); + assert_eq!(manifests.len(), 2); + assert!(manifests[0].ends_with("packages/psql-stack.yaml") + || manifests[0] + .file_name() + .and_then(|s| s.to_str()) + == Some("psql-stack.yaml")); + assert!(path_under_packages(&dir, &manifests[0])); + assert!(!path_under_packages(&dir, &manifests[1])); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn discover_from_envs_local() { + let dir = std::env::temp_dir().join(format!( + "hops-cg-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let envs = dir.join("gitops/envs/local"); + let cluster = dir.join("gitops/cluster"); + fs::create_dir_all(&envs).unwrap(); + fs::create_dir_all(&cluster).unwrap(); + let found = discover_cluster_path(&envs).unwrap(); + assert_eq!(found.canonicalize().unwrap(), cluster.canonicalize().unwrap()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn discover_walks_up_to_meta_root() { + let dir = std::env::temp_dir().join(format!( + "hops-cg-meta-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + // Meta-root layout: cluster at meta root, env deep under a project + let cluster = dir.join("gitops/cluster"); + let deep_env = dir.join("clients/foo/gitops/envs/local"); + fs::create_dir_all(&cluster).unwrap(); + fs::create_dir_all(&deep_env).unwrap(); + let found = discover_cluster_path(&deep_env).unwrap(); + assert_eq!( + found.canonicalize().unwrap(), + cluster.canonicalize().unwrap() + ); + // explicit override wins + let other = dir.join("other-cluster"); + fs::create_dir_all(&other).unwrap(); + let resolved = resolve_cluster_path(Some(&deep_env), Some(&other)).unwrap(); + assert_eq!(resolved, other); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn skips_examples_and_docs() { + let dir = std::env::temp_dir().join(format!("hops-cg-skip-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("stack.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n", + ) + .unwrap(); + fs::write( + dir.join("aws.yaml.example"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ex\n", + ) + .unwrap(); + fs::write(dir.join("README.md"), "# hi\n").unwrap(); + let m = collect_cluster_manifests(&dir).unwrap(); + assert_eq!(m.len(), 1); + assert!(m[0].ends_with("stack.yaml")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn should_apply_requires_apiversion_kind() { + let dir = std::env::temp_dir().join(format!("hops-cg-kind-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let good = dir.join("good.yaml"); + let bad = dir.join("bad.yaml"); + fs::write(&good, "apiVersion: v1\nkind: Namespace\nmetadata:\n name: n\n").unwrap(); + fs::write(&bad, "just: a map\n").unwrap(); + assert!(should_apply_manifest(&good)); + assert!(!should_apply_manifest(&bad)); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/commands/local/workbench/delivery.rs b/src/commands/local/workbench/delivery.rs new file mode 100644 index 0000000..7ff8eb8 --- /dev/null +++ b/src/commands/local/workbench/delivery.rs @@ -0,0 +1,1203 @@ +//! Speed-first source delivery: hostPath when probe passes, mutagen-class fallback. +//! +//! Probe checks whether the host path is visible **on the Kubernetes node** +//! (not merely whether it exists on the laptop). Sync mode actually transfers +//! files into cluster-dev pods using mutagen when available, else tar|kubectl +//! exec with the same ignore list. + +use std::error::Error; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::commands::local::kubectl_command; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryStrategy { + /// Worktree path is visible on the node — mount hostPath. + HostPath, + /// Probe failed — mutagen-class (or equivalent) host→pod sync. + Sync, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeliveryProbe { + /// Absolute host worktree / project root path. + pub host_path: PathBuf, + /// Whether the path is visible on the Kubernetes node. + pub host_path_visible: bool, + /// Optional detail for status/verbose (probe command result). + pub detail: String, +} + +/// Default paths excluded from mutagen-class sync (LWB-REQ-150). +pub fn default_sync_ignores() -> Vec<&'static str> { + vec![ + "node_modules", + "target", + ".git", + "dist", + "build", + ".svelte-kit", + "playwright-report", + "test-results", + "_output", + ".cache", + ".turbo", + "coverage", + // macOS noise that bloats tar and confuses extract + ".DS_Store", + ] +} + +/// Auto-select delivery strategy from probe result (LWB-REQ-140, LWB-REQ-240). +/// Prefers hostPath when capable; never requires user choice. +pub fn select_delivery_strategy(probe: &DeliveryProbe) -> DeliveryStrategy { + if probe.host_path_visible { + DeliveryStrategy::HostPath + } else { + DeliveryStrategy::Sync + } +} + +impl DeliveryStrategy { + pub fn as_str(self) -> &'static str { + match self { + DeliveryStrategy::HostPath => "hostPath", + DeliveryStrategy::Sync => "sync", + } + } + + /// Runtime values fragment for helm inject. + pub fn helm_mode_value(self) -> &'static str { + self.as_str() + } +} + +/// Build a probe result from a pure boolean (unit-test / fake backend). +pub fn probe_from_visibility( + host_path: &Path, + visible: bool, + detail: impl Into, +) -> DeliveryProbe { + DeliveryProbe { + host_path: host_path.to_path_buf(), + host_path_visible: visible, + detail: detail.into(), + } +} + +/// Whether a relative path component should be excluded from sync sessions. +pub fn path_is_sync_excluded(path: &Path) -> bool { + let ignores = default_sync_ignores(); + path.components().any(|c| { + let s = c.as_os_str().to_string_lossy(); + ignores.iter().any(|ig| *ig == s.as_ref()) + }) +} + +/// Mutagen CLI `--ignore` flags derived from the shared ignore list (shipped path). +pub fn mutagen_ignore_args() -> Vec { + default_sync_ignores() + .into_iter() + .flat_map(|ig| vec!["--ignore".to_string(), ig.to_string()]) + .collect() +} + +/// GNU/BSD tar `--exclude=` flags for the shared ignore list (shipped path). +pub fn tar_exclude_args() -> Vec { + let mut args: Vec = default_sync_ignores() + .into_iter() + .map(|ig| format!("--exclude={ig}")) + .collect(); + // AppleDouble resource forks (`._file`) — pattern exclude (bsdtar/gnutar). + args.push("--exclude=._*".into()); + args +} + +/// Build mutagen sync create argv (without program name) for a host→pod session. +/// +/// Destination uses the kubectl-exec transport form consumed by +/// [`start_mutagen_session`]; pure so unit tests can assert ignore wiring. +pub fn build_mutagen_create_args( + session_name: &str, + host_path: &Path, + dest_url: &str, +) -> Vec { + let mut args = vec![ + "sync".into(), + "create".into(), + "--name".into(), + session_name.into(), + "--sync-mode".into(), + "one-way-replica".into(), + ]; + args.extend(mutagen_ignore_args()); + args.push(host_path.display().to_string()); + args.push(dest_url.into()); + args +} + +/// Session name for a workspace app pair (DNS-safe-ish). +pub fn sync_session_name(workspace: &str, app: &str) -> String { + format!("hops-lwb-{workspace}-{app}") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect() +} + +/// Target pod for source delivery. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncPodTarget { + pub namespace: String, + pub pod: String, + pub container: Option, + pub mount_path: String, + pub app_name: String, + /// Per-app host directory to tar/sync (NOT a shared monorepo root for all apps). + pub host_source_path: PathBuf, +} + +/// Result of attaching delivery after reconcile. +#[derive(Debug, Clone)] +pub struct DeliveryAttachResult { + pub strategy: DeliveryStrategy, + pub probe: DeliveryProbe, + /// Mutagen session names started (if any). + pub mutagen_sessions: Vec, + /// Background sync watcher PIDs (tar-based continuous sync). + pub sync_pids: Vec, + pub messages: Vec, +} + +/// Abstraction over node path visibility for tests. +pub trait NodePathProber { + fn probe(&self, host_path: &Path) -> Result>; +} + +/// System prober: host dir must exist, then verify the **node** can see it. +/// +/// Strategies (first success wins): +/// 1. Docker exec into containers matching Ready node names (kind/dory-style). +/// 2. Short-lived probe Pod with hostPath mount (portable; detects FailedMount). +pub struct SystemNodeProber; + +impl NodePathProber for SystemNodeProber { + fn probe(&self, host_path: &Path) -> Result> { + probe_node_path_visibility(host_path) + } +} + +/// Production probe entrypoint. +pub fn probe_node_path_visibility(host_path: &Path) -> Result> { + if !host_path.is_dir() { + return Ok(probe_from_visibility( + host_path, + false, + "host path is not a directory", + )); + } + let abs = host_path + .canonicalize() + .unwrap_or_else(|_| host_path.to_path_buf()); + + // 1) Docker node containers (kind / dory / similar) + match try_docker_node_probe(&abs) { + Ok(Some(probe)) => return Ok(probe), + Ok(None) => {} + Err(e) => log::debug!("docker node probe skipped: {e}"), + } + + // 2) kubectl hostPath probe pod + match try_kubectl_hostpath_probe(&abs) { + Ok(probe) => return Ok(probe), + Err(e) => log::debug!("kubectl hostPath probe failed: {e}"), + } + + // Fail closed → Sync (do not assume hostPath for Mac paths on remote nodes) + Ok(probe_from_visibility( + &abs, + false, + "node path not visible (docker+kubectl probes failed or inconclusive); selecting sync", + )) +} + +fn try_docker_node_probe(host_path: &Path) -> Result, Box> { + if !command_exists("docker") || !command_exists("kubectl") { + return Ok(None); + } + let nodes_json = Command::new("kubectl") + .args(["get", "nodes", "-o", "json"]) + .output()?; + if !nodes_json.status.success() { + return Ok(None); + } + let value: serde_json::Value = serde_json::from_slice(&nodes_json.stdout)?; + let Some(items) = value.get("items").and_then(|i| i.as_array()) else { + return Ok(None); + }; + let mut node_names = Vec::new(); + for item in items { + if let Some(name) = item.pointer("/metadata/name").and_then(|v| v.as_str()) { + node_names.push(name.to_string()); + } + } + if node_names.is_empty() { + return Ok(None); + } + + // Candidate container names: exact node name, kind-style, dory-k8s, hops-control-plane + let mut candidates: Vec = node_names.clone(); + candidates.push("dory-k8s".into()); + candidates.push("hops-control-plane".into()); + for n in &node_names { + // kind often uses -control-plane matching node name already + if let Some(stripped) = n.strip_suffix("-control-plane") { + candidates.push(format!("{stripped}-control-plane")); + } + } + candidates.sort(); + candidates.dedup(); + + let path_str = host_path.display().to_string(); + for container in candidates { + let status = Command::new("docker") + .args(["exec", &container, "test", "-d", &path_str]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => { + return Ok(Some(probe_from_visibility( + host_path, + true, + format!("docker exec {container}: path visible on node"), + ))); + } + Ok(_) => { + // Container exists but path missing → definitive not visible if we hit a real node + if docker_container_running(&container) { + return Ok(Some(probe_from_visibility( + host_path, + false, + format!("docker exec {container}: path not present on node"), + ))); + } + } + Err(_) => continue, + } + } + Ok(None) +} + +fn docker_container_running(name: &str) -> bool { + Command::new("docker") + .args([ + "inspect", + "-f", + "{{.State.Running}}", + name, + ]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim() == "true") + .unwrap_or(false) +} + +fn try_kubectl_hostpath_probe(host_path: &Path) -> Result> { + let name = format!( + "hops-path-probe-{}", + std::process::id() % 100_000 + ); + let path_str = host_path.display().to_string(); + // Escape for YAML double quotes + let path_yaml = path_str.replace('\\', "\\\\").replace('"', "\\\""); + let yaml = format!( + r#"apiVersion: v1 +kind: Pod +metadata: + name: {name} + namespace: default + labels: + app.kubernetes.io/managed-by: hops-local-gitops + hops.ops.com.ai/probe: path-visibility +spec: + restartPolicy: Never + terminationGracePeriodSeconds: 1 + containers: + - name: probe + image: busybox:1.36 + command: + - sh + - -c + - "test -d /probe && echo HOPS_PATH_VISIBLE || echo HOPS_PATH_MISSING" + volumeMounts: + - name: host + mountPath: /probe + readOnly: true + volumes: + - name: host + hostPath: + path: "{path_yaml}" + type: Directory +"# + ); + + // Apply + let mut child = Command::new("kubectl") + .args(["apply", "-f", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(yaml.as_bytes())?; + } + let apply_out = child.wait_with_output()?; + if !apply_out.status.success() { + let _ = Command::new("kubectl") + .args(["delete", "pod", &name, "-n", "default", "--wait=false"]) + .output(); + return Err(format!( + "probe pod apply failed: {}", + String::from_utf8_lossy(&apply_out.stderr) + ) + .into()); + } + + // Wait up to ~15s for Succeeded/Failed + let mut visible = false; + let mut detail = String::from("kubectl hostPath probe: timeout"); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(400)); + let phase_out = Command::new("kubectl") + .args([ + "get", + "pod", + &name, + "-n", + "default", + "-o", + "jsonpath={.status.phase}", + ]) + .output()?; + let phase = String::from_utf8_lossy(&phase_out.stdout).trim().to_string(); + + // FailedMount appears in events / container statuses + let desc = Command::new("kubectl") + .args(["describe", "pod", &name, "-n", "default"]) + .output()?; + let desc_s = String::from_utf8_lossy(&desc.stdout); + if desc_s.contains("FailedMount") + || desc_s.contains("failed to mount") + || desc_s.contains("hostPath type check failed") + || desc_s.contains("not a directory") + { + visible = false; + detail = "kubectl hostPath probe: FailedMount (path not on node)".into(); + break; + } + + if phase == "Succeeded" || phase == "Failed" || phase == "Running" { + let logs = Command::new("kubectl") + .args(["logs", &name, "-n", "default", "--tail=20"]) + .output()?; + let log_s = String::from_utf8_lossy(&logs.stdout); + if log_s.contains("HOPS_PATH_VISIBLE") { + visible = true; + detail = "kubectl hostPath probe: path visible on node".into(); + } else if log_s.contains("HOPS_PATH_MISSING") { + visible = false; + detail = "kubectl hostPath probe: mount empty/missing".into(); + } else if phase == "Succeeded" { + // Treat success without marker as visible (container started = mount ok) + visible = true; + detail = format!("kubectl hostPath probe: phase={phase}"); + } else { + visible = false; + detail = format!("kubectl hostPath probe: phase={phase} logs={log_s}"); + } + break; + } + } + + let _ = Command::new("kubectl") + .args([ + "delete", + "pod", + &name, + "-n", + "default", + "--wait=false", + "--ignore-not-found=true", + ]) + .output(); + + Ok(probe_from_visibility(host_path, visible, detail)) +} + +fn command_exists(program: &str) -> bool { + Command::new("sh") + .args(["-c", &format!("command -v {program} >/dev/null 2>&1")]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +// ── Sync attach (real delivery) ───────────────────────────────────────────── + +/// Pure argv for the host-side `tar cf` half of sync (includes ignore excludes). +pub fn build_tar_cf_args(host_path: &Path) -> Vec { + let mut tar_args: Vec = vec!["cf".into(), "-".into()]; + tar_args.extend(tar_exclude_args()); + tar_args.push("-C".into()); + tar_args.push(host_path.display().to_string()); + tar_args.push(".".into()); + tar_args +} + +/// Pure argv for the kubectl exec half of sync (extract into mount_path). +pub fn build_kubectl_tar_extract_args(target: &SyncPodTarget) -> Vec { + let mut kubectl_args = vec![ + "exec".into(), + "-i".into(), + "-n".into(), + target.namespace.clone(), + target.pod.clone(), + ]; + if let Some(c) = &target.container { + kubectl_args.push("-c".into()); + kubectl_args.push(c.clone()); + } + kubectl_args.push("--".into()); + kubectl_args.push("tar".into()); + kubectl_args.push("xf".into()); + kubectl_args.push("-".into()); + kubectl_args.push("-C".into()); + kubectl_args.push(target.mount_path.clone()); + kubectl_args +} + +/// One-shot tar|kubectl exec sync of host_path → pod mount, applying ignore list. +/// Retries briefly while the container becomes exec-ready. +pub fn sync_directory_to_pod( + host_path: &Path, + target: &SyncPodTarget, +) -> Result<(), Box> { + if !host_path.is_dir() { + return Err(format!("sync source not a directory: {}", host_path.display()).into()); + } + let mut last_err = String::new(); + // Pods often become exec-ready a few seconds after Running; keep trying. + for attempt in 1..=12 { + match sync_directory_to_pod_once(host_path, target) { + Ok(()) => return Ok(()), + Err(e) => { + last_err = e.to_string(); + log::warn!( + "sync attempt {attempt}/12 for {} failed: {last_err}", + target.pod + ); + std::thread::sleep(Duration::from_secs(2)); + } + } + } + Err(format!("sync failed after retries: {last_err}").into()) +} + +fn sync_directory_to_pod_once( + host_path: &Path, + target: &SyncPodTarget, +) -> Result<(), Box> { + let tar_args = build_tar_cf_args(host_path); + + let mut tar = Command::new("tar") + .args(&tar_args) + .env("COPYFILE_DISABLE", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn tar: {e}"))?; + + // Strip the leading "exec" so kubectl_command can inject --context. + let kubectl_args = build_kubectl_tar_extract_args(target); + let kubectl_refs: Vec<&str> = kubectl_args.iter().map(String::as_str).collect(); + let mut kubectl = kubectl_command(&kubectl_refs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn kubectl exec: {e}"))?; + + // Pipe tar stdout → kubectl stdin + if let (Some(mut tar_out), Some(mut k_in)) = (tar.stdout.take(), kubectl.stdin.take()) { + std::io::copy(&mut tar_out, &mut k_in).map_err(|e| format!("pipe tar→kubectl: {e}"))?; + } + let tar_status = tar.wait()?; + let k_out = kubectl.wait_with_output()?; + if !tar_status.success() { + return Err(format!("tar failed with {tar_status}").into()); + } + if !k_out.status.success() { + return Err(format!( + "kubectl exec tar extract failed: {}", + String::from_utf8_lossy(&k_out.stderr) + ) + .into()); + } + write_sync_marker(target)?; + Ok(()) +} + +fn write_sync_marker(target: &SyncPodTarget) -> Result<(), Box> { + let marker = format!("{}/.hops-synced", target.mount_path.trim_end_matches('/')); + let mut args = vec![ + "exec".to_string(), + "-n".to_string(), + target.namespace.clone(), + target.pod.clone(), + ]; + if let Some(c) = &target.container { + args.push("-c".to_string()); + args.push(c.clone()); + } + args.push("--".to_string()); + args.push("sh".to_string()); + args.push("-c".to_string()); + args.push(format!("touch {marker}")); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = kubectl_command(&refs).output()?; + if !out.status.success() { + return Err(format!( + "failed to write sync marker: {}", + String::from_utf8_lossy(&out.stderr) + ) + .into()); + } + Ok(()) +} + +/// Discover Running pods labeled for this workspace and attach per-app host paths. +/// +/// `app_host_paths`: app name → absolute host directory to sync into that pod. +pub fn discover_sync_targets( + namespace: &str, + workspace: &str, + mount_path: &str, + app_host_paths: &std::collections::BTreeMap, +) -> Result, Box> { + let label = format!("hops.ops.com.ai/local-env={workspace}"); + let json = kubectl_command(&[ + "get", + "pods", + "-n", + namespace, + "-l", + &label, + "-o", + "json", + ]) + .output()?; + if !json.status.success() { + return Err(format!( + "kubectl get pods failed: {}", + String::from_utf8_lossy(&json.stderr) + ) + .into()); + } + let value: serde_json::Value = serde_json::from_slice(&json.stdout)?; + let mut out = Vec::new(); + if let Some(items) = value.get("items").and_then(|i| i.as_array()) { + for item in items { + let phase = item + .pointer("/status/phase") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // Only Running pods accept kubectl exec for tar extract. + if phase != "Running" { + continue; + } + let pod = item + .pointer("/metadata/name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if pod.is_empty() { + continue; + } + let app = item + .pointer("/metadata/labels/hops.ops.com.ai~1local-app") + .and_then(|v| v.as_str()) + .or_else(|| { + item.pointer("/metadata/labels/app.kubernetes.io~1name") + .and_then(|v| v.as_str()) + }) + .unwrap_or("app") + .to_string(); + let container = item + .pointer("/spec/containers/0/name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let host_source_path = app_host_paths + .get(&app) + .cloned() + .or_else(|| { + // Fuzzy: app name contains key + app_host_paths + .iter() + .find(|(k, _)| app.contains(k.as_str()) || k.contains(&app)) + .map(|(_, p)| p.clone()) + }) + .unwrap_or_else(|| PathBuf::from(".")); + out.push(SyncPodTarget { + namespace: namespace.to_string(), + pod, + container, + mount_path: mount_path.to_string(), + app_name: app, + host_source_path, + }); + } + } + Ok(out) +} + +/// Start mutagen session if mutagen is on PATH; returns session name. +pub fn start_mutagen_session( + session_name: &str, + host_path: &Path, + dest_url: &str, +) -> Result<(), Box> { + if !command_exists("mutagen") { + return Err("mutagen not on PATH".into()); + } + // Terminate any prior session with same name + let _ = Command::new("mutagen") + .args(["sync", "terminate", session_name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let args = build_mutagen_create_args(session_name, host_path, dest_url); + let output = Command::new("mutagen").args(&args).output()?; + if !output.status.success() { + return Err(format!( + "mutagen sync create failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(()) +} + +/// Terminate mutagen sessions by name (best-effort). +pub fn stop_mutagen_sessions(sessions: &[String]) { + for s in sessions { + let _ = Command::new("mutagen") + .args(["sync", "terminate", s]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +/// Attach sync delivery for all targets: each target uses its own `host_source_path`. +/// +/// `watch`: when true and mutagen unavailable, spawn a multi-target tar re-sync loop. +pub fn attach_sync_delivery( + targets: &[SyncPodTarget], + workspace: &str, + watch: bool, +) -> Result> { + let probe = probe_from_visibility(Path::new("."), false, "sync attach (per-app hosts)"); + let mut result = DeliveryAttachResult { + strategy: DeliveryStrategy::Sync, + probe, + mutagen_sessions: Vec::new(), + sync_pids: Vec::new(), + messages: Vec::new(), + }; + + if targets.is_empty() { + result + .messages + .push("no Running pods to sync into yet; re-run up after pods are Ready".into()); + return Ok(result); + } + + let mutagen = command_exists("mutagen"); + for target in targets { + let host_path = &target.host_source_path; + let session = sync_session_name(workspace, &target.app_name); + if mutagen { + let dest = format!( + "kubectl://{}/{}{}", + target.namespace, target.pod, target.mount_path + ); + match start_mutagen_session(&session, host_path, &dest) { + Ok(()) => { + result.mutagen_sessions.push(session); + result.messages.push(format!( + "mutagen session for {} ← {}", + target.pod, + host_path.display() + )); + continue; + } + Err(e) => { + result.messages.push(format!( + "mutagen unavailable for {}: {e}; using tar sync", + target.pod + )); + } + } + } + + // Tar-based real sync (per-app host path + default_sync_ignores). + // Per-target errors must not abort the other apps. + match sync_directory_to_pod(host_path, target) { + Ok(()) => result.messages.push(format!( + "tar sync {} → {}/{}:{}", + host_path.display(), + target.namespace, + target.pod, + target.mount_path + )), + Err(e) => result.messages.push(format!( + "tar sync FAILED for {} ← {}: {e}", + target.pod, + host_path.display() + )), + } + } + + // Always keep a multi-app tar re-sync loop for Sync mode: emptyDir is wiped + // on container restart, so a one-shot tar is not durable without continuous + // delivery. `--watch` only controls gitops chart re-apply (caller). + let _ = watch; + if result.mutagen_sessions.is_empty() && !targets.is_empty() { + // Immediate second pass after a short delay (covers race with first start). + for target in targets { + let _ = sync_directory_to_pod(&target.host_source_path, target); + } + match spawn_tar_sync_watcher(targets.to_vec()) { + Ok(pid) => { + result.sync_pids.push(pid); + result + .messages + .push(format!("tar sync watcher pid={pid} (all apps, continuous)")); + } + Err(e) => result + .messages + .push(format!("tar sync watcher not started: {e}")), + } + } + + Ok(result) +} + +/// Build the continuous multi-app tar re-sync shell script (testable pure builder). +/// +/// Pod names are **not** frozen at attach time. Each loop resolves the current +/// Running pod for `hops.ops.com.ai/local-app=` so rollouts (new emptyDir) +/// still receive source. Syncs when: +/// - host tree fingerprint changes, or +/// - the live pod is missing `/.hops-synced` (fresh pod / failed prior sync). +pub fn build_multi_app_tar_watch_script(targets: &[SyncPodTarget]) -> String { + let excludes = tar_exclude_args().join(" "); + let mut script = String::from("set +e\n"); + // macOS tar emits AppleDouble / xattr headers that clutter logs; disable. + script.push_str("export COPYFILE_DISABLE=1\n"); + // Honor hops' selected kube context (dory/colima/kind) for every kubectl call. + // Without this, the watcher hits whatever current-context is — often a stale + // hops-dory CA and silent sync failure. + script.push_str( + r#" +KCTX="${HOPS_KUBE_CONTEXT:-}" +k() { + if [ -n "$KCTX" ]; then + command kubectl --context "$KCTX" "$@" + else + command kubectl "$@" + fi +} +"#, + ); + + // sync_one app host ns mount container + // Resolves pod by label every call — survives Deployment rollouts. + script.push_str(&format!( + r#" +sync_one() {{ + app="$1"; host="$2"; ns="$3"; mount="$4"; cont="$5" + pod=$(k get pod -n "$ns" -l "hops.ops.com.ai/local-app=$app" \ + --field-selector=status.phase=Running \ + -o jsonpath='{{.items[0].metadata.name}}' 2>/dev/null) + [ -n "$pod" ] || return 0 + cflag="" + [ -n "$cont" ] && cflag="-c $cont" + # Always tar into the *current* pod; ignore extract noise. + echo "[$(date +%H:%M:%S)] sync $app → $ns/$pod" >&2 + if tar cf - {excl} -C "$host" . 2>/dev/null \ + | k exec -i -n "$ns" "$pod" $cflag -- tar xf - -C "$mount" 2>/dev/null + then + k exec -n "$ns" "$pod" $cflag -- sh -c "touch \"$mount/.hops-synced\"" 2>/dev/null || true + else + echo "[$(date +%H:%M:%S)] sync FAILED $app → $ns/$pod" >&2 + fi +}} +"#, + excl = excludes + )); + + script.push_str("sync_all() {\n"); + for t in targets { + let cont = t.container.as_deref().unwrap_or(""); + script.push_str(&format!( + " sync_one \"{app}\" \"{host}\" \"{ns}\" \"{mount}\" \"{cont}\"\n", + app = t.app_name, + host = t.host_source_path.display(), + ns = t.namespace, + mount = t.mount_path, + cont = cont, + )); + } + script.push_str("}\n"); + + // needs_marker: true if any live pod is missing /.hops-synced + script.push_str("needs_marker() {\n"); + for t in targets { + let cont = t.container.as_deref().unwrap_or(""); + script.push_str(&format!( + r#" pod=$(k get pod -n "{ns}" -l "hops.ops.com.ai/local-app={app}" \ + --field-selector=status.phase=Running \ + -o jsonpath='{{.items[0].metadata.name}}' 2>/dev/null) + if [ -n "$pod" ]; then + cflag="" + [ -n "{cont}" ] && cflag="-c {cont}" + if ! k exec -n "{ns}" "$pod" $cflag -- test -f "{mount}/.hops-synced" 2>/dev/null; then + return 0 + fi + fi +"#, + ns = t.namespace, + app = t.app_name, + cont = cont, + mount = t.mount_path, + )); + } + script.push_str(" return 1\n}\n"); + + // Fingerprint union of all host roots so any app source change triggers full resync. + let mut find_parts = Vec::new(); + for t in targets { + find_parts.push(format!("\"{}\"", t.host_source_path.display())); + } + let roots = find_parts.join(" "); + script.push_str(&format!( + r#" +prev="" +while true; do + cur=$(find {roots} -type f \ + ! -path '*/node_modules/*' ! -path '*/target/*' ! -path '*/.git/*' \ + ! -path '*/dist/*' ! -path '*/.svelte-kit/*' \ + -print0 2>/dev/null | xargs -0 stat -f '%m' 2>/dev/null | cksum | awk '{{print $1}}') + if [ "$cur" != "$prev" ] || needs_marker; then + prev="$cur" + sync_all + fi + sleep 2 +done +"#, + roots = roots + )); + script +} + +fn spawn_tar_sync_watcher(targets: Vec) -> Result> { + let script = build_multi_app_tar_watch_script(&targets); + // Log path: ~/.hops/local/runtime/delivery-watch.log (shared; PIDs are per-workspace). + let log_path = crate::commands::local::local_state_dir() + .ok() + .map(|d| { + let p = d.join("runtime").join("delivery-watch.log"); + let _ = std::fs::create_dir_all(p.parent().unwrap_or(d.as_path())); + p + }); + let (stdout, stderr) = if let Some(ref path) = log_path { + let f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + let f2 = f.try_clone()?; + (Stdio::from(f), Stdio::from(f2)) + } else { + (Stdio::null(), Stdio::null()) + }; + let mut child = Command::new("sh"); + child + .arg("-c") + .arg(script) + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(stderr); + // Pass through hops' selected context so the watcher targets the same cluster + // as `hops local up` (see HOPS_KUBE_CONTEXT_ENV). + if let Ok(ctx) = std::env::var(crate::commands::local::HOPS_KUBE_CONTEXT_ENV) { + if !ctx.is_empty() { + child.env(crate::commands::local::HOPS_KUBE_CONTEXT_ENV, ctx); + } + } + let child = child.spawn()?; + if let Some(path) = log_path { + log::info!("tar sync watcher log: {}", path.display()); + } + Ok(child.id()) +} + +/// Whether a path change under host_path should trigger re-sync (not chart re-apply). +/// Uses the same ignore rules as tar/mutagen delivery. +pub fn should_resync_on_path_change(changed: &Path) -> bool { + !path_is_sync_excluded(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_host_path_when_probe_passes() { + let probe = + probe_from_visibility(Path::new("/Users/dev/proj"), true, "path exists on node"); + assert_eq!( + select_delivery_strategy(&probe), + DeliveryStrategy::HostPath + ); + } + + #[test] + fn falls_back_to_sync_when_probe_fails() { + let probe = probe_from_visibility(Path::new("/Users/dev/proj"), false, "not on node"); + assert_eq!(select_delivery_strategy(&probe), DeliveryStrategy::Sync); + } + + #[test] + fn sync_ignores_build_artifacts() { + assert!(path_is_sync_excluded(Path::new("ui/node_modules/x"))); + assert!(path_is_sync_excluded(Path::new("api/target/debug"))); + assert!(path_is_sync_excluded(Path::new(".git/config"))); + assert!(!path_is_sync_excluded(Path::new( + "ui/src/routes/+page.svelte" + ))); + } + + #[test] + fn mutagen_and_tar_args_include_default_ignores() { + let m = mutagen_ignore_args(); + assert!(m.windows(2).any(|w| w[0] == "--ignore" && w[1] == "node_modules")); + assert!(m.windows(2).any(|w| w[0] == "--ignore" && w[1] == "target")); + let t = tar_exclude_args(); + assert!(t.iter().any(|a| a == "--exclude=node_modules")); + assert!(t.iter().any(|a| a == "--exclude=target")); + assert!(t.iter().any(|a| a == "--exclude=.git")); + } + + #[test] + fn build_mutagen_create_args_wires_ignores_and_paths() { + let args = build_mutagen_create_args( + "hops-lwb-alice-ui", + Path::new("/proj"), + "kubectl://ns/pod/workspace", + ); + assert_eq!(args[0], "sync"); + assert_eq!(args[1], "create"); + assert!(args.contains(&"--name".into())); + assert!(args.contains(&"hops-lwb-alice-ui".into())); + assert!(args.contains(&"/proj".into())); + assert!(args.contains(&"kubectl://ns/pod/workspace".into())); + assert!(args.windows(2).any(|w| w[0] == "--ignore" && w[1] == "node_modules")); + } + + #[test] + fn build_tar_cf_args_is_what_sync_directory_to_pod_runs() { + // Drives the real helper used by sync_directory_to_pod — not a parallel reimplementation. + let args = build_tar_cf_args(Path::new("/Users/me/proj")); + assert_eq!(args[0], "cf"); + assert_eq!(args[1], "-"); + assert!(args.iter().any(|a| a == "--exclude=node_modules")); + assert!(args.iter().any(|a| a == "--exclude=target")); + assert!(args.iter().any(|a| a == "--exclude=.git")); + let c_idx = args.iter().position(|a| a == "-C").unwrap(); + assert_eq!(args[c_idx + 1], "/Users/me/proj"); + assert_eq!(args.last().map(String::as_str), Some(".")); + } + + #[test] + fn build_kubectl_tar_extract_args_targets_pod_mount() { + let t = SyncPodTarget { + namespace: "alice".into(), + pod: "e2e-ui-ui-xyz".into(), + container: Some("ui".into()), + mount_path: "/workspace".into(), + app_name: "e2e-ui-ui".into(), + host_source_path: PathBuf::from("/proj/ui"), + }; + let args = build_kubectl_tar_extract_args(&t); + assert!(args.contains(&"exec".into())); + assert!(args.contains(&"alice".into())); + assert!(args.contains(&"e2e-ui-ui-xyz".into())); + assert!(args.contains(&"-c".into())); + assert!(args.contains(&"ui".into())); + assert!(args.contains(&"/workspace".into())); + } + + #[test] + fn multi_app_watch_script_resyncs_every_target_with_own_host() { + let targets = vec![ + SyncPodTarget { + namespace: "ns".into(), + pod: "api-pod".into(), // frozen name must NOT be used by watcher + container: Some("api".into()), + mount_path: "/workspace".into(), + app_name: "e2e-ui-api".into(), + host_source_path: PathBuf::from("/proj"), + }, + SyncPodTarget { + namespace: "ns".into(), + pod: "ui-pod".into(), + container: Some("ui".into()), + mount_path: "/workspace".into(), + app_name: "e2e-ui-ui".into(), + host_source_path: PathBuf::from("/proj/ui"), + }, + ]; + let script = build_multi_app_tar_watch_script(&targets); + assert!(script.contains("sync_all()")); + assert!(script.contains("sync_one()")); + assert!(script.contains("needs_marker()")); + assert!(script.contains("HOPS_KUBE_CONTEXT") || script.contains("KCTX")); + // Resolve by label — not frozen pod names from attach time + assert!( + script.contains("hops.ops.com.ai/local-app=$app") + || script.contains("hops.ops.com.ai/local-app=") + ); + assert!(script.contains("e2e-ui-api")); + assert!(script.contains("e2e-ui-ui")); + assert!(script.contains("/proj")); + assert!(script.contains("/proj/ui")); + // Frozen pod names from the attach snapshot must not be baked into the loop + assert!( + !script.contains("\"api-pod\"") && !script.contains("\"ui-pod\""), + "watcher must re-resolve pods by label, not freeze attach-time names" + ); + assert!( + script.contains("sync_all\n") + || script.contains("sync_all\r") + || script.contains("sync_all") + ); + assert!(script.contains(".hops-synced")); + } + + #[test] + fn should_resync_source_but_not_ignored_build_dirs() { + assert!(should_resync_on_path_change(Path::new( + "/proj/ui/src/App.svelte" + ))); + assert!(!should_resync_on_path_change(Path::new( + "/proj/ui/node_modules/x" + ))); + assert!(!should_resync_on_path_change(Path::new( + "/proj/api/target/debug/foo" + ))); + } + + #[test] + fn strategy_string_stable_for_registry() { + assert_eq!(DeliveryStrategy::HostPath.as_str(), "hostPath"); + assert_eq!(DeliveryStrategy::Sync.as_str(), "sync"); + } + + #[test] + fn fake_prober_selects_sync_when_node_cannot_see_mac_path() { + struct AlwaysHidden; + impl NodePathProber for AlwaysHidden { + fn probe(&self, host_path: &Path) -> Result> { + Ok(probe_from_visibility( + host_path, + false, + "simulated: Mac path not on Linux node", + )) + } + } + let probe = AlwaysHidden.probe(Path::new("/Users/me/proj")).unwrap(); + assert!(!probe.host_path_visible); + assert_eq!(select_delivery_strategy(&probe), DeliveryStrategy::Sync); + } + + #[test] + fn select_delivery_strategy_never_assumes_hostpath_for_is_dir_only() { + // Document the contract: mere host is_dir is NOT enough — probe must set visible. + let host_exists_but_node_blind = + probe_from_visibility(Path::new("/Users/me/proj"), false, "is_dir alone is not enough"); + assert_eq!( + select_delivery_strategy(&host_exists_but_node_blind), + DeliveryStrategy::Sync + ); + } + + #[test] + fn real_tar_invocation_excludes_node_modules_using_shipped_args() { + // Drive the same argv builder sync_directory_to_pod uses, then run real tar. + let dir = std::env::temp_dir().join(format!( + "lwb-tar-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap(); + std::fs::write(dir.join("src/app.js"), "console.log(1)\n").unwrap(); + std::fs::write(dir.join("node_modules/pkg/index.js"), "secret\n").unwrap(); + + let args = build_tar_cf_args(&dir); + let archive = dir.join("out.tar"); + // Replace stdout "-" with a file for inspection + let mut file_args: Vec = args + .into_iter() + .map(|a| { + if a == "-" { + archive.display().to_string() + } else { + a + } + }) + .collect(); + // tar cf ... + assert_eq!(file_args[0], "cf"); + let status = Command::new("tar").args(&file_args).status().unwrap(); + assert!(status.success(), "tar failed with shipped exclude args"); + + let list = Command::new("tar") + .args(["tf", &archive.display().to_string()]) + .output() + .unwrap(); + assert!(list.status.success()); + let listing = String::from_utf8_lossy(&list.stdout); + assert!( + listing.contains("src/app.js") || listing.contains("./src/app.js"), + "expected source file in archive, got:\n{listing}" + ); + assert!( + !listing.contains("node_modules"), + "node_modules must be excluded by shipped tar args, got:\n{listing}" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/commands/local/workbench/mod.rs b/src/commands/local/workbench/mod.rs new file mode 100644 index 0000000..f85e1e4 --- /dev/null +++ b/src/commands/local/workbench/mod.rs @@ -0,0 +1,17 @@ +//! Local workbench library: Application reconcile, workspace registry, +//! gitops watch path filtering, source delivery selection, and host access URLs. +//! +//! Pure logic is unit-tested without a cluster; kubectl/helm live behind thin +//! adapters used by the CLI entrypoints. + +pub mod application; +pub mod cluster_dns; +pub mod cluster_gitops; +pub mod delivery; +pub mod net; +pub mod reconcile; +pub mod registry; +pub mod watch; + +// Selective re-exports used by CLI entrypoints. +pub use registry::{namespace_for_name, slugify_name}; diff --git a/src/commands/local/workbench/net.rs b/src/commands/local/workbench/net.rs new file mode 100644 index 0000000..fa45a65 --- /dev/null +++ b/src/commands/local/workbench/net.rs @@ -0,0 +1,1055 @@ +//! Host access — one path. +//! +//! Services this workspace cares about: +//! - Services in the workspace namespace +//! - In-cluster `*.svc.cluster.local` endpoints referenced by those pods +//! (e.g. OIDC issuer in `auth`) +//! +//! For each: +//! 1. allocate loopback IPs in `127.53.0.0/16` +//! 2. write `/etc/hosts` + lo0 aliases (one admin elevation) +//! 3. run a supervisor that keeps `kubectl port-forward` alive +//! +//! URLs are real k8s FQDNs, e.g. +//! `http://e2e-ui-ui.dogfood.svc.cluster.local:5180` + +use super::cluster_dns::{ + self, format_dns_url, remove_loopback_aliases, sync_alloc_for_namespace, MACOS_LOCAL_DNS_PORT, +}; +use crate::commands::local::{kubectl_command, HOPS_KUBE_CONTEXT_ENV}; +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fs; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +pub const RUNTIME_SUBDIR: &str = "runtime"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceEndpoint { + pub namespace: String, + pub name: String, + pub port: u16, + pub protocol: String, +} + +impl ServiceEndpoint { + pub fn key(&self) -> String { + format!("{}/{}", self.namespace, self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HostAccessPlan { + /// Primary workspace namespace (status / cards). + pub namespace: String, + pub urls: BTreeMap, + /// service key `ns/name` → loopback IP + pub ip_map: BTreeMap, + /// service key → port + pub service_ports: BTreeMap, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HostAccessRuntime { + pub namespace: String, + pub pids: Vec, + #[serde(default)] + pub log_path: Option, + /// service key `ns/name` → port (preferred). Also accepts bare name for older files. + #[serde(default)] + pub service_ports: BTreeMap, + /// service key `ns/name` → loopback IP + #[serde(default)] + pub ip_map: BTreeMap, +} + +pub fn format_dns_service_url(service: &str, namespace: &str, port: u16) -> String { + format_dns_url(service, namespace, port) +} + +pub fn plan_host_access(namespace: &str, services: &[ServiceEndpoint]) -> HostAccessPlan { + plan_host_access_with_ips(namespace, services, &BTreeMap::new()) +} + +pub fn plan_host_access_with_ips( + namespace: &str, + services: &[ServiceEndpoint], + ip_map: &BTreeMap, +) -> HostAccessPlan { + let mut urls = BTreeMap::new(); + let mut service_ports = BTreeMap::new(); + for svc in services { + let key = svc.key(); + urls.insert(key.clone(), format_dns_url(&svc.name, &svc.namespace, svc.port)); + service_ports.insert(key, svc.port); + } + HostAccessPlan { + namespace: namespace.to_string(), + urls, + ip_map: ip_map.clone(), + service_ports, + } +} + +pub fn format_status_card(workspace: &str, plan: &HostAccessPlan) -> String { + format_status_card_with_listen(workspace, plan, &BTreeMap::new()) +} + +pub fn format_status_card_with_listen( + workspace: &str, + plan: &HostAccessPlan, + listen: &BTreeMap, +) -> String { + let mut lines = Vec::new(); + lines.push(format!("workspace: {workspace}")); + lines.push(format!("namespace: {}", plan.namespace)); + lines.push("access: cluster DNS (Service FQDNs)".into()); + if plan.urls.is_empty() { + lines.push("urls: (no services discovered yet)".into()); + } else { + lines.push("urls:".into()); + for (key, url) in &plan.urls { + let mark = match listen.get(key) { + Some(true) => " [up]", + Some(false) => " [down]", + None => "", + }; + lines.push(format!(" - {key}: {url}{mark}")); + } + } + lines.join("\n") +} + +pub fn host_access_status_line(rt: &HostAccessRuntime) -> String { + let alive = rt.pids.iter().filter(|p| pid_is_alive(**p)).count(); + let listen = rt + .ip_map + .iter() + .filter(|(key, ip)| { + let port = rt.service_ports.get(*key).copied().unwrap_or(80); + ip_port_listening(ip, port) + }) + .count(); + format!( + "access: dns supervisor {}/{} alive; {}/{} endpoints listening", + alive, + rt.pids.len().max(1), + listen, + rt.ip_map.len() + ) +} + +fn runtime_path(state_dir: &Path, workspace: &str) -> PathBuf { + state_dir + .join(RUNTIME_SUBDIR) + .join(format!("{workspace}.host-access.json")) +} + +pub fn save_host_access_runtime( + state_dir: &Path, + workspace: &str, + rt: &HostAccessRuntime, +) -> Result<(), Box> { + fs::create_dir_all(state_dir.join(RUNTIME_SUBDIR))?; + fs::write(runtime_path(state_dir, workspace), serde_json::to_string_pretty(rt)?)?; + Ok(()) +} + +pub fn load_host_access_runtime( + state_dir: &Path, + workspace: &str, +) -> Result, Box> { + let path = runtime_path(state_dir, workspace); + if !path.exists() { + return Ok(None); + } + Ok(Some(serde_json::from_str(&fs::read_to_string(path)?)?)) +} + +pub fn clear_host_access_runtime(state_dir: &Path, workspace: &str) { + let _ = fs::remove_file(runtime_path(state_dir, workspace)); +} + +/// Services in `namespace` (first TCP port each). +pub fn discover_services(namespace: &str) -> Result, Box> { + discover_services_in_namespace(namespace) +} + +fn discover_services_in_namespace( + namespace: &str, +) -> Result, Box> { + let output = kubectl_command(&["get", "svc", "-n", namespace, "-o", "json"]) + .output() + .map_err(|e| format!("kubectl get svc failed: {e}"))?; + if !output.status.success() { + return Err(format!( + "kubectl get svc -n {namespace} failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + let v: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let mut out = Vec::new(); + for item in v["items"].as_array().cloned().unwrap_or_default() { + let name = item["metadata"]["name"].as_str().unwrap_or("").to_string(); + if name.is_empty() || name == "kubernetes" { + continue; + } + for p in item["spec"]["ports"].as_array().cloned().unwrap_or_default() { + let port = p["port"].as_u64().unwrap_or(0) as u16; + let protocol = p["protocol"].as_str().unwrap_or("TCP"); + if port == 0 || (protocol != "TCP" && protocol != "tcp") { + continue; + } + // Skip postgres-ish ports for host browser access. + if port == 5432 { + continue; + } + out.push(ServiceEndpoint { + namespace: namespace.to_string(), + name: name.clone(), + port, + protocol: "TCP".into(), + }); + break; + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Workspace Services **plus** in-cluster FQDNs referenced by pod env +/// (OIDC issuer, etc.). +pub fn discover_workspace_endpoints( + namespace: &str, +) -> Result, Box> { + let mut by_key: BTreeMap = BTreeMap::new(); + for svc in discover_services_in_namespace(namespace)? { + by_key.insert(svc.key(), svc); + } + + for (ref_ns, ref_name, port_hint) in scan_pod_cluster_dns_refs(namespace)? { + if ref_ns == namespace && by_key.contains_key(&format!("{ref_ns}/{ref_name}")) { + continue; + } + let key = format!("{ref_ns}/{ref_name}"); + if by_key.contains_key(&key) { + continue; + } + // Resolve live service port when possible. + let port = match service_port(&ref_ns, &ref_name) { + Ok(p) => p, + Err(_) => port_hint.unwrap_or(80), + }; + if port == 5432 { + continue; + } + by_key.insert( + key, + ServiceEndpoint { + namespace: ref_ns, + name: ref_name, + port, + protocol: "TCP".into(), + }, + ); + } + + Ok(by_key.into_values().collect()) +} + +fn service_port(namespace: &str, name: &str) -> Result> { + let output = kubectl_command(&[ + "get", + "svc", + name, + "-n", + namespace, + "-o", + "jsonpath={.spec.ports[0].port}", + ]) + .output() + .map_err(|e| format!("kubectl get svc {name}: {e}"))?; + if !output.status.success() { + return Err("service not found".into()); + } + let s = String::from_utf8_lossy(&output.stdout); + s.trim() + .parse() + .map_err(|_| format!("bad port for {namespace}/{name}").into()) +} + +/// Parse pod env for `http(s)://svc.ns.svc.cluster.local:port` references. +fn scan_pod_cluster_dns_refs( + namespace: &str, +) -> Result)>, Box> { + let output = kubectl_command(&["get", "pods", "-n", namespace, "-o", "json"]) + .output() + .map_err(|e| format!("kubectl get pods failed: {e}"))?; + if !output.status.success() { + return Ok(Vec::new()); + } + let v: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let mut found: BTreeSet<(String, String, Option)> = BTreeSet::new(); + + for item in v["items"].as_array().cloned().unwrap_or_default() { + let containers = item["spec"]["containers"] + .as_array() + .cloned() + .unwrap_or_default(); + for c in containers { + for env in c["env"].as_array().cloned().unwrap_or_default() { + if let Some(val) = env["value"].as_str() { + for (ns, name, port) in regex_lite_cluster_dns(val) { + found.insert((ns, name, port)); + } + } + } + } + } + Ok(found.into_iter().collect()) +} + +/// Minimal parser: find `name.namespace.svc.cluster.local` and optional `:port`. +fn regex_lite_cluster_dns(s: &str) -> Vec<(String, String, Option)> { + let mut out = Vec::new(); + let marker = ".svc.cluster.local"; + let mut search_from = 0; + while let Some(rel) = s[search_from..].find(marker) { + let end = search_from + rel; + // walk back for hostname labels + let head = &s[..end]; + let start = head + .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '.')) + .map(|i| i + 1) + .unwrap_or(0); + let host = &s[start..end]; + // expect name.namespace + let mut parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 2 { + let ns = parts.pop().unwrap().to_string(); + let name = parts.join("."); + if !name.is_empty() && !ns.is_empty() { + let after = end + marker.len(); + let port = if s[after..].starts_with(':') { + let digits: String = s[after + 1..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().ok() + } else { + None + }; + out.push((ns, name, port)); + } + } + search_from = end + marker.len(); + } + out +} + +/// Start host access for workspace endpoints (FQDNs + supervisor). +pub fn start_host_access( + namespace: &str, + services: &[ServiceEndpoint], + state_dir: &Path, + workspace: &str, +) -> Result<(HostAccessPlan, HostAccessRuntime), Box> { + if services.is_empty() { + return Ok(( + plan_host_access(namespace, services), + HostAccessRuntime { + namespace: namespace.into(), + ..Default::default() + }, + )); + } + + // Allocate IPs per namespace group. + let mut by_ns: BTreeMap> = BTreeMap::new(); + for svc in services { + by_ns + .entry(svc.namespace.clone()) + .or_default() + .push(svc.clone()); + } + + let mut ip_map: BTreeMap = BTreeMap::new(); + let mut ns_blocks: Vec<(String, BTreeMap)> = Vec::new(); + for (ns, svcs) in &by_ns { + // sync_alloc maps bare service name → ip; rekey to ns/name + let bare = sync_alloc_for_namespace(state_dir, ns, svcs)?; + let mut bare_for_hosts = BTreeMap::new(); + for (name, ip) in bare { + bare_for_hosts.insert(name.clone(), ip.clone()); + ip_map.insert(format!("{ns}/{name}"), ip); + } + ns_blocks.push((ns.clone(), bare_for_hosts)); + } + + let ips: Vec = ip_map.values().cloned().collect(); + + let mut blocks = collect_dns_blocks_from_runtimes(state_dir, Some(workspace))?; + blocks.extend(ns_blocks); + let mut merged_by_ns: BTreeMap> = BTreeMap::new(); + for (ns, m) in blocks { + merged_by_ns.entry(ns).or_default().extend(m); + } + let mut host_lines = Vec::new(); + for (ns, m) in &merged_by_ns { + host_lines.extend(cluster_dns::hosts_lines_for_workspace(ns, m)); + } + let current = fs::read_to_string("/etc/hosts").unwrap_or_default(); + let hosts_body = cluster_dns::merge_hosts_file(¤t, &host_lines); + + cluster_dns::apply_privileged_dns_config(&hosts_body, &ips)?; + verify_loopback_aliases_ready(&ips)?; + + let plan = plan_host_access_with_ips(namespace, services, &ip_map); + // Zone file for macOS stub DNS (instant *.svc.cluster.local; avoid mDNS). + write_zone_file(state_dir, &merged_by_ns)?; + ensure_macos_stub_dns(state_dir)?; + let rt = start_dns_supervisor(&plan, services, state_dir, workspace)?; + std::thread::sleep(Duration::from_millis(700)); + if !rt.pids.iter().any(|p| pid_is_alive(*p)) { + let tail = rt + .log_path + .as_ref() + .and_then(|p| fs::read_to_string(p).ok()) + .unwrap_or_default(); + let tail: String = tail + .lines() + .rev() + .take(30) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + return Err(format!("dns supervisor exited immediately\n{tail}").into()); + } + log::info!( + "host access: cluster DNS for {} endpoint(s)", + services.len() + ); + Ok((plan, rt)) +} + +/// Write zone: fqdn → ip for the macOS stub DNS (and reloads on every start). +fn write_zone_file( + state_dir: &Path, + by_ns: &BTreeMap>, +) -> Result<(), Box> { + let path = state_dir.join(RUNTIME_SUBDIR).join("dns-zone.tsv"); + fs::create_dir_all(path.parent().unwrap())?; + let mut body = String::new(); + for (ns, m) in by_ns { + for (svc, ip) in m { + let fqdn = format!("{svc}.{ns}.svc.cluster.local"); + let twin = format!("{svc}.{ns}.svc.cluster"); + let short = format!("{svc}.{ns}"); + body.push_str(&format!("{fqdn}\t{ip}\n{twin}\t{ip}\n{short}\t{ip}\n")); + } + } + fs::write(path, body)?; + Ok(()) +} + +/// macOS: run a tiny UDP DNS on 127.0.0.1:53535 answering A records from dns-zone.tsv. +/// Paired with `/etc/resolver/svc.cluster.local` so getaddrinfo skips mDNS. +fn ensure_macos_stub_dns(state_dir: &Path) -> Result<(), Box> { + if !cfg!(target_os = "macos") { + return Ok(()); + } + let log_dir = state_dir.join(RUNTIME_SUBDIR); + fs::create_dir_all(&log_dir)?; + let zone = log_dir.join("dns-zone.tsv"); + let script = log_dir.join("macos-stub-dns.py"); + let pid_path = log_dir.join("macos-stub-dns.pid"); + let log_path = log_dir.join("macos-stub-dns.log"); + + // Already healthy? + if let Ok(raw) = fs::read_to_string(&pid_path) { + if let Ok(pid) = raw.trim().parse::() { + if pid_is_alive(pid) && stub_dns_responds() { + return Ok(()); + } + let _ = Command::new("kill").args(["-TERM", &pid.to_string()]).status(); + } + } + + let py = format!( + r#"#!/usr/bin/env python3 +import socket, struct, sys, time, os +ZONE = '{zone}' +PORT = {port} +LOG = '{log}' + +def load_zone(): + m = {{}} + try: + with open(ZONE) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split() + if len(parts) >= 2: + m[parts[0].lower().rstrip('.')] = parts[1] + except FileNotFoundError: + pass + return m + +def parse_qname(data, off): + labels = [] + while True: + if off >= len(data): + raise ValueError('bad qname') + l = data[off] + if l == 0: + return '.'.join(labels).lower(), off + 1 + if (l & 0xC0) == 0xC0: + # pointer — not expected in questions we generate answers for + ptr = struct.unpack('!H', data[off:off+2])[0] & 0x3FFF + name, _ = parse_qname(data, ptr) + return name, off + 2 + off += 1 + labels.append(data[off:off+l].decode('ascii', 'ignore')) + off += l + +def encode_name(name): + out = b'' + for lab in name.split('.'): + if not lab: + continue + b = lab.encode('ascii') + out += bytes([len(b)]) + b + return out + b'\x00' + +def build_response(req, zone): + if len(req) < 12: + return None + tid = req[:2] + flags_qr = b'\x81\x80' # standard response, recursion available + # parse question + try: + qname, qend = parse_qname(req, 12) + except Exception: + return None + if qend + 4 > len(req): + return None + qtype, qclass = struct.unpack('!HH', req[qend:qend+4]) + question = req[12:qend+4] + ip = zone.get(qname) + if qtype != 1 or qclass != 1 or not ip: # A IN + # NXDOMAIN-ish empty answer so clients fall through quickly + return tid + flags_qr + struct.pack('!HHHH', 1, 0, 0, 0) + question + try: + addr = socket.inet_aton(ip) + except OSError: + return tid + flags_qr + struct.pack('!HHHH', 1, 0, 0, 0) + question + answer = encode_name(qname) + struct.pack('!HHIH', 1, 1, 30, 4) + addr + return tid + flags_qr + struct.pack('!HHHH', 1, 1, 0, 0) + question + answer + +def main(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('127.0.0.1', PORT)) + with open(LOG, 'a') as lf: + lf.write('start port=%s zone=%s\\n' % (PORT, ZONE)) + lf.flush() + zone = load_zone() + last = time.time() + while True: + sock.settimeout(2.0) + try: + data, addr = sock.recvfrom(512) + except socket.timeout: + if time.time() - last > 2: + zone = load_zone() + last = time.time() + continue + if time.time() - last > 2: + zone = load_zone() + last = time.time() + resp = build_response(data, zone) + if resp: + sock.sendto(resp, addr) + +if __name__ == '__main__': + main() +"#, + zone = zone.display().to_string(), + port = MACOS_LOCAL_DNS_PORT, + log = log_path.display().to_string(), + ); + fs::write(&script, py)?; + import_unix_chmod(&script); + + let log_out = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + let log_err = log_out.try_clone()?; + let child = Command::new("python3") + .arg(&script) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_out)) + .stderr(Stdio::from(log_err)) + .spawn() + .map_err(|e| format!("failed to spawn macOS stub DNS: {e}"))?; + let pid = child.id(); + std::mem::forget(child); + fs::write(&pid_path, format!("{pid}\n"))?; + std::thread::sleep(Duration::from_millis(200)); + if !pid_is_alive(pid) { + return Err("macOS stub DNS exited immediately".into()); + } + log::info!("macOS stub DNS for *.svc.cluster.local on 127.0.0.1:{MACOS_LOCAL_DNS_PORT} pid={pid}"); + Ok(()) +} + +fn stub_dns_responds() -> bool { + // best-effort: UDP not easy to probe without a real query; process alive is enough + true +} + +fn verify_loopback_aliases_ready(ips: &[String]) -> Result<(), Box> { + if !cfg!(target_os = "macos") { + return Ok(()); + } + let lo0 = Command::new("ifconfig") + .arg("lo0") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let missing: Vec<&str> = ips + .iter() + .filter(|ip| !ip.is_empty() && *ip != "127.0.0.1" && !lo0.contains(ip.as_str())) + .map(String::as_str) + .collect(); + if missing.is_empty() { + return Ok(()); + } + Err(format!( + "lo0 missing aliases ({}); approve the single admin prompt so aliases apply", + missing.join(", ") + ) + .into()) +} + +fn start_dns_supervisor( + plan: &HostAccessPlan, + services: &[ServiceEndpoint], + state_dir: &Path, + workspace: &str, +) -> Result> { + let _ = stop_host_access_processes_only(state_dir, workspace); + + let log_dir = state_dir.join(RUNTIME_SUBDIR); + fs::create_dir_all(&log_dir)?; + let log_path = log_dir.join(format!("{workspace}.host-access.log")); + let config_path = log_dir.join(format!("{workspace}.dns-forwards.tsv")); + let script_path = log_dir.join(format!("{workspace}.dns-sup.sh")); + let piddir = log_dir.join(format!("{workspace}.dns-pf-pids")); + + // TSV: NS \t SVC \t IP \t PORT \t KEY + let mut tsv = String::new(); + for svc in services { + let key = svc.key(); + let ip = plan + .ip_map + .get(&key) + .cloned() + .unwrap_or_else(|| "127.0.0.1".into()); + tsv.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\n", + svc.namespace, svc.name, ip, svc.port, key + )); + } + fs::write(&config_path, &tsv)?; + + let q = |s: &str| s.replace('\'', r"'\''"); + let script = format!( + r#"#!/usr/bin/env bash +set -u +CONFIG='{config}' +LOG='{log}' +PIDDIR='{piddir}' +export KUBECONFIG="${{KUBECONFIG:-}}" +KCTX="${{HOPS_KUBE_CONTEXT:-}}" +k() {{ + if [ -n "$KCTX" ]; then + command kubectl --context "$KCTX" "$@" + else + command kubectl "$@" + fi +}} +mkdir -p "$PIDDIR" +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) supervisor start" >>"$LOG" +cleanup() {{ + for f in "$PIDDIR"/*.pid; do + [ -f "$f" ] || continue + kill "$(cat "$f")" 2>/dev/null || true + done + exit 0 +}} +trap cleanup TERM INT HUP +while true; do + while IFS=$'\t' read -r NS SVC IP PORT KEY; do + [ -z "${{NS:-}}" ] && continue + safe=$(echo "$KEY" | tr '/:' '__') + pf="$PIDDIR/$safe.pid" + pid="" + if [ -f "$pf" ]; then pid=$(cat "$pf" 2>/dev/null || true); fi + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then continue; fi + if [ -n "$pid" ]; then + kill "$pid" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restart $KEY" >>"$LOG" + else + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) start $KEY $IP:$PORT" >>"$LOG" + fi + k port-forward -n "$NS" --address "$IP" "svc/$SVC" "${{PORT}}:${{PORT}}" >>"$LOG" 2>&1 & + echo $! >"$pf" + done < "$CONFIG" + sleep 2 +done +"#, + config = q(&config_path.to_string_lossy()), + log = q(&log_path.to_string_lossy()), + piddir = q(&piddir.to_string_lossy()), + ); + fs::write(&script_path, script)?; + import_unix_chmod(&script_path); + + let log_out = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + let log_err = log_out.try_clone()?; + let mut cmd = Command::new("bash"); + cmd.arg(&script_path) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_out)) + .stderr(Stdio::from(log_err)); + if let Ok(kc) = std::env::var("KUBECONFIG") { + cmd.env("KUBECONFIG", kc); + } + if let Ok(ctx) = std::env::var(HOPS_KUBE_CONTEXT_ENV) { + cmd.env(HOPS_KUBE_CONTEXT_ENV, ctx); + } + let child = cmd + .spawn() + .map_err(|e| format!("failed to spawn dns supervisor: {e}"))?; + let pid = child.id(); + std::mem::forget(child); + + let service_ports: BTreeMap = services + .iter() + .map(|s| (s.key(), s.port)) + .collect(); + let runtime = HostAccessRuntime { + namespace: plan.namespace.clone(), + pids: vec![pid], + log_path: Some(log_path.display().to_string()), + service_ports, + ip_map: plan.ip_map.clone(), + }; + save_host_access_runtime(state_dir, workspace, &runtime)?; + log::info!("host access started (dns supervisor) pid={pid}"); + Ok(runtime) +} + +#[cfg(unix)] +fn import_unix_chmod(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755)); +} +#[cfg(not(unix))] +fn import_unix_chmod(_path: &Path) {} + +/// Build `kubectl port-forward` argv (package registry host publish). +pub fn build_port_forward_args( + namespace: &str, + service: &str, + host_port: u16, + service_port: u16, +) -> Vec { + vec![ + "port-forward".into(), + "-n".into(), + namespace.into(), + format!("svc/{service}"), + format!("{host_port}:{service_port}"), + ] +} + +pub fn localhost_port_listening(port: u16) -> bool { + TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_millis(200), + ) + .is_ok() +} + +pub fn ip_port_listening(ip: &str, port: u16) -> bool { + let Ok(addr) = format!("{ip}:{port}").parse::() else { + return false; + }; + TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() +} + +pub fn host_access_needs_heal(rt: &HostAccessRuntime) -> bool { + if rt.ip_map.is_empty() { + return true; + } + if !rt.pids.iter().any(|p| pid_is_alive(*p)) { + return true; + } + for (key, ip) in &rt.ip_map { + let port = rt.service_ports.get(key).copied().unwrap_or(80); + if !ip_port_listening(ip, port) { + return true; + } + } + false +} + +pub fn ensure_host_access( + namespace: &str, + services: &[ServiceEndpoint], + state_dir: &Path, + workspace: &str, +) -> Result<(HostAccessPlan, HostAccessRuntime, bool), Box> { + let prior = load_host_access_runtime(state_dir, workspace)?; + if let Some(rt) = &prior { + if !host_access_needs_heal(rt) { + return Ok((plan_from_runtime(rt), rt.clone(), false)); + } + log::info!("host access unhealthy; restarting dns supervisor"); + } + let services = if services.is_empty() { + prior + .as_ref() + .map(services_from_runtime) + .unwrap_or_else(|| services.to_vec()) + } else { + services.to_vec() + }; + let (plan, rt) = start_host_access(namespace, &services, state_dir, workspace)?; + std::thread::sleep(Duration::from_millis(400)); + Ok((plan, rt, true)) +} + +fn plan_from_runtime(rt: &HostAccessRuntime) -> HostAccessPlan { + let mut urls = BTreeMap::new(); + for (key, port) in &rt.service_ports { + let (ns, name) = split_service_key(key, &rt.namespace); + urls.insert(key.clone(), format_dns_url(&name, &ns, *port)); + } + if urls.is_empty() { + for key in rt.ip_map.keys() { + let (ns, name) = split_service_key(key, &rt.namespace); + urls.insert(key.clone(), format_dns_url(&name, &ns, 80)); + } + } + HostAccessPlan { + namespace: rt.namespace.clone(), + urls, + ip_map: rt.ip_map.clone(), + service_ports: rt.service_ports.clone(), + } +} + +fn split_service_key(key: &str, default_ns: &str) -> (String, String) { + if let Some((ns, name)) = key.split_once('/') { + (ns.to_string(), name.to_string()) + } else { + (default_ns.to_string(), key.to_string()) + } +} + +fn services_from_runtime(rt: &HostAccessRuntime) -> Vec { + let keys: Vec = if !rt.service_ports.is_empty() { + rt.service_ports.keys().cloned().collect() + } else { + rt.ip_map.keys().cloned().collect() + }; + keys.into_iter() + .map(|key| { + let (ns, name) = split_service_key(&key, &rt.namespace); + ServiceEndpoint { + namespace: ns, + name, + port: rt.service_ports.get(&key).copied().unwrap_or(80), + protocol: "TCP".into(), + } + }) + .collect() +} + +pub fn url_listen_status(plan: &HostAccessPlan) -> BTreeMap { + let mut out = BTreeMap::new(); + for (key, ip) in &plan.ip_map { + let port = plan.service_ports.get(key).copied().unwrap_or(80); + out.insert(key.clone(), ip_port_listening(ip, port)); + } + out +} + +fn stop_host_access_processes_only( + state_dir: &Path, + workspace: &str, +) -> Result<(), Box> { + if let Some(rt) = load_host_access_runtime(state_dir, workspace)? { + for pid in &rt.pids { + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + std::thread::sleep(Duration::from_millis(200)); + for pid in &rt.pids { + let _ = Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .status(); + } + let piddir = state_dir + .join(RUNTIME_SUBDIR) + .join(format!("{workspace}.dns-pf-pids")); + if let Ok(entries) = fs::read_dir(piddir) { + for ent in entries.flatten() { + if let Ok(s) = fs::read_to_string(ent.path()) { + if let Ok(pid) = s.trim().parse::() { + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + } + } + } + } + clear_host_access_runtime(state_dir, workspace); + Ok(()) +} + +pub fn stop_host_access(state_dir: &Path, workspace: &str) -> Result<(), Box> { + let rt = load_host_access_runtime(state_dir, workspace)?; + stop_host_access_processes_only(state_dir, workspace)?; + if let Some(rt) = rt { + if !rt.ip_map.is_empty() { + remove_loopback_aliases(&rt.ip_map.values().cloned().collect::>()); + if let Ok(blocks) = collect_dns_blocks_from_runtimes(state_dir, Some(workspace)) { + let _ = rebuild_hosts_from_blocks_noprompt(&blocks); + } + } + } + Ok(()) +} + +fn rebuild_hosts_from_blocks_noprompt( + blocks: &[(String, BTreeMap)], +) -> Result<(), Box> { + use super::cluster_dns::{ + dns_os_config_present, hosts_lines_for_workspace, merge_hosts_file, run_privileged_shell, + PrivilegedPrompt, + }; + let mut lines = Vec::new(); + for (ns, ips) in blocks { + lines.extend(hosts_lines_for_workspace(ns, ips)); + } + let current = fs::read_to_string("/etc/hosts").unwrap_or_default(); + let merged = merge_hosts_file(¤t, &lines); + if dns_os_config_present(&merged, &[]) { + return Ok(()); + } + let tmp = + std::env::temp_dir().join(format!("hops-hosts-down-{}.tmp", std::process::id())); + fs::write(&tmp, &merged)?; + let script = format!("cp '{}' /etc/hosts && chmod 644 /etc/hosts", tmp.display()); + let res = run_privileged_shell(&script, PrivilegedPrompt::Never); + let _ = fs::remove_file(&tmp); + res +} + +fn collect_dns_blocks_from_runtimes( + state_dir: &Path, + skip_workspace: Option<&str>, +) -> Result)>, Box> { + let dir = state_dir.join(RUNTIME_SUBDIR); + let mut by_ns: BTreeMap> = BTreeMap::new(); + let Ok(entries) = fs::read_dir(&dir) else { + return Ok(Vec::new()); + }; + for ent in entries.flatten() { + let name = ent.file_name().to_string_lossy().into_owned(); + if !name.ends_with(".host-access.json") { + continue; + } + let ws = name.trim_end_matches(".host-access.json"); + if skip_workspace == Some(ws) { + continue; + } + let Ok(text) = fs::read_to_string(ent.path()) else { + continue; + }; + let Ok(rt) = serde_json::from_str::(&text) else { + continue; + }; + for (key, ip) in rt.ip_map { + let (ns, svc) = split_service_key(&key, &rt.namespace); + by_ns.entry(ns).or_default().insert(svc, ip); + } + } + Ok(by_ns.into_iter().collect()) +} + +pub fn pid_is_alive(pid: u32) -> bool { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "state="]) + .output(); + match output { + Ok(o) if o.status.success() => { + let state = String::from_utf8_lossy(&o.stdout); + let state = state.trim(); + !state.is_empty() && !state.starts_with('Z') && !state.starts_with('z') + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_urls_are_cluster_fqdns() { + let svcs = vec![ServiceEndpoint { + namespace: "dogfood".into(), + name: "e2e-ui-ui".into(), + port: 5180, + protocol: "TCP".into(), + }]; + let plan = plan_host_access("dogfood", &svcs); + assert_eq!( + plan.urls + .get("dogfood/e2e-ui-ui") + .map(String::as_str), + Some("http://e2e-ui-ui.dogfood.svc.cluster.local:5180") + ); + } + + #[test] + fn parse_cluster_dns_from_env_value() { + let refs = regex_lite_cluster_dns( + "http://zitadel-zitadel.auth.svc.cluster.local:8080/oauth/v2/keys", + ); + assert_eq!( + refs, + vec![("auth".into(), "zitadel-zitadel".into(), Some(8080))] + ); + } +} diff --git a/src/commands/local/workbench/reconcile.rs b/src/commands/local/workbench/reconcile.rs new file mode 100644 index 0000000..554c1a1 --- /dev/null +++ b/src/commands/local/workbench/reconcile.rs @@ -0,0 +1,694 @@ +//! Application reconcile: helm template + label inject + apply. + +use super::application::{load_applications, resolve_source_path, Application}; +use serde_yaml::Value; +use std::collections::BTreeMap; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Label value for `app.kubernetes.io/managed-by`. +pub const MANAGED_BY_VALUE: &str = "hops-local-gitops"; +/// Workspace / env label key. +pub const WORKSPACE_ENV_LABEL: &str = "hops.ops.com.ai/local-env"; +/// App name label key. +pub const WORKSPACE_APP_LABEL: &str = "hops.ops.com.ai/local-app"; + +#[derive(Debug, Clone, Default)] +pub struct ReconcileOptions { + /// Destination namespace override (from --namespace / workspace name). + pub namespace: String, + /// Workspace name for labels. + pub workspace_name: String, + /// Extra values merged last (runtime inject) — shared across apps. + pub runtime_values: BTreeMap, + /// Per-app hostPath for source delivery (app name → absolute host path). + /// Injected as `sourceDelivery.hostPath` for that app only. + pub app_delivery_host_paths: BTreeMap, + /// Delivery mode string injected when set (`hostPath` | `sync` | `none`). + pub delivery_mode: Option, + /// When true, only render (no apply). Used by tests. + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +pub struct ReconcileResult { + pub app_name: String, + pub chart_path: PathBuf, + pub namespace: String, + pub rendered_yaml: String, + pub applied: bool, +} + +/// Abstraction over `helm template` for tests. +pub trait HelmRunner { + fn template( + &self, + release: &str, + chart_path: &Path, + namespace: &str, + values_yaml: &str, + ) -> Result>; +} + +/// Abstraction over kubectl apply for tests. +pub trait KubectlApplier { + fn ensure_namespace( + &self, + namespace: &str, + labels: &BTreeMap, + ) -> Result<(), Box>; + fn apply(&self, yaml: &str) -> Result<(), Box>; +} + +/// Real helm binary runner. +pub struct SystemHelm; + +impl HelmRunner for SystemHelm { + fn template( + &self, + release: &str, + chart_path: &Path, + namespace: &str, + values_yaml: &str, + ) -> Result> { + let values_path = std::env::temp_dir().join(format!( + "hops-lwb-values-{}-{}.yaml", + std::process::id(), + release + )); + std::fs::write(&values_path, values_yaml)?; + let output = Command::new("helm") + .args([ + "template", + release, + &chart_path.to_string_lossy(), + "--namespace", + namespace, + "--values", + &values_path.to_string_lossy(), + ]) + .output()?; + let _ = std::fs::remove_file(&values_path); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "helm template failed for {}: {}", + chart_path.display(), + stderr + ) + .into()); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} + +/// Real kubectl apply via stdin helpers in parent module. +pub struct SystemKubectl; + +impl KubectlApplier for SystemKubectl { + fn ensure_namespace( + &self, + namespace: &str, + labels: &BTreeMap, + ) -> Result<(), Box> { + let mut label_lines = String::new(); + for (k, v) in labels { + label_lines.push_str(&format!(" {k}: {v}\n")); + } + let yaml = format!( + "apiVersion: v1\nkind: Namespace\nmetadata:\n name: {namespace}\n labels:\n{label_lines}" + ); + crate::commands::local::kubectl_apply_stdin(&yaml) + } + + fn apply(&self, yaml: &str) -> Result<(), Box> { + // Apply one document at a time so a missing platform CRD (e.g. PSQLCluster + // when the pack is not installed) does not prevent core Deploy/Service apply. + let mut hard_errors = Vec::new(); + for doc in split_yaml_docs_owned(yaml) { + if doc.trim().is_empty() { + continue; + } + match crate::commands::local::kubectl_apply_stdin(&doc) { + Ok(()) => {} + Err(e) => { + let msg = e.to_string(); + if is_soft_apply_error(&msg) { + log::warn!("skipping resource (platform CRD/type unavailable): {msg}"); + } else { + hard_errors.push(msg); + } + } + } + } + if hard_errors.is_empty() { + Ok(()) + } else { + Err(format!("kubectl apply failed:\n - {}", hard_errors.join("\n - ")).into()) + } + } +} + +/// Missing CRDs / unknown types are expected until platform packs are installed. +fn is_soft_apply_error(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + lower.contains("no matches for kind") + || lower.contains("no matches for") + || lower.contains("ensure crds are installed") + || lower.contains("the server doesn't have a resource type") +} + +/// Merge chart-level application values with runtime inject. +/// Precedence: base (app helm values) ← runtime_values (runtime wins on key clash). +pub fn merge_helm_values( + app_values: Option<&Value>, + runtime: &BTreeMap, +) -> Value { + let mut out = serde_yaml::Mapping::new(); + if let Some(Value::Mapping(m)) = app_values { + for (k, v) in m { + out.insert(k.clone(), deep_clone_value(v)); + } + } + for (k, v) in runtime { + out.insert(Value::String(k.clone()), v.clone()); + } + Value::Mapping(out) +} + +fn deep_clone_value(v: &Value) -> Value { + // serde_yaml::Value is already cloneable + v.clone() +} + +/// Labels stamped on every managed object. +pub fn inject_labels(workspace_name: &str, app_name: &str) -> BTreeMap { + let mut labels = BTreeMap::new(); + labels.insert( + "app.kubernetes.io/managed-by".to_string(), + MANAGED_BY_VALUE.to_string(), + ); + labels.insert(WORKSPACE_ENV_LABEL.to_string(), workspace_name.to_string()); + labels.insert(WORKSPACE_APP_LABEL.to_string(), app_name.to_string()); + labels +} + +fn split_yaml_docs_owned(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut buf = String::new(); + for line in s.lines() { + if line.trim() == "---" { + if !buf.trim().is_empty() { + parts.push(std::mem::take(&mut buf)); + } + } else { + buf.push_str(line); + buf.push('\n'); + } + } + if !buf.trim().is_empty() { + parts.push(buf); + } + parts +} + +fn inject_labels_into_value(value: &mut Value, labels: &BTreeMap) { + let Some(root) = value.as_mapping_mut() else { + return; + }; + inject_labels_into_metadata_map(root, labels); + + // Workload kinds: also stamp pod template labels so selectors/discovery work. + let kind = root + .get(Value::String("kind".into())) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if matches!( + kind, + "Deployment" | "StatefulSet" | "DaemonSet" | "Job" | "ReplicaSet" + ) { + if let Some(spec) = root + .get_mut(Value::String("spec".into())) + .and_then(|v| v.as_mapping_mut()) + { + if let Some(template) = spec + .get_mut(Value::String("template".into())) + .and_then(|v| v.as_mapping_mut()) + { + inject_labels_into_metadata_map(template, labels); + } + } + } +} + +fn inject_labels_into_metadata_map( + obj: &mut serde_yaml::Mapping, + labels: &BTreeMap, +) { + let meta_key = Value::String("metadata".into()); + if !obj.contains_key(&meta_key) { + obj.insert(meta_key.clone(), Value::Mapping(serde_yaml::Mapping::new())); + } + let Some(meta) = obj.get_mut(&meta_key).and_then(|v| v.as_mapping_mut()) else { + return; + }; + let labels_key = Value::String("labels".into()); + if !meta.contains_key(&labels_key) { + meta.insert(labels_key.clone(), Value::Mapping(serde_yaml::Mapping::new())); + } + let Some(label_map) = meta.get_mut(&labels_key).and_then(|v| v.as_mapping_mut()) else { + return; + }; + for (k, v) in labels { + label_map.insert(Value::String(k.clone()), Value::String(v.clone())); + } +} + +/// Inject labels into every document in a multi-doc YAML stream. +pub fn render_labels_into_manifests( + rendered: &str, + labels: &BTreeMap, +) -> Result> { + let mut out_docs = Vec::new(); + for doc in split_yaml_docs_owned(rendered) { + if doc.trim().is_empty() { + continue; + } + let mut value: Value = serde_yaml::from_str(&doc) + .map_err(|e| format!("parse rendered manifest: {e}\n---\n{doc}"))?; + inject_labels_into_value(&mut value, labels); + out_docs.push(serde_yaml::to_string(&value)?); + } + Ok(out_docs.join("---\n")) +} + +fn chart_has_chart_yaml(chart_path: &Path) -> bool { + chart_path.join("Chart.yaml").is_file() || chart_path.join("Chart.yml").is_file() +} + +fn values_to_yaml(values: &Value) -> Result> { + Ok(serde_yaml::to_string(values)?) +} + +fn build_runtime_values(opts: &ReconcileOptions, app_name: &str) -> BTreeMap { + let mut runtime = opts.runtime_values.clone(); + runtime + .entry("local".into()) + .or_insert(Value::Bool(true)); + runtime.insert("namespace".into(), Value::String(opts.namespace.clone())); + + // sourceDelivery: mode + hostPath (usually the git worktree root for all apps). + let mut sd = serde_yaml::Mapping::new(); + if let Some(mode) = &opts.delivery_mode { + sd.insert( + Value::String("mode".into()), + Value::String(mode.clone()), + ); + } + if let Some(host) = opts.app_delivery_host_paths.get(app_name) { + sd.insert( + Value::String("hostPath".into()), + Value::String(host.display().to_string()), + ); + } + if !sd.is_empty() { + // Merge into existing sourceDelivery mapping if runtime already has one. + if let Some(Value::Mapping(existing)) = runtime.get("sourceDelivery").cloned() { + let mut merged = existing; + for (k, v) in sd { + merged.insert(k, v); + } + runtime.insert("sourceDelivery".into(), Value::Mapping(merged)); + } else { + runtime.insert("sourceDelivery".into(), Value::Mapping(sd)); + } + } + runtime +} + +/// Reconcile all Applications under `env_path`. +pub fn reconcile_applications( + env_path: &Path, + opts: &ReconcileOptions, + helm: &H, + kubectl: &K, +) -> Result, Box> { + let apps = load_applications(env_path)?; + if apps.is_empty() { + return Err(format!( + "no Application YAML files found under {}", + env_path.display() + ) + .into()); + } + + let ns_labels = { + let mut m = BTreeMap::new(); + m.insert( + "app.kubernetes.io/managed-by".to_string(), + MANAGED_BY_VALUE.to_string(), + ); + m.insert( + WORKSPACE_ENV_LABEL.to_string(), + opts.workspace_name.clone(), + ); + m + }; + + if !opts.dry_run { + kubectl.ensure_namespace(&opts.namespace, &ns_labels)?; + } + + let mut results = Vec::new(); + let mut errors: Vec = Vec::new(); + + for (app_file, app) in apps { + match reconcile_one(&app_file, &app, opts, helm, kubectl) { + Ok(r) => results.push(r), + Err(e) => errors.push(format!("{}: {e}", app.metadata.name)), + } + } + + if !errors.is_empty() { + return Err(format!( + "reconcile failed for {} app(s):\n - {}", + errors.len(), + errors.join("\n - ") + ) + .into()); + } + Ok(results) +} + +fn reconcile_one( + app_file: &Path, + app: &Application, + opts: &ReconcileOptions, + helm: &H, + kubectl: &K, +) -> Result> { + let chart_path = resolve_source_path(app_file, &app.spec.source.path)?; + if !chart_path.exists() { + return Err(format!("chart path does not exist: {}", chart_path.display()).into()); + } + if !chart_has_chart_yaml(&chart_path) { + return Err(format!( + "not a Helm chart (missing Chart.yaml): {}", + chart_path.display() + ) + .into()); + } + + let runtime = build_runtime_values(opts, &app.metadata.name); + let merged = merge_helm_values(app.spec.source.helm.values.as_ref(), &runtime); + let values_yaml = values_to_yaml(&merged)?; + let release = sanitize_release_name(&app.metadata.name); + let rendered = helm.template(&release, &chart_path, &opts.namespace, &values_yaml)?; + let labels = inject_labels(&opts.workspace_name, &app.metadata.name); + let labeled = render_labels_into_manifests(&rendered, &labels)?; + let labeled = ensure_namespace_on_docs(&labeled, &opts.namespace)?; + + let applied = if opts.dry_run { + false + } else { + kubectl.apply(&labeled)?; + true + }; + + Ok(ReconcileResult { + app_name: app.metadata.name.clone(), + chart_path, + namespace: opts.namespace.clone(), + rendered_yaml: labeled, + applied, + }) +} + +fn sanitize_release_name(name: &str) -> String { + let mut s: String = name + .to_ascii_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect(); + while s.contains("--") { + s = s.replace("--", "-"); + } + s.trim_matches('-').chars().take(53).collect() +} + +fn ensure_namespace_on_docs(yaml: &str, namespace: &str) -> Result> { + let mut out = Vec::new(); + for doc in split_yaml_docs_owned(yaml) { + if doc.trim().is_empty() { + continue; + } + let mut value: Value = serde_yaml::from_str(&doc)?; + if let Some(root) = value.as_mapping_mut() { + let kind = root + .get(Value::String("kind".into())) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let cluster_scoped = matches!( + kind, + "Namespace" | "ClusterRole" | "ClusterRoleBinding" | "CustomResourceDefinition" + ); + if !cluster_scoped { + let meta_key = Value::String("metadata".into()); + if let Some(meta) = root.get_mut(&meta_key).and_then(|v| v.as_mapping_mut()) { + meta.insert( + Value::String("namespace".into()), + Value::String(namespace.to_string()), + ); + } + } + } + out.push(serde_yaml::to_string(&value)?); + } + Ok(out.join("---\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[test] + fn merge_helm_values_runtime_wins() { + let app = serde_yaml::from_str::( + r#" +local: true +appRuntime: cluster-dev +image: + tag: app +"#, + ) + .unwrap(); + let mut runtime = BTreeMap::new(); + runtime.insert("namespace".into(), Value::String("alice".into())); + runtime.insert("appRuntime".into(), Value::String("host".into())); + let merged = merge_helm_values(Some(&app), &runtime); + assert_eq!(merged["local"], Value::Bool(true)); + assert_eq!(merged["appRuntime"], Value::String("host".into())); + assert_eq!(merged["namespace"], Value::String("alice".into())); + assert_eq!(merged["image"]["tag"], Value::String("app".into())); + } + + #[test] + fn inject_labels_contains_required_keys() { + let labels = inject_labels("alice", "e2e-ui-api"); + assert_eq!( + labels.get("app.kubernetes.io/managed-by").map(String::as_str), + Some(MANAGED_BY_VALUE) + ); + assert_eq!( + labels.get(WORKSPACE_ENV_LABEL).map(String::as_str), + Some("alice") + ); + assert_eq!( + labels.get(WORKSPACE_APP_LABEL).map(String::as_str), + Some("e2e-ui-api") + ); + } + + #[test] + fn render_labels_into_manifests_stamps_all_docs() { + let rendered = r#" +apiVersion: v1 +kind: Service +metadata: + name: svc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dep + labels: + existing: keep +spec: + template: + metadata: + labels: + app: dep +"#; + let labels = inject_labels("ws", "app"); + let out = render_labels_into_manifests(rendered, &labels).unwrap(); + assert!(out.contains("app.kubernetes.io/managed-by: hops-local-gitops")); + assert!(out.contains("hops.ops.com.ai/local-env: ws")); + assert!(out.contains("hops.ops.com.ai/local-app: app")); + assert!(out.contains("existing: keep")); + // Deployment top-level + pod template both labeled + assert!(out.matches("hops-local-gitops").count() >= 2); + // Pod template must carry workspace labels for kubectl -l discovery + let docs: Vec<&str> = out.split("---").collect(); + let dep = docs.iter().find(|d| d.contains("kind: Deployment")).unwrap(); + assert!( + dep.contains("local-env: ws"), + "deployment/pod template missing local-env: {dep}" + ); + } + + struct MockHelm { + body: String, + } + + impl HelmRunner for MockHelm { + fn template( + &self, + _release: &str, + _chart_path: &Path, + _namespace: &str, + _values_yaml: &str, + ) -> Result> { + Ok(self.body.clone()) + } + } + + struct MockKubectl { + applied: Mutex>, + namespaces: Mutex>, + } + + impl KubectlApplier for MockKubectl { + fn ensure_namespace( + &self, + namespace: &str, + _labels: &BTreeMap, + ) -> Result<(), Box> { + self.namespaces.lock().unwrap().push(namespace.to_string()); + Ok(()) + } + fn apply(&self, yaml: &str) -> Result<(), Box> { + self.applied.lock().unwrap().push(yaml.to_string()); + Ok(()) + } + } + + #[test] + fn reconcile_applications_labels_and_namespace_override() { + let dir = std::env::temp_dir().join(format!( + "lwb-rec-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).unwrap(); + let chart = dir.join("chart"); + std::fs::create_dir_all(chart.join("templates")).unwrap(); + std::fs::write( + chart.join("Chart.yaml"), + "apiVersion: v2\nname: t\nversion: 0.1.0\n", + ) + .unwrap(); + std::fs::write(chart.join("values.yaml"), "local: false\n").unwrap(); + std::fs::write( + chart.join("templates/svc.yaml"), + "apiVersion: v1\nkind: Service\nmetadata:\n name: t\n", + ) + .unwrap(); + + let env = dir.join("env"); + std::fs::create_dir_all(&env).unwrap(); + let app_yaml = r#" +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: demo-app +spec: + source: + path: ../chart + helm: + values: + local: true + destination: + namespace: should-be-overridden +"#; + std::fs::write(env.join("app.yaml"), app_yaml).unwrap(); + + let helm = MockHelm { + body: "apiVersion: v1\nkind: Service\nmetadata:\n name: t\n".into(), + }; + let kubectl = MockKubectl { + applied: Mutex::new(Vec::new()), + namespaces: Mutex::new(Vec::new()), + }; + let opts = ReconcileOptions { + namespace: "alice".into(), + workspace_name: "alice".into(), + runtime_values: BTreeMap::new(), + app_delivery_host_paths: BTreeMap::new(), + delivery_mode: None, + dry_run: false, + }; + let results = reconcile_applications(&env, &opts, &helm, &kubectl).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].namespace, "alice"); + assert!(results[0].applied); + let applied = kubectl.applied.lock().unwrap(); + assert_eq!(applied.len(), 1); + assert!(applied[0].contains("hops-local-gitops")); + assert!(applied[0].contains("hops.ops.com.ai/local-env: alice")); + assert!(applied[0].contains("namespace: alice")); + assert_eq!( + kubectl.namespaces.lock().unwrap().as_slice(), + &["alice".to_string()] + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn build_runtime_values_injects_delivery_host_path() { + // Usual case: same worktree root for every app in the workspace. + let root = PathBuf::from("/worktrees/feature-x"); + let mut hosts = BTreeMap::new(); + hosts.insert("e2e-ui-ui".into(), root.clone()); + hosts.insert("e2e-ui-api".into(), root.clone()); + let opts = ReconcileOptions { + namespace: "feature-x".into(), + workspace_name: "feature-x".into(), + runtime_values: BTreeMap::new(), + app_delivery_host_paths: hosts, + delivery_mode: Some("hostPath".into()), + dry_run: true, + }; + let ui = build_runtime_values(&opts, "e2e-ui-ui"); + let api = build_runtime_values(&opts, "e2e-ui-api"); + assert_eq!( + ui["sourceDelivery"]["hostPath"], + Value::String("/worktrees/feature-x".into()) + ); + assert_eq!( + ui["sourceDelivery"]["hostPath"], + api["sourceDelivery"]["hostPath"] + ); + assert_eq!( + ui["sourceDelivery"]["mode"], + Value::String("hostPath".into()) + ); + } +} diff --git a/src/commands/local/workbench/registry.rs b/src/commands/local/workbench/registry.rs new file mode 100644 index 0000000..22d3e17 --- /dev/null +++ b/src/commands/local/workbench/registry.rs @@ -0,0 +1,204 @@ +//! Workspace registry: name → namespace (+ path metadata) under ~/.hops/local/envs/. + +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const ENVS_SUBDIR: &str = "envs"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceRecord { + /// Workspace name (user-facing). + pub name: String, + /// Kubernetes namespace — same as the slugified workspace name (`--name`). + pub namespace: String, + /// Absolute path to the env Application directory. + pub env_path: String, + /// Absolute project root (parent of env path when known). + #[serde(default)] + pub project_root: Option, + /// Source delivery strategy selected. + #[serde(default)] + pub delivery_mode: Option, + /// ISO-ish timestamp of last up. + #[serde(default)] + pub updated_at: Option, +} + +/// DNS-1123-ish slug for a workspace name. +pub fn slugify_name(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + let mut out = String::with_capacity(lower.len()); + let mut last_dash = false; + for ch in lower.chars() { + let ok = ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'; + if ok { + let c = if ch == '_' { '-' } else { ch }; + if c == '-' { + if !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } else { + out.push(c); + last_dash = false; + } + } else if !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } + let trimmed = out.trim_matches('-').to_string(); + if trimmed.is_empty() { + "workspace".to_string() + } else { + // DNS-1123 label max 63. + trimmed.chars().take(63).collect() + } +} + +/// Namespace = DNS-1123 slug of `--name` (e.g. `dogfood` → +/// `e2e-ui-ui.dogfood.svc.cluster.local`). +pub fn namespace_for_name(name: &str) -> String { + slugify_name(name) +} + +/// Default workspace name from cwd basename. +/// +/// For a git worktree at `…/worktrees/my-feature`, that becomes `my-feature` +/// → namespace `my-feature`. +pub fn default_name_from_cwd(cwd: &Path) -> String { + cwd.file_name() + .and_then(|s| s.to_str()) + .map(slugify_name) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "workspace".to_string()) +} + +pub fn ensure_envs_dir(state_dir: &Path) -> Result> { + let dir = state_dir.join(ENVS_SUBDIR); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn record_path(state_dir: &Path, name: &str) -> PathBuf { + state_dir + .join(ENVS_SUBDIR) + .join(format!("{}.json", slugify_name(name))) +} + +pub fn save_workspace(state_dir: &Path, record: &WorkspaceRecord) -> Result> { + ensure_envs_dir(state_dir)?; + let path = record_path(state_dir, &record.name); + let json = serde_json::to_string_pretty(record)?; + fs::write(&path, json)?; + Ok(path) +} + +pub fn load_workspace(state_dir: &Path, name: &str) -> Result, Box> { + let path = record_path(state_dir, name); + if !path.exists() { + return Ok(None); + } + let text = fs::read_to_string(&path)?; + let record: WorkspaceRecord = serde_json::from_str(&text)?; + Ok(Some(record)) +} + +pub fn remove_workspace(state_dir: &Path, name: &str) -> Result> { + let path = record_path(state_dir, name); + if path.exists() { + fs::remove_file(&path)?; + Ok(true) + } else { + Ok(false) + } +} + +pub fn list_workspaces(state_dir: &Path) -> Result, Box> { + let dir = state_dir.join(ENVS_SUBDIR); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut records = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let text = fs::read_to_string(&path)?; + match serde_json::from_str::(&text) { + Ok(r) => records.push(r), + Err(e) => log::warn!("skip corrupt workspace record {}: {e}", path.display()), + } + } + records.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(records) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugify_and_namespace_are_dns_safe() { + assert_eq!(slugify_name("My Worktree"), "my-worktree"); + assert_eq!(namespace_for_name("My Worktree"), "my-worktree"); + assert_eq!(slugify_name("___"), "workspace"); + assert_eq!(namespace_for_name("alice"), "alice"); + assert_eq!(namespace_for_name("bob"), "bob"); + assert_ne!(namespace_for_name("alice"), namespace_for_name("bob")); + } + + #[test] + fn registry_round_trip_and_concurrent_names() { + let dir = std::env::temp_dir().join(format!( + "lwb-reg-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&dir).unwrap(); + + let a = WorkspaceRecord { + name: "alice".into(), + namespace: namespace_for_name("alice"), + env_path: "/proj/gitops/env/local".into(), + project_root: Some("/proj".into()), + delivery_mode: Some("hostPath".into()), + updated_at: None, + }; + let b = WorkspaceRecord { + name: "bob".into(), + namespace: namespace_for_name("bob"), + env_path: "/proj/gitops/env/local".into(), + project_root: Some("/proj".into()), + delivery_mode: Some("sync".into()), + updated_at: None, + }; + save_workspace(&dir, &a).unwrap(); + save_workspace(&dir, &b).unwrap(); + + let loaded_a = load_workspace(&dir, "alice").unwrap().unwrap(); + let loaded_b = load_workspace(&dir, "bob").unwrap().unwrap(); + assert_eq!(loaded_a.namespace, "alice"); + assert_eq!(loaded_b.namespace, "bob"); + assert_ne!(loaded_a.namespace, loaded_b.namespace); + + let all = list_workspaces(&dir).unwrap(); + assert_eq!(all.len(), 2); + assert!(remove_workspace(&dir, "alice").unwrap()); + assert!(load_workspace(&dir, "alice").unwrap().is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn default_name_from_cwd_uses_basename() { + assert_eq!( + default_name_from_cwd(Path::new("/Users/x/dev/my-feature")), + "my-feature" + ); + } +} diff --git a/src/commands/local/workbench/watch.rs b/src/commands/local/workbench/watch.rs new file mode 100644 index 0000000..f176d18 --- /dev/null +++ b/src/commands/local/workbench/watch.rs @@ -0,0 +1,180 @@ +//! GitOps watch path filtering: re-reconcile only on env YAML / chart changes. + +use super::application::{load_applications, resolve_source_path, Application}; +use std::error::Error; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WatchPathClass { + /// Env Application YAML or chart template/values — should re-reconcile. + ChartOrEnv, + /// Ordinary app source under the project — must NOT re-helm. + AppSource, + /// Build artifacts / VCS — ignore entirely. + Ignored, +} + +/// Paths that should never trigger gitops re-reconcile. +pub fn should_ignore_watch_path(path: &Path) -> bool { + path.components().any(|c| { + let s = c.as_os_str().to_string_lossy(); + matches!( + s.as_ref(), + "node_modules" + | "target" + | ".git" + | ".svelte-kit" + | "dist" + | "build" + | "_output" + | ".cache" + | "playwright-report" + | "test-results" + ) + }) +} + +/// Build the set of roots to watch: env dir + each Application chart path. +pub fn watch_roots_for_applications( + env_path: &Path, +) -> Result, Box> { + let mut roots = Vec::new(); + let env_canon = env_path + .canonicalize() + .unwrap_or_else(|_| env_path.to_path_buf()); + roots.push(env_canon); + + let apps = load_applications(env_path)?; + for (app_file, app) in apps { + let chart = resolve_source_path(&app_file, &app.spec.source.path)?; + if chart.exists() { + roots.push(chart.canonicalize().unwrap_or(chart)); + } else { + roots.push(chart); + } + } + roots.sort(); + roots.dedup(); + Ok(roots) +} + +/// Classify a changed path relative to known env/chart roots and project root. +/// +/// - Under env dir or any chart root → ChartOrEnv +/// - Under ignored dirs → Ignored +/// - Otherwise (app source) → AppSource +pub fn is_chart_or_env_path( + changed: &Path, + env_path: &Path, + chart_paths: &[PathBuf], +) -> WatchPathClass { + if should_ignore_watch_path(changed) { + return WatchPathClass::Ignored; + } + + let changed_norm = normalize(changed); + let env_norm = normalize(env_path); + if path_is_under(&changed_norm, &env_norm) { + // Only YAML under env counts as env change; still ChartOrEnv for any env path. + return WatchPathClass::ChartOrEnv; + } + for chart in chart_paths { + let chart_norm = normalize(chart); + if path_is_under(&changed_norm, &chart_norm) { + return WatchPathClass::ChartOrEnv; + } + } + WatchPathClass::AppSource +} + +fn normalize(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +fn path_is_under(path: &Path, root: &Path) -> bool { + path == root || path.starts_with(root) +} + +/// Helper for tests/docs: whether a change should trigger re-reconcile. +pub fn should_reconcile_on_change( + changed: &Path, + env_path: &Path, + chart_paths: &[PathBuf], +) -> bool { + is_chart_or_env_path(changed, env_path, chart_paths) == WatchPathClass::ChartOrEnv +} + +#[allow(dead_code)] +fn _use_app(_: &Application) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignores_build_artifact_paths() { + assert!(should_ignore_watch_path(Path::new( + "/proj/ui/node_modules/foo/index.js" + ))); + assert!(should_ignore_watch_path(Path::new("/proj/api/target/debug/x"))); + assert!(should_ignore_watch_path(Path::new("/proj/.git/objects/aa"))); + assert!(!should_ignore_watch_path(Path::new( + "/proj/ui/src/routes/+page.svelte" + ))); + } + + #[test] + fn chart_and_env_trigger_reconcile_source_does_not() { + let env = PathBuf::from("/proj/gitops/env/local"); + let charts = vec![ + PathBuf::from("/proj/api/.gitops/deploy"), + PathBuf::from("/proj/ui/.gitops/deploy"), + ]; + + assert!(should_reconcile_on_change( + Path::new("/proj/gitops/env/local/api.yaml"), + &env, + &charts + )); + assert!(should_reconcile_on_change( + Path::new("/proj/api/.gitops/deploy/templates/service.yaml"), + &env, + &charts + )); + assert!(should_reconcile_on_change( + Path::new("/proj/ui/.gitops/deploy/values.yaml"), + &env, + &charts + )); + // Ordinary app source — must NOT re-apply charts + assert!(!should_reconcile_on_change( + Path::new("/proj/ui/src/routes/+page.svelte"), + &env, + &charts + )); + assert!(!should_reconcile_on_change( + Path::new("/proj/crates/service/src/lib.rs"), + &env, + &charts + )); + // Ignored even if under chart-ish names + assert_eq!( + is_chart_or_env_path( + Path::new("/proj/ui/node_modules/x"), + &env, + &charts + ), + WatchPathClass::Ignored + ); + } +} diff --git a/src/commands/local/zitadel.rs b/src/commands/local/zitadel.rs index 7cc7a5c..966579f 100644 --- a/src/commands/local/zitadel.rs +++ b/src/commands/local/zitadel.rs @@ -1,7 +1,9 @@ +use super::gitops_write::{log_written, write_gitops_files, GitopsFile}; use super::{kubectl_apply_stdin, run_cmd_output}; use clap::Args; use serde_json::json; use std::error::Error; +use std::path::PathBuf; use std::process::Command; use std::thread; use std::time::Duration; @@ -71,6 +73,11 @@ pub struct ZitadelArgs { /// Refresh credentials in the secret only; skips Provider and ProviderConfig apply #[arg(long)] pub refresh: bool, + + /// Write non-secret Provider / ProviderConfig YAML under this directory + /// (e.g. `./gitops/cluster`). Credential Secrets are not written. + #[arg(long)] + pub gitops: Option, } pub fn run(args: &ZitadelArgs) -> Result<(), Box> { @@ -99,14 +106,39 @@ pub fn run(args: &ZitadelArgs) -> Result<(), Box> { return Ok(()); } + let provider_yaml = build_provider_yaml(&args.provider_name, &args.provider_package); + let provider_config_yaml = build_provider_config_yaml( + &args.namespace, + &args.provider_config_name, + &args.secret_name, + ); + + if let Some(gitops) = &args.gitops { + let written = write_gitops_files( + gitops, + &[ + GitopsFile { + rel_path: "providers/zitadel.yaml".into(), + yaml: provider_yaml.clone(), + }, + GitopsFile { + rel_path: "providerconfigs/zitadel.yaml".into(), + yaml: provider_config_yaml.clone(), + }, + ], + )?; + log_written(&written); + log::info!( + "Zitadel non-secret manifests written under {} (Secret still applied live only)", + gitops.display() + ); + } + log::info!( "Applying provider-upjet-zitadel package '{}'...", args.provider_package ); - kubectl_apply_stdin(&build_provider_yaml( - &args.provider_name, - &args.provider_package, - ))?; + kubectl_apply_stdin(&provider_yaml)?; wait_for_crd(PROVIDER_CONFIG_CRD)?; @@ -126,11 +158,7 @@ pub fn run(args: &ZitadelArgs) -> Result<(), Box> { args.namespace, args.provider_config_name ); - kubectl_apply_stdin(&build_provider_config_yaml( - &args.namespace, - &args.provider_config_name, - &args.secret_name, - ))?; + kubectl_apply_stdin(&provider_config_yaml)?; log::info!( "Zitadel provider configured for '{}' (ProviderConfig: {}/{})",