diff --git a/.github/workflows/on-pr-colima-smoke.yaml b/.github/workflows/on-pr-colima-smoke.yaml index 52d4d16..91faa81 100644 --- a/.github/workflows/on-pr-colima-smoke.yaml +++ b/.github/workflows/on-pr-colima-smoke.yaml @@ -1,7 +1,7 @@ name: colima backend smoke # End-to-end check of `hops local` on the colima backend on macOS. -# Mirrors on-pr-kind-smoke.yaml with --backend colima and kubectl --context colima. +# Mirrors on-pr-kind-smoke.yaml with colima as both providers and kubectl --context colima. # # Runner constraints: # - Colima needs nested virtualization (Lima VM). Pin macos-15-intel — that @@ -12,7 +12,7 @@ name: colima backend smoke # (macOS 15 Local Network Privacy can block non-root VM-IP access). # # Install: brew provides colima/docker/kubectl/helm only. Do not pre-start -# colima here — hops local start --backend colima owns cluster bring-up. +# colima here — hops local start with colima providers owns cluster bring-up. # # Sizing: hops defaults (8 CPU / 16 GiB / 60 GiB) exceed standard # macos-15-intel runners (~4 CPU / ~14 GiB). Pass explicit smaller sizes @@ -57,14 +57,14 @@ jobs: sysctl hw.memsize df -h / - - name: hops local start --backend colima + - name: hops local start with colima providers run: | set -euxo pipefail # Fit macos-15-intel (~4 CPU / ~14 GiB RAM). Defaults (8/16/60) OOM VZ. # Memory 10 (not 8): at 8Gi CoreDNS/metrics-server thrash and smoke pods # sit in ContainerCreating without IPs even after images pull. Leave ~4Gi # for host macOS + VZ. Disk 40: registry PVC requests 20Gi. - ./target/debug/hops-cli local start --backend colima \ + ./target/debug/hops-cli local start --cluster-provider colima --docker-provider colima \ --cpus 3 --memory 10 --disk 40 - name: hops local doctor diff --git a/.github/workflows/on-pr-dory-smoke.yaml b/.github/workflows/on-pr-dory-smoke.yaml index 3696675..6c51b33 100644 --- a/.github/workflows/on-pr-dory-smoke.yaml +++ b/.github/workflows/on-pr-dory-smoke.yaml @@ -206,11 +206,11 @@ jobs: # DOCKER_HOST + KUBECONFIG already in env from prior step dory readiness || true - - name: hops local start --backend dory + - name: hops local start with Dory providers run: | set -euxo pipefail # HOPS_DORY_DESKTOP=0 → env session only (no global context changes) - ./target/debug/hops-cli local start --backend dory + ./target/debug/hops-cli local start --cluster-provider dory --docker-provider dory - name: hops local doctor run: | @@ -290,7 +290,7 @@ jobs: test -d "$FIXTURE" test -f "$FIXTURE/upbound.yaml" - ./target/debug/hops-cli config install --path "$FIXTURE" --backend dory + ./target/debug/hops-cli config install --path "$FIXTURE" --cluster-provider dory --docker-provider dory # Wait for Configuration package Healthy. for i in $(seq 1 90); do diff --git a/.github/workflows/on-pr-kind-smoke.yaml b/.github/workflows/on-pr-kind-smoke.yaml index 5d9c247..cf710af 100644 --- a/.github/workflows/on-pr-kind-smoke.yaml +++ b/.github/workflows/on-pr-kind-smoke.yaml @@ -34,8 +34,8 @@ jobs: - name: Build hops run: cargo build - - name: hops local start --backend kind - run: ./target/debug/hops-cli local start --backend kind + - name: hops local start with kind + Docker + run: ./target/debug/hops-cli local start --cluster-provider kind --docker-provider docker - name: hops local doctor run: ./target/debug/hops-cli local doctor @@ -84,7 +84,7 @@ jobs: test -d "$FIXTURE" test -f "$FIXTURE/upbound.yaml" - ./target/debug/hops-cli config install --path "$FIXTURE" --backend kind + ./target/debug/hops-cli config install --path "$FIXTURE" --cluster-provider kind --docker-provider docker for i in $(seq 1 90); do healthy="$(kubectl --context kind-hops get configuration.pkg.crossplane.io hops-ops-config-smoke \ diff --git a/README.md b/README.md index f79d5f5..c3a792b 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,13 @@ hops xr --help Multi-workspace local GitOps on the laptop control plane: ```bash -# once -hops local start +# shared control-plane tree (terminal 1) +hops local gitops cluster ./gitops/cluster \ + --cluster-provider kind --docker-provider dory --cluster-name hops -# daily -hops local up ./gitops/env/local -hops local status -hops local open -hops local down +# per-workspace tree (terminal 2) +hops local gitops worktree ./gitops/envs/local --name alice \ + --cluster-provider kind --docker-provider dory --cluster-name hops ``` Use `--name` for concurrent worktrees (`` namespaces). Full guide: [skills/claude/references/local-workbench.md](skills/claude/references/local-workbench.md). @@ -208,9 +207,8 @@ Examples: ## Create a Local Control Plane ```bash -# 1) Install the backend (via Homebrew). Defaults to colima on macOS; -# pass --backend kind to use kind on any docker daemon. -hops local install +# 1) Install/select the cluster and Docker providers. +hops local install --cluster-provider kind --docker-provider dory # 2) Start local k8s + Crossplane + providers + local registry hops local start @@ -228,9 +226,9 @@ hops local zitadel --source-context pat-local --domain auth.ops.com.ai hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 ``` -### Cluster backends +### Cluster and Docker providers -`hops local` supports three backends behind the same commands: +`hops local` separates Kubernetes node provisioning from the Docker engine: - **colima** — a VM running dockerd + k3s. macOS/Linux; supports `--cpus`, `--memory`, `--disk`, and `hops local resize`. @@ -247,17 +245,14 @@ hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 dockerd runs *inside* the engine — Mac `localhost` is the wrong plane. The VM is sized in the Dory app, so hops sizing flags don't apply. -Select with the global `--backend` flag: +Select both dimensions explicitly: ```bash -hops local start --backend kind +hops local start --cluster-provider kind --docker-provider dory --cluster-name hops ``` -The chosen backend is persisted to `~/.hops/local/backend` on a successful -start, so later commands (`stop`, `destroy`, `doctor`, package installs) -target the same cluster without the flag. Resolution order: `--backend` flag > -persisted choice > existing cluster detection (colima wins) > platform -default (macOS: colima, otherwise kind). +The chosen pair is persisted to `~/.hops/local/providers.json` on a successful +start, so later commands can target the same cluster without repeating flags. Unless `--context` is given, kubectl commands automatically use the backend's kubeconfig context (`colima`, `kind-hops`, or `hops-dory`), regardless of your @@ -273,7 +268,7 @@ fork of Dory required. # 2. Enable Kubernetes in the app; wait until the cluster is running # (product container is usually named dory-k8s) # 3. Bootstrap Crossplane + local package registry -hops local start --backend dory +hops local start --cluster-provider dory --docker-provider dory ``` On start/activate, hops: @@ -283,8 +278,8 @@ On start/activate, hops: - runs `kubectl config use-context hops-dory` - creates/uses a docker context of the same name → `unix://$HOME/.dory/dory.sock` -`--dory-name` is intentionally **not** `--name`. Workspace commands use `--name` for the -Kubernetes namespace (`hops local up|down|status|open|gitops worktree --name alice`). +`--dory-name` is intentionally **not** `--name`. Workspace GitOps uses `--name` +for the Kubernetes namespace. So you should **not** need: @@ -294,22 +289,22 @@ export DOCKER_HOST=unix://$HOME/.dory/dory.sock ``` ```bash -hops local start --backend dory # dory name defaults to hops-dory -hops local start --backend dory --dory-name mine # custom kube+docker context name -hops local up ./gitops/envs/local --name alice # workspace ns only; does not rename Dory +hops local start --cluster-provider dory --docker-provider dory +hops local start --cluster-provider dory --docker-provider dory --dory-name mine +hops local gitops worktree ./gitops/envs/local --name alice kubectl get nodes # context hops-dory docker info # context hops-dory hops local doctor hops local github -o hops-ops -hops config install --path … --backend dory +hops config install --path … --cluster-provider dory --docker-provider dory ``` Alternatively, use kind on Dory's docker socket (no product k3s): ```bash docker context use dory # product context from Dory.app -hops local start --backend kind +hops local start --cluster-provider kind --docker-provider dory --cluster-name hops ``` **CI:** `.github/workflows/on-pr-dory-smoke.yaml` runs on a **self-hosted** @@ -397,7 +392,7 @@ k3s is product-owned. hops does not run `dory k8s enable`. 1. Install the Kubernetes component if needed: `dory component install kubernetes` 2. Enable Kubernetes in the Dory app UI 3. Wait until a `dory-k8s` container is running: `docker ps` (with context hops-dory) -4. Re-run `hops local start --backend dory` +4. Re-run `hops local start --cluster-provider dory --docker-provider dory` **`k ctx` has no dory / hops-dory entry** @@ -406,7 +401,7 @@ merges that into `~/.kube/config` as **`hops-dory`** on activate/start. If the merge is missing: ```bash -hops local doctor --backend dory +hops local doctor --cluster-provider dory --docker-provider dory kubectl config get-contexts # expect hops-dory kubectl config use-context hops-dory ``` @@ -423,7 +418,7 @@ Crossplane always pulls packages with HTTPS. The local registry must be TLS ```bash kubectl -n crossplane-system delete deploy registry kubectl -n crossplane-system delete secret hops-local-registry-tls -hops local start --backend dory # recreates TLS secret + registry + CA patch +hops local start --cluster-provider dory --docker-provider dory ``` **docker push to localhost:30500 fails (connection refused / HTTPS to HTTP)** @@ -458,7 +453,7 @@ Avoid raw `kill` of dockerd inside the guest unless you are prepared to wait for ```bash dory doctor dory readiness -hops local doctor --backend dory +hops local doctor --cluster-provider dory --docker-provider dory docker context show kubectl config current-context kubectl get configuration,provider -A diff --git a/skills/claude/SKILL.md b/skills/claude/SKILL.md index 653f2b7..2ea7cfc 100644 --- a/skills/claude/SKILL.md +++ b/skills/claude/SKILL.md @@ -44,7 +44,8 @@ For detailed reference on each area, see the bundled references: ## Local control plane + platform packages (dogfood) ```bash -hops local start --backend dory --gitops ./gitops/cluster +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster # bootstrap writes helm/k8s providers + ProviderConfigs (default) into the tree, # then runs cluster gitops (apply + watch) diff --git a/skills/claude/references/config-install.md b/skills/claude/references/config-install.md index 02f52cb..10a898d 100644 --- a/skills/claude/references/config-install.md +++ b/skills/claude/references/config-install.md @@ -65,7 +65,7 @@ gitops (not only as one-shot kubectl applies). ```bash # 1. Bootstrap CP (creates helm/k8s ProviderConfigs named "default") -hops local start --backend dory +hops local start --cluster-provider kind --docker-provider dory --cluster-name hops # 2. Install published stacks + write package YAML under gitops/cluster hops config install --repo hops-ops/psql-stack --version v0.9.1 \ @@ -76,7 +76,7 @@ hops config install --repo hops-ops/auth-stack --version v1.6.0 \ # 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 +# or: hops local start --cluster-provider kind --docker-provider dory --cluster-name hops --gitops ./gitops/cluster ``` | Flag | Effect | diff --git a/skills/claude/references/local-setup.md b/skills/claude/references/local-setup.md index 9b95ad8..66687e2 100644 --- a/skills/claude/references/local-setup.md +++ b/skills/claude/references/local-setup.md @@ -4,8 +4,8 @@ ```bash # 1. Start local k8s + Crossplane + providers + registry -# (backend preference is user-local: ~/.hops/local/backend) -hops local start --backend dory +# (provider selection is user-local: ~/.hops/local/providers.json) +hops local start --cluster-provider kind --docker-provider dory --cluster-name hops # 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 \ @@ -45,7 +45,8 @@ With **`--gitops PATH`** (e.g. `./gitops/cluster`): 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 +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster ``` **Version bumps:** Renovate owns these pins (`cli/renovate.json` customManagers → diff --git a/skills/claude/references/local-source-packages.md b/skills/claude/references/local-source-packages.md index 26e7e74..bbb3282 100644 --- a/skills/claude/references/local-source-packages.md +++ b/skills/claude/references/local-source-packages.md @@ -22,7 +22,8 @@ providers that Configurations depend on. ## Prerequisites ```bash -hops local start --backend dory --gitops ./gitops/cluster +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster # Creates: Crossplane, helm/k8s providers (pinned), ProviderConfigs named "default", # local OCI registry, and writes bootstrap YAML under gitops/cluster/ ``` diff --git a/skills/claude/references/local-workbench.md b/skills/claude/references/local-workbench.md index bec6d96..05312a3 100644 --- a/skills/claude/references/local-workbench.md +++ b/skills/claude/references/local-workbench.md @@ -15,8 +15,8 @@ tree copy). You do not need to learn volume types. ```bash # Dory app running (engine healthy). Product Dory Kubernetes is optional. -# hops points kind at ~/.dory/dory.sock when present. -hops local start --backend kind --gitops ./gitops/cluster +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster ``` Context is typically `kind-hops`. Confirm mounts: @@ -25,17 +25,19 @@ Context is typically `kind-hops`. Confirm mounts: hops local doctor # "kind node projects-root mount (hostPath capable)" ``` -**Changing mounts:** recreate the kind cluster — `hops local reset --backend kind` +**Changing mounts:** recreate the kind cluster — +`hops local reset --cluster-provider kind --docker-provider dory --cluster-name hops` (or destroy + start). Existing clusters created without mounts will not pick up home mounts until reset. ### Alternative: product Dory Kubernetes -Stock Dory k8s (`--backend dory`) is fine for platform experiments but usually +Stock Dory k8s (`--cluster-provider dory --docker-provider dory`) is fine for platform experiments but usually **cannot** hostPath-mount Mac paths into the node; delivery falls back to sync. ```bash -hops local start --backend dory --gitops ./gitops/cluster +hops local start --cluster-provider dory --docker-provider dory \ + --gitops ./gitops/cluster ``` ## Daily loop @@ -47,16 +49,7 @@ 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 +# Stop either watcher with Ctrl+C. ``` Watch is the default for both gitops commands. Use `--once` for a single reconcile (CI/scripts). @@ -72,25 +65,19 @@ 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. +Each name maps to namespace ``. ## Dogfood: e2e-ui ```bash cd distributed/tests/e2e-ui # Prefer kind-on-Dory for hostPath HMR (see One-time prerequisite) -hops local start --backend kind --gitops ./gitops/cluster +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster hops local gitops cluster ./gitops/cluster hops local gitops worktree ./gitops/envs/local --name dogfood -# or: hops local up ./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: @@ -108,7 +95,7 @@ host `make run` you invent. **When the dogfood site is broken:** -1. **Confirm runtime first** (`KUBECONFIG` = dory, e.g. `~/.kube/dory-config`): +1. **Confirm runtime first** (`kubectl --context kind-hops`): ```bash kubectl -n dogfood get pods kubectl -n dogfood logs deploy/e2e-ui-api --tail=40 @@ -139,8 +126,8 @@ host `make run` you invent. 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. +**Kube context:** use `kind-hops`; map host access uses cluster FQDNs +(`*.svc.cluster.local`), not `localhost` alone. ## Layout diff --git a/src/commands/config/install.rs b/src/commands/config/install.rs index 448eae1..ecc86e0 100644 --- a/src/commands/config/install.rs +++ b/src/commands/config/install.rs @@ -1,10 +1,11 @@ -use crate::commands::local::backend::{self, Backend}; +use crate::commands::local::backend::{self, Backend, ClusterProvider, DockerProvider}; use crate::commands::local::package_install::run_watch; use crate::commands::local::package_install::{ docker_arch, ensure_cached_repo_checkout, ensure_registry, image_config_name, - parse_docker_push_digest, parse_repo_spec, resolve_repo_install_target, rewrite_registry, - rewrite_registry_with_tag, sanitize_name_component, short_hash, split_ref, strip_registry, - unique_suffix, RepoInstallTarget, RepoSpec, registry_pull, registry_push, + parse_docker_push_digest, parse_repo_spec, registry_pull, registry_push, + resolve_repo_install_target, rewrite_registry, rewrite_registry_with_tag, + sanitize_name_component, short_hash, split_ref, strip_registry, unique_suffix, + RepoInstallTarget, RepoSpec, }; use crate::commands::local::{kubectl_apply_stdin, kubectl_command, run_cmd, run_cmd_output}; use clap::Args; @@ -41,9 +42,17 @@ pub struct ConfigArgs { #[arg(long)] pub context: Option, - /// Local cluster backend whose node should be wired for local package pulls. - #[arg(long, value_enum)] - pub backend: Option, + /// How Kubernetes nodes are provisioned: `kind`, `dory`, or `colima`. + #[arg(long = "cluster-provider", value_enum)] + pub cluster_provider: Option, + + /// Container engine for kind/tools: `dory`, `colima`, or `docker`. + #[arg(long = "docker-provider", value_enum)] + pub docker_provider: Option, + + /// Named hops-managed kind cluster. Default `hops` uses context `kind-hops`. + #[arg(long = "cluster-name", value_name = "NAME")] + pub cluster_name: Option, /// Watch the project directory for changes and re-run install automatically #[arg(long, conflicts_with = "repo")] @@ -97,6 +106,11 @@ struct PackageMetadataName { name: String, } +#[derive(Debug, Deserialize)] +struct ConfigurationPackageMetadata { + metadata: PackageMetadataName, +} + #[derive(Debug, Deserialize)] struct PackageSpec { #[serde(rename = "package")] @@ -110,7 +124,13 @@ struct PackageResource { } pub fn run(args: &ConfigArgs) -> Result<(), Box> { - let backend = backend::activate(args.backend, args.context.as_deref()); + let provider_selected = args.cluster_provider.is_some() || args.docker_provider.is_some(); + let backend = backend::activate_with_providers( + args.cluster_provider, + args.docker_provider, + args.cluster_name.as_deref(), + args.context.as_deref(), + )?; match (args.repo.as_deref(), args.version.as_deref()) { (Some(repo), Some(version)) => { @@ -120,12 +140,12 @@ pub fn run(args: &ConfigArgs) -> Result<(), Box> { repo, args.skip_dependency_resolution, backend, - args.backend, + provider_selected, args.context.as_deref(), ), (None, _) => { let path = args.path.as_deref().unwrap_or("."); - prepare_local_registry(backend, args.backend, args.context.as_deref())?; + prepare_local_registry(backend, provider_selected, args.context.as_deref())?; run_local_path(path, args.skip_dependency_resolution)?; if args.watch { @@ -145,7 +165,7 @@ fn run_repo_install( repo: &str, skip_dependency_resolution: bool, backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { let spec = parse_repo_spec(repo)?; @@ -154,7 +174,7 @@ fn run_repo_install( &spec, skip_dependency_resolution, backend, - backend_flag, + provider_selected, context, ), RepoInstallTarget::PublishedVersion(version) => { @@ -167,21 +187,21 @@ fn run_repo_clone( spec: &RepoSpec, skip_dependency_resolution: bool, backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { let cache_path = ensure_cached_repo_checkout(spec)?; - prepare_local_registry(backend, backend_flag, context)?; + prepare_local_registry(backend, provider_selected, context)?; run_local_path(&cache_path.to_string_lossy(), skip_dependency_resolution) } fn prepare_local_registry( backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { ensure_registry()?; - backend::wire_local_registry_for_target(backend, backend_flag, context) + backend::wire_local_registry_for_target(backend, provider_selected, context) } fn apply_repo_version_spec( @@ -380,7 +400,7 @@ spec: } // Patch and push configuration images. - let mut config_pull_refs = Vec::new(); + let mut configurations = Vec::new(); for img in &loaded { if !is_configuration_image(&img.source) { continue; @@ -394,10 +414,10 @@ spec: dev_tag, img.source ); - config_pull_refs.push(pull_ref.clone()); - let mut source_to_push = img.source.clone(); let package_yaml = extract_package_yaml_from_uppkg(&img.uppkg_path, &img.source)?; + let configuration_name = configuration_name_from_package_yaml(&package_yaml, &pull_ref); + configurations.push((configuration_name, pull_ref.clone())); let (patched_yaml, changed) = rewrite_render_dependency_digests(&package_yaml, &render_rewrites); if changed { @@ -415,10 +435,7 @@ spec: // Apply Crossplane Configuration resources and let Crossplane resolve // dependencies (skipDependencyResolution is intentionally not set). - for pull_ref in &config_pull_refs { - let (img_path, _) = split_ref(pull_ref); - let path = strip_registry(img_path); - let name = path.replace('/', "-"); + for (name, pull_ref) in &configurations { let existing_package_ref = current_configuration_package_ref(&name)?; log_existing_install_replacement(&name, existing_package_ref.as_deref(), pull_ref); @@ -610,6 +627,21 @@ fn is_configuration_image(image: &str) -> bool { split_ref(image).1 == "configuration" } +/// Prefer the package author's declared metadata.name so a source install +/// updates the same Configuration object as a published GitOps pin. Fall back +/// to the historical registry-path name for older packages without metadata. +fn configuration_name_from_package_yaml(package_yaml: &str, pull_ref: &str) -> String { + serde_yaml::Deserializer::from_str(package_yaml) + .next() + .and_then(|document| ConfigurationPackageMetadata::deserialize(document).ok()) + .map(|package| sanitize_name_component(&package.metadata.name)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| { + let (image_path, _) = split_ref(pull_ref); + strip_registry(image_path).replace('/', "-") + }) +} + fn extract_package_yaml_from_uppkg( uppkg_path: &Path, configuration_image: &str, @@ -1099,9 +1131,41 @@ spec: } #[test] - fn local_registry_wiring_skips_foreign_context_without_backend_flag() { + fn source_install_uses_declared_configuration_name() { + let package_yaml = r#"apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: secret-stack +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: secretstores.hops.ops.com.ai +"#; + assert_eq!( + configuration_name_from_package_yaml( + package_yaml, + "registry.crossplane-system.svc.cluster.local:5000/hops-ops/secret-stack:dev-abc" + ), + "secret-stack" + ); + } + + #[test] + fn source_install_name_falls_back_to_registry_path() { + assert_eq!( + configuration_name_from_package_yaml( + "apiVersion: meta.pkg.crossplane.io/v1\nkind: Configuration\n", + "registry.crossplane-system.svc.cluster.local:5000/hops-ops/secret-stack:dev-abc" + ), + "hops-ops-secret-stack" + ); + } + + #[test] + fn local_registry_wiring_skips_foreign_context_without_provider_selection() { assert!(!backend::should_wire_local_registry( - None, + false, Some("kind-hops"), Backend::Colima )); diff --git a/src/commands/local/backend/dory.rs b/src/commands/local/backend/dory.rs index 1773a01..369a88d 100644 --- a/src/commands/local/backend/dory.rs +++ b/src/commands/local/backend/dory.rs @@ -15,10 +15,10 @@ //! - **Node image pulls:** k3s `registries.yaml` mirrors → Service ClusterIP. use super::SizeArgs; -use crate::commands::local::{command_exists, run_cmd, run_cmd_output}; use crate::commands::local::package_install::{ REGISTRY_HOSTNAME, REGISTRY_PULL_INCLUSTER, REGISTRY_PUSH, }; +use crate::commands::local::{command_exists, run_cmd, run_cmd_output}; use std::error::Error; use std::path::PathBuf; use std::thread; @@ -64,7 +64,7 @@ pub fn install() -> Result<(), Box> { run_cmd("brew", &["install", "--cask", "Augani/dory/dory"])?; log::info!( "Dory installed; open the app, wait until the engine is healthy, \ - enable Kubernetes, then re-run `hops local start --backend dory`" + enable Kubernetes, then re-run `hops local start --cluster-provider dory --docker-provider dory`" ); Ok(()) } @@ -79,7 +79,7 @@ pub fn uninstall() -> Result<(), Box> { pub fn start(size: &SizeArgs) -> Result<(), Box> { if size.any_set() { return Err(format!( - "the dory backend's VM is sized by the Dory app, not hops; drop{}", + "the dory cluster provider's VM is sized by the Dory app, not hops; drop{}", size.command_suffix() ) .into()); @@ -122,7 +122,7 @@ pub fn reset() -> Result<(), Box> { preflight()?; destroy()?; log::info!( - "Enable Kubernetes in the Dory app, then run `hops local start --backend dory` again" + "Enable Kubernetes in the Dory app, then run `hops local start --cluster-provider dory --docker-provider dory` again" ); Ok(()) } @@ -192,7 +192,7 @@ fn ensure_k8s_node_running() -> Result<(), Box> { Err( "Dory Kubernetes is not enabled (no `dory-k8s` container).\n\ In the Dory app: enable Kubernetes, wait until it is running, then re-run:\n\ - hops local start --backend dory\n\ + hops local start --cluster-provider dory --docker-provider dory\n\ (hops uses stock Dory only — it does not create the cluster for you.)" .into(), ) @@ -239,12 +239,9 @@ fn ensure_side_kubeconfig_hint() { if std::path::Path::new(&path).is_file() { return; } - if let Ok(yaml) = engine_docker_output(&[ - "exec", - NODE_CONTAINER, - "cat", - "/etc/rancher/k3s/k3s.yaml", - ]) { + if let Ok(yaml) = + engine_docker_output(&["exec", NODE_CONTAINER, "cat", "/etc/rancher/k3s/k3s.yaml"]) + { if yaml.contains("server:") { if let Some(parent) = std::path::Path::new(&path).parent() { let _ = std::fs::create_dir_all(parent); @@ -298,9 +295,7 @@ fn node_ip() -> Result> { /// Restarts the node only when the file content changes. fn ensure_k3s_registry_mirrors(cluster_ip: &str) -> Result<(), Box> { if !node_running() { - return Err( - "dory k8s node is not running; enable Kubernetes in the Dory app first".into(), - ); + return Err("dory k8s node is not running; enable Kubernetes in the Dory app first".into()); } let push = registry_push_addr()?; @@ -407,8 +402,11 @@ fn ensure_engine_push_path() -> Result<(), Box> { fn ensure_dockerd_insecure_for_push(push_hostport: &str) -> Result<(), Box> { // Already configured? - if let Ok(info) = engine_docker_output(&["info", "-f", "{{json .RegistryConfig.InsecureRegistryCIDRs}}{{json .RegistryConfig.IndexConfigs}}"]) - { + if let Ok(info) = engine_docker_output(&[ + "info", + "-f", + "{{json .RegistryConfig.InsecureRegistryCIDRs}}{{json .RegistryConfig.IndexConfigs}}", + ]) { if info.contains(push_hostport) || info.contains("192.168.215.0/24") { return Ok(()); } @@ -519,10 +517,9 @@ fn validate_context_name(name: &str) -> Result> { } // kubectl context names: keep it simple (no path separators / whitespace). if name.contains(['/', '\\', ' ', '\t', '\n', ':']) { - return Err(format!( - "invalid --dory-name '{name}': use a simple token (e.g. hops-dory)" - ) - .into()); + return Err( + format!("invalid --dory-name '{name}': use a simple token (e.g. hops-dory)").into(), + ); } Ok(name.to_string()) } @@ -690,16 +687,12 @@ fn ensure_user_kubeconfig_context(name: &str) -> Result<(), Box> { let backup = kube_dir.join("config.hops-dory-backup"); let _ = std::fs::copy(&main, &backup); std::fs::write(&main, merged)?; - let _ = std::fs::set_permissions( - &main, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&main, std::os::unix::fs::PermissionsExt::from_mode(0o600)); } else { std::fs::copy(&tmp, &main)?; - let _ = std::fs::set_permissions( - &main, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&main, std::os::unix::fs::PermissionsExt::from_mode(0o600)); } let _ = std::fs::remove_file(&tmp); @@ -732,8 +725,8 @@ fn ensure_docker_context_default(name: &str) -> Result<(), Box> { return Ok(()); } let host = format!("host=unix://{}", sock.display()); - let contexts = run_cmd_output("docker", &["context", "ls", "--format", "{{.Name}}"]) - .unwrap_or_default(); + let contexts = + run_cmd_output("docker", &["context", "ls", "--format", "{{.Name}}"]).unwrap_or_default(); if !contexts.lines().any(|n| n.trim() == name) { log::info!( "Creating docker context '{}' → unix://{}", @@ -741,16 +734,10 @@ fn ensure_docker_context_default(name: &str) -> Result<(), Box> { sock.display() ); // Ignore failure if a stale context exists with different metadata. - let create = run_cmd( - "docker", - &["context", "create", name, "--docker", &host], - ); + let create = run_cmd("docker", &["context", "create", name, "--docker", &host]); if create.is_err() { // Update in place when possible. - let _ = run_cmd( - "docker", - &["context", "update", name, "--docker", &host], - ); + let _ = run_cmd("docker", &["context", "update", name, "--docker", &host]); } } run_cmd("docker", &["context", "use", name])?; @@ -794,7 +781,7 @@ mod tests { fn missing_k8s_error_mentions_app_not_fork() { let msg = "Dory Kubernetes is not enabled (no `dory-k8s` container).\n\ In the Dory app: enable Kubernetes, wait until it is running, then re-run:\n\ - hops local start --backend dory\n\ + hops local start --cluster-provider dory --docker-provider dory\n\ (hops uses stock Dory only — it does not create the cluster for you.)"; assert!(msg.contains("Dory app")); assert!(!msg.contains("feat/scriptable")); diff --git a/src/commands/local/backend/kind.rs b/src/commands/local/backend/kind.rs index 64dacce..df2f891 100644 --- a/src/commands/local/backend/kind.rs +++ b/src/commands/local/backend/kind.rs @@ -11,7 +11,7 @@ //! On create, hops injects kind `extraMounts` for `$HOME` (same path in the //! node) so Mac worktrees are visible for hostPath delivery when the engine //! can bind-mount host dirs (e.g. Dory). Changing mounts requires recreate -//! (`hops local reset --backend kind`). +//! (`hops local reset --cluster-provider kind --docker-provider dory`). //! //! ## Docker engine selection (spike toward --docker-provider) //! @@ -28,8 +28,42 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; -pub const CLUSTER_NAME: &str = "hops"; -const NODE_CONTAINER: &str = "hops-control-plane"; +/// Default kind cluster name (and historical hard-coded value). +pub const DEFAULT_CLUSTER_NAME: &str = "hops"; +const KIND_CLUSTER_NAME_ENV: &str = "HOPS_KIND_CLUSTER_NAME"; + +/// Active hops kind cluster name (`kind create --name`). +pub fn active_cluster_name() -> String { + std::env::var(KIND_CLUSTER_NAME_ENV) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_CLUSTER_NAME.to_string()) +} + +/// Set active kind cluster name for this process (and kind create/delete). +pub fn set_active_cluster_name(name: &str) { + let n = name.trim(); + if n.is_empty() { + std::env::remove_var(KIND_CLUSTER_NAME_ENV); + } else { + std::env::set_var(KIND_CLUSTER_NAME_ENV, n); + } +} + +/// kubeconfig context kind creates for the active name (`kind-`). +pub fn kube_context_name() -> String { + format!("kind-{}", active_cluster_name()) +} + +/// Docker container name for the control-plane node. +pub fn node_container_name() -> String { + format!("{}-control-plane", active_cluster_name()) +} + +// Compatibility: older call sites used constants. +#[allow(dead_code)] +const CLUSTER_NAME: &str = DEFAULT_CLUSTER_NAME; /// kind node images before v0.27.0 ship containerd 1.x without certs.d /// `config_path` enabled, so our hosts.toml files would be ignored. @@ -76,14 +110,14 @@ impl NodeMountReport { pub fn summary(&self) -> String { match self { NodeMountReport::NoKindNode => { - "kind node not running (start/reset --backend kind for hostPath mounts)".into() + "kind node not running (start/reset with --cluster-provider kind for hostPath mounts)".into() } NodeMountReport::NoMountRoot => "no HOME/projects root to mount".into(), NodeMountReport::Visible { path } => { format!("hostPath capable — kind node sees {path}") } NodeMountReport::Missing { path } => format!( - "kind node missing mount {path}; run `hops local reset --backend kind` to apply extraMounts" + "kind node missing mount {path}; run `hops local reset --cluster-provider kind --docker-provider dory` to apply extraMounts" ), } } @@ -95,7 +129,8 @@ impl NodeMountReport { /// Whether the kind control-plane container is present on the resolved docker engine. pub fn kind_node_present() -> bool { - docker_output(&["inspect", "-f", "{{.Id}}", NODE_CONTAINER]).is_ok() + let node = node_container_name(); + docker_output(&["inspect", "-f", "{{.Id}}", &node]).is_ok() } /// Probe the kind node for the default projects-root mount (same path as create). @@ -116,7 +151,8 @@ pub fn report_projects_root_on_kind_node() -> NodeMountReport { /// `docker exec` test -d on the kind node (shared by create verify + doctor). pub fn node_sees_path(path: &str) -> bool { - docker_output(&["exec", NODE_CONTAINER, "test", "-d", path]).is_ok() + let node = node_container_name(); + docker_output(&["exec", &node, "test", "-d", path]).is_ok() } /// Build the kind cluster config YAML. @@ -152,12 +188,31 @@ nodes: } /// Host directory to bind into the kind node for hostPath delivery. -/// Default: `$HOME` when it is an existing directory. +/// +/// Precedence: +/// 1. `HOPS_KIND_EXTRA_MOUNT` (absolute path) +/// 2. `$HOME/dev` when it exists (narrower than full home — avoids kube-proxy +/// EMFILE from watching huge home trees on Mac/Dory) +/// 3. `$HOME` when it is a directory pub fn default_extra_mount_root() -> Option { + if let Ok(raw) = std::env::var("HOPS_KIND_EXTRA_MOUNT") { + let p = PathBuf::from(raw.trim()); + if p.is_dir() { + return Some(p); + } + log::warn!( + "HOPS_KIND_EXTRA_MOUNT={} is not a directory; falling back", + p.display() + ); + } let home = std::env::var_os("HOME")?; - let p = PathBuf::from(home); - if p.is_dir() { - Some(p) + let home = PathBuf::from(home); + let dev = home.join("dev"); + if dev.is_dir() { + return Some(dev); + } + if home.is_dir() { + Some(home) } else { None } @@ -242,7 +297,7 @@ pub fn uninstall() -> Result<(), Box> { pub fn start(size: &SizeArgs) -> Result<(), Box> { if size.any_set() { return Err(format!( - "the kind backend has no VM to size; drop{} (resources are governed by the docker daemon kind runs on)", + "the kind cluster provider has no VM to size; drop{} (resources are governed by the selected Docker provider)", size.command_suffix() ) .into()); @@ -254,29 +309,33 @@ pub fn start(size: &SizeArgs) -> Result<(), Box> { return create_cluster(); } + let name = active_cluster_name(); + let node = node_container_name(); if node_running() { - log::info!("kind cluster '{}' is already running", CLUSTER_NAME); + log::info!("kind cluster '{name}' is already running"); log_mount_hint(); return Ok(()); } // kind has no start/stop; the node is a docker container. Restarting a // single-node cluster is reliable in practice but not guaranteed by kind. - log::info!("Starting stopped kind node '{}'...", NODE_CONTAINER); - docker_run(&["start", NODE_CONTAINER])?; + log::info!("Starting stopped kind node '{node}'..."); + docker_run(&["start", &node])?; wait_for_api_after_restart() } pub fn stop() -> Result<(), Box> { - log::info!("Stopping kind node '{}'...", NODE_CONTAINER); - docker_run(&["stop", NODE_CONTAINER])?; + let node = node_container_name(); + log::info!("Stopping kind node '{node}'..."); + docker_run(&["stop", &node])?; log::info!("kind cluster stopped"); Ok(()) } pub fn destroy() -> Result<(), Box> { - log::info!("Deleting kind cluster '{}'...", CLUSTER_NAME); - let status = kind_cmd(&["delete", "cluster", "--name", CLUSTER_NAME]) + let name = active_cluster_name(); + log::info!("Deleting kind cluster '{name}'..."); + let status = kind_cmd(&["delete", "cluster", "--name", &name]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -319,13 +378,15 @@ pub fn cluster_exists() -> bool { if !output.status.success() { return false; } + let name = active_cluster_name(); String::from_utf8_lossy(&output.stdout) .lines() - .any(|line| line.trim() == CLUSTER_NAME) + .any(|line| line.trim() == name) } fn node_running() -> bool { - docker_output(&["inspect", "-f", "{{.State.Running}}", NODE_CONTAINER]) + let node = node_container_name(); + docker_output(&["inspect", "-f", "{{.State.Running}}", &node]) .map(|out| out.trim() == "true") .unwrap_or(false) } @@ -333,7 +394,7 @@ fn node_running() -> bool { fn preflight() -> Result<(), Box> { if !command_exists("kind") { return Err( - "kind is not installed; run `hops local install --backend kind` or `brew install kind`" + "kind is not installed; run `hops local install --cluster-provider kind --docker-provider docker` or `brew install kind`" .into(), ); } @@ -383,21 +444,20 @@ fn create_cluster() -> Result<(), Box> { package push may need the same port)" ); } + let name = active_cluster_name(); if let Some(ref m) = mount { log::info!( - "Creating kind cluster '{}' with extraMounts {} → {} (hostPath delivery)...", - CLUSTER_NAME, + "Creating kind cluster '{name}' with extraMounts {} → {} (hostPath delivery)...", m.display(), m.display() ); } else { log::info!( - "Creating kind cluster '{}' (no HOME mount; hostPath delivery may fall back to sync)...", - CLUSTER_NAME + "Creating kind cluster '{name}' (no HOME mount; hostPath delivery may fall back to sync)..." ); } - let mut child = kind_cmd(&["create", "cluster", "--name", CLUSTER_NAME, "--config", "-"]) + let mut child = kind_cmd(&["create", "cluster", "--name", &name, "--config", "-"]) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -413,9 +473,21 @@ fn create_cluster() -> Result<(), Box> { if let Some(ref m) = mount { verify_node_mount(m)?; } + // Raise inotify limits: mounting large host trees (even $HOME/dev) can make + // kube-proxy fail with "too many open files" under default instance caps. + raise_node_inotify_limits(); Ok(()) } +fn raise_node_inotify_limits() { + let node = node_container_name(); + let script = "sysctl -w fs.inotify.max_user_instances=8192 fs.inotify.max_user_watches=1048576 >/dev/null 2>&1 || true"; + match docker_output(&["exec", &node, "sh", "-c", script]) { + Ok(_) => log::info!("raised kind node inotify limits for host mounts"), + Err(e) => log::debug!("inotify sysctl skipped: {e}"), + } +} + fn verify_node_mount(host_path: &Path) -> Result<(), Box> { let path_str = host_path.display().to_string(); if node_sees_path(&path_str) { @@ -477,8 +549,8 @@ fn write_hosts_toml(registry_name: &str, cluster_ip: &str) -> Result<(), Box Result<(), Box '{}'", dir, path), @@ -566,6 +638,18 @@ mod tests { assert!(!NodeMountReport::NoKindNode.is_hostpath_capable()); } + #[test] + fn named_cluster_drives_context_and_node_container() { + set_active_cluster_name("dogfood"); + assert_eq!(active_cluster_name(), "dogfood"); + assert_eq!(kube_context_name(), "kind-dogfood"); + assert_eq!(node_container_name(), "dogfood-control-plane"); + set_active_cluster_name("hops"); + assert_eq!(kube_context_name(), "kind-hops"); + set_active_cluster_name(""); + assert_eq!(active_cluster_name(), DEFAULT_CLUSTER_NAME); + } + #[test] fn kind_config_includes_extra_mounts_for_host_path() { let cfg = build_kind_config(Some(Path::new("/Users/test")), 30500); @@ -636,4 +720,3 @@ mod tests { assert!(cfg.contains("/home/ci")); } } - diff --git a/src/commands/local/backend/mod.rs b/src/commands/local/backend/mod.rs index b00f2b1..a1753be 100644 --- a/src/commands/local/backend/mod.rs +++ b/src/commands/local/backend/mod.rs @@ -7,6 +7,12 @@ mod colima; mod dory; pub(crate) mod kind; +pub mod providers; + +pub use providers::{ + apply_docker_provider_env, load_persisted_providers, persist_providers, resolve_provider_pair, + ClusterProvider, DockerProvider, +}; use super::{local_state_dir, run_cmd_output, HOPS_KUBE_CONTEXT_ENV}; use clap::Args; @@ -81,7 +87,7 @@ impl Backend { pub fn kube_context(self) -> String { match self { Backend::Colima => "colima".to_string(), - Backend::Kind => "kind-hops".to_string(), + Backend::Kind => kind::kube_context_name(), // Merged into ~/.kube/config (default name hops-dory; see dory::context_name). Backend::Dory => dory::context_name(), } @@ -180,9 +186,9 @@ impl Backend { } } - /// Backend-specific package registry for local provider/config installs. - /// All backends use an in-cluster NodePort registry for Crossplane package - /// pulls (pod network). Host push is always localhost:30500. + /// Cluster-provider-specific package registry for local provider/config installs. + /// Every cluster provider uses an in-cluster NodePort registry for Crossplane + /// package pulls (pod network). pub fn ensure_package_registry(self) -> Result<(), Box> { crate::commands::local::package_install::ensure_incluster_registry() } @@ -199,13 +205,18 @@ impl Backend { Backend::Dory => dory::registry_push_addr().unwrap_or_else(|_| { crate::commands::local::package_install::REGISTRY_PUSH.to_string() }), - Backend::Colima | Backend::Kind => { - crate::commands::local::package_install::REGISTRY_PUSH.to_string() - } + // Use explicit IPv4. Dory's dockerd can resolve `localhost` to ::1, + // where its published kind port terminates the TLS request with EOF. + Backend::Kind => kind_registry_push(kind::registry_host_port()), + Backend::Colima => crate::commands::local::package_install::REGISTRY_PUSH.to_string(), } } } +fn kind_registry_push(host_port: u16) -> String { + format!("127.0.0.1:{host_port}") +} + impl fmt::Display for Backend { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.name()) @@ -221,7 +232,7 @@ impl FromStr for Backend { "kind" => Ok(Backend::Kind), "dory" => Ok(Backend::Dory), other => Err(format!( - "unknown backend '{}' (expected colima, kind, or dory)", + "unknown persisted cluster provider '{}' (expected colima, kind, or dory)", other )), } @@ -257,6 +268,36 @@ pub fn resolve(flag: Option) -> Backend { ) } +/// Resolve the backend adapter from the optional provider pair, then activate. +pub fn activate_with_providers( + cluster_provider: Option, + docker_provider: Option, + cluster_name: Option<&str>, + context: Option<&str>, +) -> Result> { + if let Some(name) = cluster_name.map(str::trim).filter(|s| !s.is_empty()) { + kind::set_active_cluster_name(name); + } else if let Some(persisted) = load_persisted_providers() { + if let Some(name) = persisted.cluster_name.as_deref() { + kind::set_active_cluster_name(name); + } + } + + let pair = resolve_provider_pair(cluster_provider, docker_provider)?; + let backend = match pair { + Some(p) => { + apply_docker_provider_env(p.docker)?; + let cname = kind::active_cluster_name(); + let _ = persist_providers(p, Some(cname.as_str())); + p.as_backend() + } + None => resolve(None), + }; + + // Kind + default docker provider still auto-picks dory.sock inside kind module. + Ok(activate(Some(backend), context)) +} + /// Resolve the backend once and activate the kube-targeting environment for /// child kubectl/helm processes. pub fn activate(flag: Option, context: Option<&str>) -> Backend { @@ -283,7 +324,7 @@ pub fn activate(flag: Option, context: Option<&str>) -> Backend { KubeContextExport::Unset { missing_context } => { std::env::remove_var(HOPS_KUBE_CONTEXT_ENV); log::warn!( - "Kubernetes context '{}' for backend '{}' was not found; using kubeconfig current-context. Pass --context to target a specific cluster.", + "Kubernetes context '{}' for cluster provider '{}' was not found; using kubeconfig current-context. Pass --context to target a specific cluster.", missing_context, backend.name() ); @@ -374,11 +415,11 @@ pub fn wire_local_registry(backend: Backend) -> Result<(), Box> { } pub fn should_wire_local_registry( - backend_flag: Option, + provider_selected: bool, context: Option<&str>, backend: Backend, ) -> bool { - if backend_flag.is_some() { + if provider_selected { return true; } @@ -390,15 +431,15 @@ pub fn should_wire_local_registry( pub fn wire_local_registry_for_target( backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { - if should_wire_local_registry(backend_flag, context, backend) { + if should_wire_local_registry(provider_selected, context, backend) { return wire_local_registry(backend); } log::warn!( - "registry node wiring skipped: explicit --context does not match a selected backend" + "registry node wiring skipped: explicit --context does not match the selected cluster provider" ); Ok(()) } @@ -545,13 +586,13 @@ mod tests { #[test] fn registry_wiring_allowed_without_explicit_context() { - assert!(should_wire_local_registry(None, None, Backend::Colima)); + assert!(should_wire_local_registry(false, None, Backend::Colima)); } #[test] - fn registry_wiring_skips_foreign_explicit_context_without_backend_flag() { + fn registry_wiring_skips_foreign_explicit_context_without_provider_selection() { assert!(!should_wire_local_registry( - None, + false, Some("kind-hops"), Backend::Colima )); @@ -560,18 +601,23 @@ mod tests { #[test] fn registry_wiring_allowed_when_context_matches_backend() { assert!(should_wire_local_registry( - None, + false, Some("kind-hops"), Backend::Kind )); } #[test] - fn registry_wiring_allowed_when_backend_is_explicit() { + fn registry_wiring_allowed_when_provider_is_explicit() { assert!(should_wire_local_registry( - Some(Backend::Colima), + true, Some("foreign"), Backend::Colima )); } + + #[test] + fn kind_registry_push_uses_ipv4_loopback() { + assert_eq!(kind_registry_push(30501), "127.0.0.1:30501"); + } } diff --git a/src/commands/local/backend/providers.rs b/src/commands/local/backend/providers.rs new file mode 100644 index 0000000..cec6ef6 --- /dev/null +++ b/src/commands/local/backend/providers.rs @@ -0,0 +1,265 @@ +//! Cluster-provider vs docker-provider (LWB-REQ-260…263). +//! +//! Pure resolution of the two provider dimensions. Side effects (persist, +//! DOCKER_HOST) stay in the backend lifecycle layer. + +use super::Backend; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fmt; +use std::str::FromStr; + +/// How Kubernetes nodes are provisioned. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ClusterProvider { + /// hops-managed kind node(s) (+ extraMounts). + Kind, + /// Stock Dory product k3s (`dory-k8s`). + Dory, + /// Colima embedded k3s. + Colima, +} + +/// Container engine kind/tools talk to. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DockerProvider { + /// Dory engine (`~/.dory/dory.sock`). + Dory, + /// Colima docker. + Colima, + /// Default / DOCKER_HOST / docker context. + Docker, +} + +impl ClusterProvider { + pub fn as_str(self) -> &'static str { + match self { + ClusterProvider::Kind => "kind", + ClusterProvider::Dory => "dory", + ClusterProvider::Colima => "colima", + } + } +} + +impl DockerProvider { + pub fn as_str(self) -> &'static str { + match self { + DockerProvider::Dory => "dory", + DockerProvider::Colima => "colima", + DockerProvider::Docker => "docker", + } + } +} + +impl fmt::Display for ClusterProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Display for DockerProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ClusterProvider { + type Err = String; + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "kind" => Ok(ClusterProvider::Kind), + "dory" => Ok(ClusterProvider::Dory), + "colima" => Ok(ClusterProvider::Colima), + other => Err(format!( + "unknown cluster-provider '{other}' (expected kind, dory, colima)" + )), + } + } +} + +impl FromStr for DockerProvider { + type Err = String; + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "dory" => Ok(DockerProvider::Dory), + "colima" => Ok(DockerProvider::Colima), + "docker" | "default" => Ok(DockerProvider::Docker), + other => Err(format!( + "unknown docker-provider '{other}' (expected dory, colima, docker)" + )), + } + } +} + +/// Resolved pair for lifecycle + engine targeting. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPair { + pub cluster: ClusterProvider, + pub docker: DockerProvider, +} + +impl ProviderPair { + /// Preferred Mac hostPath path: kind nodes on Dory engine. + pub fn kind_on_dory() -> Self { + ProviderPair { + cluster: ClusterProvider::Kind, + docker: DockerProvider::Dory, + } + } + + /// Lifecycle backend enum used by existing start/stop/install paths. + pub fn as_backend(self) -> Backend { + match self.cluster { + ClusterProvider::Kind => Backend::Kind, + ClusterProvider::Dory => Backend::Dory, + ClusterProvider::Colima => Backend::Colima, + } + } + + /// Reject impossible combinations (product dory k8s only on dory engine). + pub fn validate(self) -> Result<(), String> { + match (self.cluster, self.docker) { + (ClusterProvider::Kind, _) => Ok(()), + (ClusterProvider::Dory, DockerProvider::Dory) => Ok(()), + (ClusterProvider::Colima, DockerProvider::Colima) => Ok(()), + (ClusterProvider::Dory, other) => Err(format!( + "cluster-provider dory requires docker-provider dory (got {other})" + )), + (ClusterProvider::Colima, other) => Err(format!( + "cluster-provider colima requires docker-provider colima (got {other})" + )), + } + } +} + +/// Resolve providers from CLI flags. +/// +/// Precedence: +/// 1. Explicit `--cluster-provider` / `--docker-provider` (pair, with defaults for missing half) +/// 2. `None` → caller uses persisted provider/backend state or detection +pub fn resolve_provider_pair( + cluster_provider: Option, + docker_provider: Option, +) -> Result, Box> { + if cluster_provider.is_none() && docker_provider.is_none() { + return Ok(None); + } + + // When either provider is set, fill the missing half from platform defaults. + let base = if cfg!(target_os = "macos") { + ProviderPair::kind_on_dory() + } else { + ProviderPair { + cluster: ClusterProvider::Kind, + docker: DockerProvider::Docker, + } + }; + + let pair = ProviderPair { + cluster: cluster_provider.unwrap_or(base.cluster), + docker: docker_provider.unwrap_or(base.docker), + }; + pair.validate()?; + Ok(Some(pair)) +} + +/// Apply docker-provider to process env for kind (and docker CLI). +/// +/// - `dory`: set DOCKER_HOST to `unix://$HOME/.dory/dory.sock` when unset +/// - `colima`: leave alone (colima context usually already selected) +/// - `docker`: leave alone +pub fn apply_docker_provider_env(dp: DockerProvider) -> Result<(), Box> { + match dp { + DockerProvider::Dory => { + if std::env::var_os("DOCKER_HOST").is_none() { + let home = std::env::var("HOME").map_err(|_| "HOME is not set")?; + let sock = std::path::Path::new(&home).join(".dory/dory.sock"); + if sock.exists() { + let host = format!("unix://{}", sock.display()); + log::info!("docker-provider dory: DOCKER_HOST={host}"); + std::env::set_var("DOCKER_HOST", host); + } else { + return Err(format!( + "docker-provider dory: socket {} missing; open the Dory app", + sock.display() + ) + .into()); + } + } + Ok(()) + } + DockerProvider::Colima | DockerProvider::Docker => Ok(()), + } +} + +const PROVIDERS_FILE: &str = "providers.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PersistedProviders { + pub cluster_provider: String, + pub docker_provider: String, + #[serde(default)] + pub cluster_name: Option, +} + +pub fn persist_providers( + pair: ProviderPair, + cluster_name: Option<&str>, +) -> Result<(), Box> { + let dir = super::super::local_state_dir()?; + std::fs::create_dir_all(&dir)?; + let rec = PersistedProviders { + cluster_provider: pair.cluster.as_str().to_string(), + docker_provider: pair.docker.as_str().to_string(), + cluster_name: cluster_name.map(|s| s.to_string()), + }; + let path = dir.join(PROVIDERS_FILE); + std::fs::write(path, serde_json::to_string_pretty(&rec)?)?; + // Keep legacy backend file in sync for older code paths. + super::persist(pair.as_backend())?; + Ok(()) +} + +pub fn load_persisted_providers() -> Option { + let path = super::super::local_state_dir().ok()?.join(PROVIDERS_FILE); + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kind_on_dory_is_valid() { + let p = ProviderPair::kind_on_dory(); + p.validate().unwrap(); + assert_eq!(p.as_backend(), Backend::Kind); + } + + #[test] + fn dory_cluster_rejects_non_dory_docker() { + let p = ProviderPair { + cluster: ClusterProvider::Dory, + docker: DockerProvider::Docker, + }; + assert!(p.validate().is_err()); + } + + #[test] + fn resolve_cp_dp_without_backend() { + let p = resolve_provider_pair(Some(ClusterProvider::Kind), Some(DockerProvider::Dory)) + .unwrap() + .unwrap(); + assert_eq!(p, ProviderPair::kind_on_dory()); + } + + #[test] + fn resolve_neither_returns_none() { + assert!(resolve_provider_pair(None, None).unwrap().is_none()); + } +} diff --git a/src/commands/local/doctor.rs b/src/commands/local/doctor.rs index 3ab9392..35437bc 100644 --- a/src/commands/local/doctor.rs +++ b/src/commands/local/doctor.rs @@ -123,18 +123,10 @@ fn check_kind_hostpath_mount(d: &mut Doctor) { match &report { NodeMountReport::NoKindNode => { // Informational: doctor still passes if Crossplane checks pass. - d.check( - "kind node projects-root mount", - true, - report.summary(), - ); + d.check("kind node projects-root mount", true, report.summary()); } NodeMountReport::NoMountRoot => { - d.check( - "kind node projects-root mount", - true, - report.summary(), - ); + d.check("kind node projects-root mount", true, report.summary()); } NodeMountReport::Visible { .. } => { d.check( diff --git a/src/commands/local/down.rs b/src/commands/local/down.rs index 26b6dd6..5408c0c 100644 --- a/src/commands/local/down.rs +++ b/src/commands/local/down.rs @@ -1,9 +1,10 @@ //! `hops local down` — stop workspace host access, delivery, and optionally purge namespace. -use super::up::stop_delivery_runtime; +use super::workbench::delivery::stop_delivery_runtime; use super::workbench::net::stop_host_access; use super::workbench::registry::{ - list_workspaces, load_workspace, namespace_for_name, remove_workspace, + activate_workspace_cluster, list_workspaces, load_workspace, namespace_for_name, + remove_workspace, }; use super::{local_state_dir, run_cmd}; use clap::Args; @@ -28,11 +29,7 @@ pub fn run(args: &DownArgs) -> Result<(), Box> { 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("No workspaces registered. Pass --name explicitly.".into()), _ => { return Err(format!( "Multiple workspaces registered ({}); pass --name .", @@ -53,9 +50,15 @@ pub fn run(args: &DownArgs) -> Result<(), Box> { .map(|r| r.namespace.clone()) .unwrap_or_else(|| namespace_for_name(&name)); + if let Some(ref rec) = record { + if let Some((cluster, ctx)) = activate_workspace_cluster(rec) { + log::info!("Using bound cluster `{cluster}` (context {ctx})"); + } + } + log::info!("Bringing down workspace `{name}` (namespace {namespace})"); - // Stop host access processes started by `up` (recorded PIDs + pkill safety net) + // Stop recorded host-access processes (recorded PIDs + pkill safety net). if let Err(e) = stop_host_access(&state_dir, &name) { log::warn!("host access stop: {e}"); } diff --git a/src/commands/local/gitops.rs b/src/commands/local/gitops.rs index 2dc3f0f..0973053 100644 --- a/src/commands/local/gitops.rs +++ b/src/commands/local/gitops.rs @@ -7,13 +7,19 @@ //! //! Both **watch by default**; pass `--once` for a single reconcile (CI/scripts). +use super::local_state_dir; 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::delivery::{ + attach_sync_delivery, discover_sync_targets, save_delivery_runtime, stop_delivery_runtime, + DeliveryStrategy, NodePathProber, SystemNodeProber, +}; use super::workbench::reconcile::{ reconcile_applications, ReconcileOptions, SystemHelm, SystemKubectl, }; +use super::workbench::registry::{activate_workspace_cluster, load_workspace}; use super::workbench::watch::{ is_chart_or_env_path, should_ignore_watch_path, watch_roots_for_applications, WatchPathClass, }; @@ -109,7 +115,7 @@ pub fn run_cluster(args: &ClusterArgs) -> Result<(), Box> { 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" + Ensure the selected control plane is Ready, then run `hops local start` with matching --cluster-provider and --docker-provider values." ) .into()); } @@ -178,21 +184,34 @@ fn run_worktree(args: &WorktreeArgs) -> Result<(), Box> { .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); + // Sticky workspace→cluster: use bound kube context when registered. + if let Ok(state_dir) = local_state_dir() { + if let Ok(Some(rec)) = load_workspace(&state_dir, &workspace_name) { + if let Some((cluster, ctx)) = activate_workspace_cluster(&rec) { + log::info!("worktree gitops: bound cluster `{cluster}` (context {ctx})"); } } } + let mut app_delivery_host_paths = BTreeMap::new(); + for (app_file, app) in load_applications(&env_path)? { + let host = resolve_delivery_host_path(&app_file, &app)?; + app_delivery_host_paths.insert(app.metadata.name, host); + } + let (delivery_strategy, delivery_detail) = + resolve_worktree_delivery(&app_delivery_host_paths, &SystemNodeProber)?; + log::info!( + "worktree gitops: source delivery {} ({})", + delivery_strategy.as_str(), + delivery_detail + ); + let opts = ReconcileOptions { namespace: namespace.clone(), workspace_name: workspace_name.clone(), runtime_values: BTreeMap::new(), app_delivery_host_paths, - delivery_mode: Some("sync".into()), + delivery_mode: Some(delivery_strategy.as_str().into()), dry_run: args.dry_run, }; @@ -211,6 +230,38 @@ fn run_worktree(args: &WorktreeArgs) -> Result<(), Box> { if r.applied { "applied" } else { "rendered" } ); } + + if !opts.dry_run { + let state_dir = local_state_dir()?; + // A previous run may have fallen back to a detached tar/mutagen + // sync runtime. Retire it even when the current probe selects + // hostPath, otherwise that stale writer keeps replacing files in + // the mounted tree and repeatedly restarts dev servers. + stop_delivery_runtime(&state_dir, &workspace_name); + + if delivery_strategy != DeliveryStrategy::Sync { + return Ok(()); + } + + let targets = wait_for_sync_targets( + &opts.namespace, + &workspace_name, + "/workspace", + &opts.app_delivery_host_paths, + 90, + ); + let attached = + attach_sync_delivery(&targets, &workspace_name, !args.once && !args.dry_run)?; + save_delivery_runtime( + &state_dir, + &workspace_name, + &attached.mutagen_sessions, + &attached.sync_pids, + )?; + for message in attached.messages { + log::info!("delivery: {message}"); + } + } Ok(()) }; @@ -222,6 +273,52 @@ fn run_worktree(args: &WorktreeArgs) -> Result<(), Box> { run_worktree_watch(&env_path, args.debounce, do_once) } +fn resolve_worktree_delivery( + app_paths: &BTreeMap, + prober: &dyn NodePathProber, +) -> Result<(DeliveryStrategy, String), Box> { + if app_paths.is_empty() { + return Err("worktree gitops found no Application source paths".into()); + } + + let mut all_visible = true; + let mut details = Vec::new(); + for (app, host) in app_paths { + let probe = prober.probe(host)?; + all_visible &= probe.host_path_visible; + details.push(format!("{app}: {}", probe.detail)); + } + + let strategy = if all_visible { + DeliveryStrategy::HostPath + } else { + DeliveryStrategy::Sync + }; + Ok((strategy, details.join("; "))) +} + +fn wait_for_sync_targets( + namespace: &str, + workspace: &str, + mount_path: &str, + app_hosts: &BTreeMap, + timeout_secs: u64, +) -> Vec { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + match discover_sync_targets(namespace, workspace, mount_path, app_hosts) { + Ok(targets) if !targets.is_empty() => return targets, + Ok(_) => {} + Err(error) => log::debug!("sync target discovery: {error}"), + } + if Instant::now() >= deadline { + return discover_sync_targets(namespace, workspace, mount_path, app_hosts) + .unwrap_or_default(); + } + std::thread::sleep(Duration::from_secs(1)); + } +} + fn run_worktree_watch( env_path: &Path, debounce_secs: u64, @@ -234,11 +331,7 @@ where 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 chart_paths: Vec = roots.iter().filter(|p| *p != &env_canon).cloned().collect(); let debounce = Duration::from_secs(debounce_secs); let (tx, rx) = mpsc::channel(); diff --git a/src/commands/local/gitops_write.rs b/src/commands/local/gitops_write.rs index e4076e8..725eba7 100644 --- a/src/commands/local/gitops_write.rs +++ b/src/commands/local/gitops_write.rs @@ -51,10 +51,7 @@ pub fn write_gitops_files( // Document the secrets gap next to written providers. let readme = root.join("SECRETS.md"); if !readme.exists() { - fs::write( - &readme, - SECRETS_README, - )?; + fs::write(&readme, SECRETS_README)?; written.push(readme); } Ok(written) @@ -69,7 +66,7 @@ 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). +1. Apply this tree with `hops local gitops cluster`. 2. Create live secrets with: - `hops local aws` / `github` / `zitadel` (without relying on git for credentials) - or a future `hops local secrets sync` diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index bbd92d6..11e3fa3 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -18,7 +18,6 @@ mod status; mod stop; mod uninstall; pub mod workbench; -mod up; mod zitadel; use clap::{Args, Subcommand}; @@ -102,43 +101,48 @@ pub struct LocalArgs { pub command: LocalCommands, /// Kubernetes context to use for all kubectl commands (e.g. "colima"). - /// Defaults to the resolved backend's own context. Global: applies to + /// Defaults to the selected cluster provider's context. Global: applies to /// every `hops local` subcommand and may be given before or after the /// subcommand. #[arg(long, global = true)] pub context: Option, - /// Local cluster backend to target. Defaults to the backend persisted by - /// the last successful `hops local start`, else an existing cluster if - /// one is detected, else the platform default (macOS: colima, otherwise - /// kind). - #[arg(long, global = true, value_enum)] - pub backend: Option, + /// How Kubernetes nodes are provisioned: `kind`, `dory` (product k3s), `colima`. + /// Preferred Mac hostPath path: `--cluster-provider kind --docker-provider dory`. + #[arg(long = "cluster-provider", global = true, value_enum)] + pub cluster_provider: Option, + + /// Container engine for kind/tools: `dory`, `colima`, `docker`. + #[arg(long = "docker-provider", global = true, value_enum)] + pub docker_provider: Option, + + /// Named hops-managed kind cluster (`kind create --name`). Default `hops` + /// → kube context `kind-hops`. Distinct from workspace `--name`. + #[arg(long = "cluster-name", global = true, value_name = "NAME")] + pub cluster_name: Option, /// Dory desktop integration name (kube context + docker context). /// Defaults to `hops-dory`. Persisted under `~/.hops/local/dory-name`. - /// Only used with `--backend dory` (or a persisted dory backend). + /// Only used with cluster-provider dory. /// /// Named `--dory-name` (not `--name`) so it never collides with workspace - /// `--name` on `hops local up|down|status|open|gitops worktree`. + /// `--name` on `hops local down|status|open|gitops worktree`. #[arg(long = "dory-name", global = true, value_name = "NAME")] pub dory_name: Option, } #[derive(Subcommand, Debug)] pub enum LocalCommands { - /// Install the local cluster backend (colima or kind) via Homebrew + /// Install local cluster-provider tools via Homebrew Install, /// Reset local Kubernetes state (colima: k8s reset; kind: recreate cluster) Reset, /// Start local k8s and ensure Crossplane control plane (skips helm when already healthy) Start(start::StartArgs), - /// Resize the local cluster VM without destroying cluster state (colima only) + /// Resize the local cluster VM without destroying cluster state (colima cluster provider only) 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 @@ -161,7 +165,7 @@ pub enum LocalCommands { Stop, /// Destroy the local cluster Destroy, - /// Uninstall the local cluster backend + /// Uninstall local cluster-provider tools Uninstall(uninstall::UninstallArgs), } @@ -176,12 +180,12 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { } let explicit_context = args.context.as_deref().filter(|ctx| !ctx.is_empty()); - let install_backend = matches!(&args.command, LocalCommands::Install) - .then(|| args.backend.unwrap_or_else(backend::platform_default)); - let activation_flag = install_backend.or(args.backend); - let install_context = install_backend.map(|b| b.kube_context()); - let activation_context = explicit_context.or(install_context.as_deref()); - let backend = backend::activate(activation_flag, activation_context); + let backend = backend::activate_with_providers( + args.cluster_provider, + args.docker_provider, + args.cluster_name.as_deref(), + explicit_context, + )?; match &args.command { LocalCommands::Install => install::run(backend), @@ -189,7 +193,6 @@ 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), @@ -441,8 +444,6 @@ mod tests { } /// Regression: workspace `--name` must not populate Dory's `--dory-name`. - /// Dual workspaces (`up --name alice` then `up --name bob`) used to rewrite - /// the desktop kube/docker context and delete the real `dory` context. #[test] fn workspace_name_does_not_set_dory_name() { use clap::Parser; @@ -456,26 +457,28 @@ mod tests { let parsed = Cli::try_parse_from([ "hops-local-test", - "up", + "gitops", + "worktree", "./gitops/envs/local", "--name", "alice", "--once", - "--no-cluster", ]) - .expect("parse up --name alice"); + .expect("parse gitops worktree --name alice"); assert!( parsed.local.dory_name.is_none(), "workspace --name must not set dory_name; got {:?}", parsed.local.dory_name ); match parsed.local.command { - LocalCommands::Up(up) => { - assert_eq!(up.name.as_deref(), Some("alice")); - assert!(up.once); - assert!(up.no_cluster); - } - other => panic!("expected Up, got {other:?}"), + LocalCommands::Gitops(gitops) => match gitops.command { + gitops::GitopsCommands::Worktree(worktree) => { + assert_eq!(worktree.name.as_deref(), Some("alice")); + assert!(worktree.once); + } + other => panic!("expected gitops worktree, got {other:?}"), + }, + other => panic!("expected Gitops, got {other:?}"), } } @@ -494,16 +497,22 @@ mod tests { "hops-local-test", "--dory-name", "mine", - "up", + "gitops", + "worktree", "./env", "--name", "bob", ]) - .expect("parse --dory-name mine up --name bob"); + .expect("parse --dory-name mine gitops worktree --name bob"); assert_eq!(parsed.local.dory_name.as_deref(), Some("mine")); match parsed.local.command { - LocalCommands::Up(up) => assert_eq!(up.name.as_deref(), Some("bob")), - other => panic!("expected Up, got {other:?}"), + LocalCommands::Gitops(gitops) => match gitops.command { + gitops::GitopsCommands::Worktree(worktree) => { + assert_eq!(worktree.name.as_deref(), Some("bob")); + } + other => panic!("expected gitops worktree, got {other:?}"), + }, + other => panic!("expected Gitops, got {other:?}"), } } } diff --git a/src/commands/local/open.rs b/src/commands/local/open.rs index e8da13a..974618d 100644 --- a/src/commands/local/open.rs +++ b/src/commands/local/open.rs @@ -1,7 +1,7 @@ //! `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::workbench::registry::{activate_workspace_cluster, list_workspaces, load_workspace}; use super::{command_exists, local_state_dir, run_cmd}; use clap::Args; use std::error::Error; @@ -20,18 +20,13 @@ pub struct OpenArgs { 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.") - })?, + Some(n) => load_workspace(&state_dir, n)? + .ok_or_else(|| format!("Workspace `{n}` is not registered."))?, 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(), - ) - } + [] => return Err("No workspaces registered.".into()), many => { return Err(format!( "Multiple workspaces ({}); pass --name.", @@ -46,6 +41,9 @@ pub fn run(args: &OpenArgs) -> Result<(), Box> { } }; + if let Some((cluster, ctx)) = activate_workspace_cluster(&ws) { + log::debug!("open: bound cluster `{cluster}` (context {ctx})"); + } let services = discover_workspace_endpoints(&ws.namespace).unwrap_or_default(); let plan = plan_host_access(&ws.namespace, &services); @@ -99,5 +97,3 @@ fn open_browser(url: &str) -> Result<(), Box> { println!("Open this URL in your browser: {url}"); Ok(()) } - - diff --git a/src/commands/local/package_install.rs b/src/commands/local/package_install.rs index 1af9b3b..56e9722 100644 --- a/src/commands/local/package_install.rs +++ b/src/commands/local/package_install.rs @@ -16,8 +16,7 @@ const REGISTRY_YAML: &str = include_str!("../../../bootstrap/registry/registry.y pub const REGISTRY_PUSH: &str = "localhost:30500"; /// Cluster-internal address used in Crossplane package references (all backends). -pub const REGISTRY_PULL_INCLUSTER: &str = - "registry.crossplane-system.svc.cluster.local:5000"; +pub const REGISTRY_PULL_INCLUSTER: &str = "registry.crossplane-system.svc.cluster.local:5000"; pub const REGISTRY_HOSTNAME: &str = "registry.crossplane-system.svc.cluster.local"; /// Back-compat alias — prefer [`registry_pull`] when the backend is known. @@ -256,10 +255,7 @@ fn ensure_registry_tls_secret() -> Result<(), Box> { } log::info!("Generating self-signed TLS for local package registry..."); - let dir = std::env::temp_dir().join(format!( - "hops-registry-tls-{}", - unique_suffix() - )); + let dir = std::env::temp_dir().join(format!("hops-registry-tls-{}", unique_suffix())); fs::create_dir_all(&dir)?; let ca_key = dir.join("ca.key"); let ca_crt = dir.join("ca.crt"); @@ -399,7 +395,10 @@ pub fn ensure_crossplane_trusts_local_registry_ca() -> Result<(), Box ], ) .unwrap_or_default(); - if has_init.split_whitespace().any(|n| n == "hops-merge-registry-ca") { + if has_init + .split_whitespace() + .any(|n| n == "hops-merge-registry-ca") + { return Ok(()); } diff --git a/src/commands/local/start.rs b/src/commands/local/start.rs index 37c6711..d4ba74a 100644 --- a/src/commands/local/start.rs +++ b/src/commands/local/start.rs @@ -255,10 +255,7 @@ fn ensure_registry_ready(backend: backend::Backend) -> Result<(), Box } /// Longer wait used for Crossplane on cold nested-virt runners (~15 minutes). -fn wait_for_deployment_with_diagnostics( - namespace: &str, - name: &str, -) -> Result<(), Box> { +fn wait_for_deployment_with_diagnostics(namespace: &str, name: &str) -> Result<(), Box> { match wait_for_deployment_attempts(namespace, name, 180) { Ok(()) => Ok(()), Err(e) => { @@ -301,10 +298,7 @@ fn wait_for_deployment_attempts( name, i * 5 ); - let _ = run_cmd( - "kubectl", - &["get", "pods", "-n", namespace, "-o", "wide"], - ); + let _ = run_cmd("kubectl", &["get", "pods", "-n", namespace, "-o", "wide"]); } thread::sleep(Duration::from_secs(5)); @@ -313,18 +307,15 @@ fn wait_for_deployment_attempts( } fn dump_namespace_diagnostics(namespace: &str) { - log::error!("Diagnostics for namespace {} after readiness timeout:", namespace); + log::error!( + "Diagnostics for namespace {} after readiness timeout:", + namespace + ); let _ = run_cmd("kubectl", &["get", "pods", "-n", namespace, "-o", "wide"]); let _ = run_cmd("kubectl", &["describe", "pods", "-n", namespace]); let _ = run_cmd( "kubectl", - &[ - "get", - "events", - "-n", - namespace, - "--sort-by=.lastTimestamp", - ], + &["get", "events", "-n", namespace, "--sort-by=.lastTimestamp"], ); let _ = run_cmd("kubectl", &["get", "nodes", "-o", "wide"]); } @@ -443,25 +434,29 @@ mod tests { #[test] fn start_args_bootstrap_defaults_false() { // clap default: bootstrap only when --bootstrap is passed - assert!(!StartArgs { - size: SizeArgs { - cpus: None, - memory: None, - disk: None, - }, - yes: false, - bootstrap: false, - } - .bootstrap); - assert!(StartArgs { - size: SizeArgs { - cpus: None, - memory: None, - disk: None, - }, - yes: false, - bootstrap: true, - } - .bootstrap); + assert!( + !StartArgs { + size: SizeArgs { + cpus: None, + memory: None, + disk: None, + }, + yes: false, + bootstrap: false, + } + .bootstrap + ); + assert!( + StartArgs { + size: SizeArgs { + cpus: None, + memory: None, + disk: None, + }, + yes: false, + bootstrap: true, + } + .bootstrap + ); } } diff --git a/src/commands/local/status.rs b/src/commands/local/status.rs index 007112b..093d890 100644 --- a/src/commands/local/status.rs +++ b/src/commands/local/status.rs @@ -6,7 +6,7 @@ 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::workbench::registry::{activate_workspace_cluster, list_workspaces, load_workspace}; use super::{local_state_dir, run_cmd_output}; use clap::Args; use std::error::Error; @@ -33,12 +33,7 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { 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()) - } + None => return Err(format!("Workspace `{name}` is not registered.").into()), } } else { list_workspaces(&state_dir)? @@ -46,7 +41,7 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { if workspaces.is_empty() { println!("No local workspaces registered."); - println!("Start one with: hops local up [--name ]"); + println!("Apply one with: hops local gitops worktree --name "); return Ok(()); } @@ -55,6 +50,12 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { if i > 0 { println!(); } + if let Some(cn) = ws.cluster_name.as_deref() { + let ctx = ws.kube_context.as_deref().unwrap_or("-"); + println!("cluster: {cn} (context {ctx})"); + } + // Target the workspace's bound cluster before kubectl discovery. + let _ = activate_workspace_cluster(ws); let services = discover_workspace_endpoints(&ws.namespace).unwrap_or_default(); let (plan, healed) = if !args.no_heal && !services.is_empty() { @@ -117,7 +118,7 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { } 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)"); + println!("access processes: not recorded"); } // URL listen summary for --check (cluster FQDN endpoints) @@ -167,7 +168,9 @@ fn discover_pods(namespace: &str) -> Result, Box> { .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()) + if let Some(cs) = item + .pointer("/status/containerStatuses") + .and_then(|v| v.as_array()) { total_containers = cs.len() as u32; for c in cs { @@ -176,7 +179,8 @@ fn discover_pods(namespace: &str) -> Result, Box> { } } } - let ready = phase == "Running" && ready_containers == total_containers && total_containers > 0; + let ready = + phase == "Running" && ready_containers == total_containers && total_containers > 0; out.push(PodStatus { name, phase, @@ -222,9 +226,12 @@ fn delivery_status_line(state_dir: &Path, workspace: &str) -> String { .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()) + 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() + "delivery processes: watcher not running".into() } else { format!( "delivery processes: tar watcher alive (pids {})", diff --git a/src/commands/local/uninstall.rs b/src/commands/local/uninstall.rs index 6cc4a4e..0ee4d0d 100644 --- a/src/commands/local/uninstall.rs +++ b/src/commands/local/uninstall.rs @@ -5,7 +5,7 @@ use std::io::{self, Write}; #[derive(Args, Debug)] pub struct UninstallArgs { - /// Uninstall the backend binary even if its local cluster still exists. + /// Uninstall the cluster-provider binary even if its local cluster still exists. #[arg(long)] pub force: bool, } @@ -36,7 +36,7 @@ where Confirm: FnOnce(Backend) -> Result>, { if !force && cluster_exists(backend) { - return Err("destroy the cluster first: `hops local destroy` (or pass --force to uninstall the backend binary anyway)".into()); + return Err("destroy the cluster first: `hops local destroy` (or pass --force to uninstall the cluster-provider binary anyway)".into()); } if confirm(backend)? { diff --git a/src/commands/local/up.rs b/src/commands/local/up.rs deleted file mode 100644 index 425bd55..0000000 --- a/src/commands/local/up.rs +++ /dev/null @@ -1,628 +0,0 @@ -//! `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 index 699a728..504ffde 100644 --- a/src/commands/local/workbench/application.rs +++ b/src/commands/local/workbench/application.rs @@ -85,8 +85,8 @@ impl Default for SyncPolicy { /// 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}"))?; + 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 {})", @@ -244,8 +244,8 @@ pub fn load_applications(env_path: &Path) -> Result, let mut apps = Vec::new(); for path in entries { - let text = fs::read_to_string(&path) - .map_err(|e| format!("read {}: {e}", path.display()))?; + 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)), @@ -300,7 +300,7 @@ spec: #[test] fn resolve_source_path_relative_to_application_file() { - let app_file = Path::new("/proj/gitops/env/local/api.yaml"); + let app_file = Path::new("/proj/gitops/envs/local/api.yaml"); let resolved = resolve_source_path(app_file, "../../../api/.gitops/deploy").unwrap(); assert_eq!(resolved, PathBuf::from("/proj/api/.gitops/deploy")); } @@ -319,7 +319,7 @@ spec: #[test] fn resolve_delivery_host_path_honors_explicit_override() { - let app_file = Path::new("/proj/gitops/env/local/ui.yaml"); + let app_file = Path::new("/proj/gitops/envs/local/ui.yaml"); let ui = parse_application_yaml( r#" apiVersion: hops.local/v1alpha1 @@ -366,10 +366,7 @@ spec: #[test] fn load_applications_from_directory() { let dir = tempfile_dir("lwb-apps"); - write_file( - &dir.join("api.yaml"), - SAMPLE, - ); + write_file(&dir.join("api.yaml"), SAMPLE); write_file( &dir.join("ui.yaml"), &SAMPLE.replace("e2e-ui-api", "e2e-ui-ui"), diff --git a/src/commands/local/workbench/cluster_dns.rs b/src/commands/local/workbench/cluster_dns.rs index c130cdc..ef4ac4a 100644 --- a/src/commands/local/workbench/cluster_dns.rs +++ b/src/commands/local/workbench/cluster_dns.rs @@ -222,9 +222,7 @@ pub fn sync_alloc_for_namespace( }); 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()); + alloc.bindings.insert(alloc_key(namespace, svc), ip.clone()); } save_ip_alloc(state_dir, &alloc)?; Ok(service_ips) @@ -266,9 +264,9 @@ pub fn dns_os_config_present(hosts_body: &str, loopback_ips: &[String]) -> bool .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()) - }) + loopback_ips + .iter() + .all(|ip| ip.is_empty() || ip == "127.0.0.1" || lo0.contains(ip.as_str())) } fn macos_resolver_present() -> bool { @@ -354,7 +352,7 @@ pub fn apply_privileged_dns_config( 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\ + Re-run `hops local status` and approve the **single** prompt,\n\ or grant passwordless sudo for hops on this machine." ) .into() @@ -379,10 +377,7 @@ pub enum PrivilegedPrompt { /// /// **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> { +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]) @@ -417,8 +412,7 @@ pub fn run_privileged_shell( .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', "; "); - let applescript = - format!("do shell script \"{escaped}\" with administrator privileges"); + let applescript = format!("do shell script \"{escaped}\" with administrator privileges"); let status = Command::new("osascript") .args(["-e", &applescript]) .status() @@ -454,13 +448,10 @@ pub fn ensure_loopback_aliases(ips: &[String]) -> Result<(), Box> { } let mut shell = String::from("true"); for ip in &missing { - shell.push_str(&format!( - " && ifconfig lo0 alias {ip} netmask 0xff000000" - )); + 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() - }) + 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. @@ -552,10 +543,8 @@ mod tests { #[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 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")); diff --git a/src/commands/local/workbench/cluster_gitops.rs b/src/commands/local/workbench/cluster_gitops.rs index 2bb0e46..d51fdaf 100644 --- a/src/commands/local/workbench/cluster_gitops.rs +++ b/src/commands/local/workbench/cluster_gitops.rs @@ -67,7 +67,6 @@ pub fn resolve_cluster_path( /// /// ```text /// gitops/envs/local → sibling gitops/cluster -/// gitops/env/local → sibling gitops/cluster /// some/deep/project → walk up → /gitops/cluster /// /gitops → /gitops/cluster /// ``` @@ -109,10 +108,7 @@ pub fn discover_cluster_path(env_path: &Path) -> Option { 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"), - ] { + for candidate in [cur.join("gitops").join("cluster"), cur.join("cluster")] { if candidate.is_dir() { return Some(candidate); } @@ -185,7 +181,9 @@ pub fn should_apply_manifest(path: &Path) -> bool { 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") + if name.ends_with(".example") + || name.ends_with(".example.yaml") + || name.ends_with(".example.yml") { return false; } @@ -247,9 +245,7 @@ pub fn reconcile_cluster_dir( Ok(()) => { log::info!( " {} {}", - path.strip_prefix(&cluster_path) - .unwrap_or(&path) - .display(), + path.strip_prefix(&cluster_path).unwrap_or(&path).display(), if dry_run { "dry-run" } else { "applied" } ); result.applied.push(path); @@ -313,8 +309,12 @@ pub fn should_reconcile_cluster_change(changed: &Path, cluster_path: &Path) -> b 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()); + 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; } @@ -324,9 +324,7 @@ pub fn should_reconcile_cluster_change(changed: &Path, cluster_path: &Path) -> b .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 + name.ends_with(".yaml") || name.ends_with(".yml") || !changed.exists() // deletion of a prior manifest } #[cfg(test)] @@ -360,11 +358,10 @@ mod tests { .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!( + 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); @@ -385,7 +382,10 @@ mod tests { 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()); + assert_eq!( + found.canonicalize().unwrap(), + cluster.canonicalize().unwrap() + ); let _ = fs::remove_dir_all(&dir); } @@ -444,7 +444,11 @@ mod tests { 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( + &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)); diff --git a/src/commands/local/workbench/delivery.rs b/src/commands/local/workbench/delivery.rs index 7ff8eb8..89f36d5 100644 --- a/src/commands/local/workbench/delivery.rs +++ b/src/commands/local/workbench/delivery.rs @@ -282,8 +282,13 @@ fn try_docker_node_probe(host_path: &Path) -> Result, Box< ))); } Ok(_) => { - // Container exists but path missing → definitive not visible if we hit a real node - if docker_container_running(&container) { + // Path missing: only treat as definitive for containers that match an + // actual node name. Hardcoded fallbacks (`dory-k8s`, `hops-control-plane`) + // may be a *different* cluster (e.g. product dory k3s vs kind-hops) and + // must not short-circuit before trying the real node container. + if docker_container_running(&container) + && node_names.iter().any(|n| n == &container) + { return Ok(Some(probe_from_visibility( host_path, false, @@ -299,12 +304,7 @@ fn try_docker_node_probe(host_path: &Path) -> Result, Box< fn docker_container_running(name: &str) -> bool { Command::new("docker") - .args([ - "inspect", - "-f", - "{{.State.Running}}", - name, - ]) + .args(["inspect", "-f", "{{.State.Running}}", name]) .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) @@ -313,10 +313,7 @@ fn docker_container_running(name: &str) -> bool { } fn try_kubectl_hostpath_probe(host_path: &Path) -> Result> { - let name = format!( - "hops-path-probe-{}", - std::process::id() % 100_000 - ); + 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('"', "\\\""); @@ -390,7 +387,9 @@ spec: "jsonpath={.status.phase}", ]) .output()?; - let phase = String::from_utf8_lossy(&phase_out.stdout).trim().to_string(); + let phase = String::from_utf8_lossy(&phase_out.stdout) + .trim() + .to_string(); // FailedMount appears in events / container statuses let desc = Command::new("kubectl") @@ -596,17 +595,8 @@ pub fn discover_sync_targets( 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()?; + let json = + kubectl_command(&["get", "pods", "-n", namespace, "-l", &label, "-o", "json"]).output()?; if !json.status.success() { return Err(format!( "kubectl get pods failed: {}", @@ -709,6 +699,51 @@ pub fn stop_mutagen_sessions(sessions: &[String]) { } } +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct DeliveryRuntime { + mutagen_sessions: Vec, + sync_pids: Vec, +} + +/// Record delivery processes so the next reconcile or `local down` can stop them. +pub(crate) 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 runtime = DeliveryRuntime { + mutagen_sessions: sessions.to_vec(), + sync_pids: pids.to_vec(), + }; + std::fs::write( + dir.join(format!("{workspace}.delivery.json")), + serde_json::to_string_pretty(&runtime)?, + )?; + Ok(()) +} + +/// Stop delivery processes recorded for a workspace (best-effort). +pub(crate) fn stop_delivery_runtime(state_dir: &Path, workspace: &str) { + let path = state_dir + .join("runtime") + .join(format!("{workspace}.delivery.json")); + 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 _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + } + } + let _ = std::fs::remove_file(path); +} + /// 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. @@ -729,7 +764,10 @@ pub fn attach_sync_delivery( if targets.is_empty() { result .messages - .push("no Running pods to sync into yet; re-run up after pods are Ready".into()); + .push( + "no Running pods to sync into yet; re-run `hops local gitops worktree` after pods are Ready" + .into(), + ); return Ok(result); } @@ -925,13 +963,11 @@ done 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 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) @@ -950,7 +986,7 @@ fn spawn_tar_sync_watcher(targets: Vec) -> Result Result<(), Box> { fs::create_dir_all(state_dir.join(RUNTIME_SUBDIR))?; - fs::write(runtime_path(state_dir, workspace), serde_json::to_string_pretty(rt)?)?; + fs::write( + runtime_path(state_dir, workspace), + serde_json::to_string_pretty(rt)?, + )?; Ok(()) } @@ -179,9 +185,7 @@ pub fn discover_services(namespace: &str) -> Result, Box Result, Box> { +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}"))?; @@ -199,7 +203,11 @@ fn discover_services_in_namespace( if name.is_empty() || name == "kubernetes" { continue; } - for p in item["spec"]["ports"].as_array().cloned().unwrap_or_default() { + 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") { @@ -478,7 +486,9 @@ fn ensure_macos_stub_dns(state_dir: &Path) -> Result<(), Box> { if pid_is_alive(pid) && stub_dns_responds() { return Ok(()); } - let _ = Command::new("kill").args(["-TERM", &pid.to_string()]).status(); + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); } } @@ -609,7 +619,9 @@ if __name__ == '__main__': 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}"); + log::info!( + "macOS stub DNS for *.svc.cluster.local on 127.0.0.1:{MACOS_LOCAL_DNS_PORT} pid={pid}" + ); Ok(()) } @@ -748,10 +760,7 @@ done let pid = child.id(); std::mem::forget(child); - let service_ports: BTreeMap = services - .iter() - .map(|s| (s.key(), s.port)) - .collect(); + let service_ports: BTreeMap = services.iter().map(|s| (s.key(), s.port)).collect(); let runtime = HostAccessRuntime { namespace: plan.namespace.clone(), pids: vec![pid], @@ -966,8 +975,7 @@ fn rebuild_hosts_from_blocks_noprompt( if dns_os_config_present(&merged, &[]) { return Ok(()); } - let tmp = - std::env::temp_dir().join(format!("hops-hosts-down-{}.tmp", std::process::id())); + 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); @@ -1035,9 +1043,7 @@ mod tests { }]; let plan = plan_host_access("dogfood", &svcs); assert_eq!( - plan.urls - .get("dogfood/e2e-ui-ui") - .map(String::as_str), + plan.urls.get("dogfood/e2e-ui-ui").map(String::as_str), Some("http://e2e-ui-ui.dogfood.svc.cluster.local:5180") ); } diff --git a/src/commands/local/workbench/reconcile.rs b/src/commands/local/workbench/reconcile.rs index 10b0337..2579bd8 100644 --- a/src/commands/local/workbench/reconcile.rs +++ b/src/commands/local/workbench/reconcile.rs @@ -1,11 +1,13 @@ //! Application reconcile: helm template + label inject + apply. use super::application::{load_applications, resolve_source_path, Application}; +use serde::{Deserialize, Serialize}; use serde_yaml::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; /// Label value for `app.kubernetes.io/managed-by`. pub const MANAGED_BY_VALUE: &str = "hops-local-gitops"; @@ -40,6 +42,16 @@ pub struct ReconcileResult { pub applied: bool, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManagedObjectRef { + api_version: String, + kind: String, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + namespace: Option, +} + /// Abstraction over `helm template` for tests. pub trait HelmRunner { fn template( @@ -59,6 +71,19 @@ pub trait KubectlApplier { labels: &BTreeMap, ) -> Result<(), Box>; fn apply(&self, yaml: &str) -> Result<(), Box>; + fn prune( + &self, + app_name: &str, + inventory_namespace: &str, + desired_yaml: &str, + ) -> Result<(), Box>; + fn record_inventory( + &self, + app_name: &str, + workspace_name: &str, + inventory_namespace: &str, + desired_yaml: &str, + ) -> Result<(), Box>; } /// Real helm binary runner. @@ -148,6 +173,198 @@ impl KubectlApplier for SystemKubectl { Err(format!("kubectl apply failed:\n - {}", hard_errors.join("\n - ")).into()) } } + + fn prune( + &self, + app_name: &str, + inventory_namespace: &str, + desired_yaml: &str, + ) -> Result<(), Box> { + let Some(previous) = load_inventory(app_name, inventory_namespace)? else { + // First prune-enabled reconcile seeds inventory after a successful + // apply. It must not infer ownership from broad label queries. + return Ok(()); + }; + // A worktree Application may render shared resources in another + // namespace, but its prune inventory is owned by this workspace. Never + // let one workspace delete another namespace's shared identity objects. + let previous = object_refs_in_namespace(previous, inventory_namespace); + let desired = + object_refs_in_namespace(managed_object_refs(desired_yaml)?, inventory_namespace); + let stale = stale_object_refs(&previous, &desired); + if stale.is_empty() { + return Ok(()); + } + + let delete_yaml = object_refs_as_delete_yaml(&stale)?; + let mut child = crate::commands::local::kubectl_command(&[ + "delete", + "--ignore-not-found=true", + "--wait=true", + "-f", + "-", + ]) + .stdin(Stdio::piped()) + .spawn()?; + child + .stdin + .as_mut() + .ok_or("failed to open kubectl delete stdin")? + .write_all(delete_yaml.as_bytes())?; + let status = child.wait()?; + if !status.success() { + return Err(format!("kubectl prune exited with {status}").into()); + } + log::info!( + "Pruned {} stale object(s) for Application {}", + stale.len(), + app_name + ); + Ok(()) + } + + fn record_inventory( + &self, + app_name: &str, + workspace_name: &str, + inventory_namespace: &str, + desired_yaml: &str, + ) -> Result<(), Box> { + let refs = + object_refs_in_namespace(managed_object_refs(desired_yaml)?, inventory_namespace); + let resources_json = serde_json::to_string(&refs)?; + let name = inventory_name(app_name); + let inventory = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": name, + "namespace": inventory_namespace, + "labels": { + "app.kubernetes.io/managed-by": MANAGED_BY_VALUE, + (WORKSPACE_ENV_LABEL): workspace_name, + (WORKSPACE_APP_LABEL): app_name, + } + }, + "data": { + "resources.json": resources_json, + } + }); + crate::commands::local::kubectl_apply_stdin(&serde_yaml::to_string(&inventory)?) + } +} + +fn inventory_name(app_name: &str) -> String { + format!("hops-lgi-{}", sanitize_release_name(app_name)) +} + +fn managed_object_refs(yaml: &str) -> Result, Box> { + let mut refs = BTreeSet::new(); + for doc in split_yaml_docs_owned(yaml) { + if doc.trim().is_empty() { + continue; + } + let value: Value = serde_yaml::from_str(&doc)?; + let Some(root) = value.as_mapping() else { + continue; + }; + let api_version = root + .get(Value::String("apiVersion".into())) + .and_then(Value::as_str); + let kind = root + .get(Value::String("kind".into())) + .and_then(Value::as_str); + let metadata = root + .get(Value::String("metadata".into())) + .and_then(Value::as_mapping); + let name = metadata + .and_then(|m| m.get(Value::String("name".into()))) + .and_then(Value::as_str); + let (Some(api_version), Some(kind), Some(name)) = (api_version, kind, name) else { + continue; + }; + let namespace = metadata + .and_then(|m| m.get(Value::String("namespace".into()))) + .and_then(Value::as_str) + .filter(|ns| !ns.is_empty()) + .map(str::to_string); + refs.insert(ManagedObjectRef { + api_version: api_version.to_string(), + kind: kind.to_string(), + name: name.to_string(), + namespace, + }); + } + Ok(refs.into_iter().collect()) +} + +fn stale_object_refs( + previous: &[ManagedObjectRef], + desired: &[ManagedObjectRef], +) -> Vec { + let desired: BTreeSet<_> = desired.iter().collect(); + previous + .iter() + .filter(|item| !desired.contains(item)) + .cloned() + .collect() +} + +fn object_refs_in_namespace(refs: Vec, namespace: &str) -> Vec { + refs.into_iter() + .filter(|item| item.namespace.as_deref() == Some(namespace)) + .collect() +} + +fn object_refs_as_delete_yaml(refs: &[ManagedObjectRef]) -> Result> { + let mut docs = Vec::new(); + for item in refs { + let mut metadata = serde_json::Map::new(); + metadata.insert("name".into(), serde_json::Value::String(item.name.clone())); + if let Some(namespace) = &item.namespace { + metadata.insert( + "namespace".into(), + serde_json::Value::String(namespace.clone()), + ); + } + let object = serde_json::json!({ + "apiVersion": item.api_version, + "kind": item.kind, + "metadata": metadata, + }); + docs.push(serde_yaml::to_string(&object)?); + } + Ok(docs.join("---\n")) +} + +fn load_inventory( + app_name: &str, + inventory_namespace: &str, +) -> Result>, Box> { + let name = inventory_name(app_name); + let output = crate::commands::local::kubectl_command(&[ + "-n", + inventory_namespace, + "get", + "configmap", + &name, + "-o", + "json", + ]) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("NotFound") || stderr.contains("not found") { + return Ok(None); + } + return Err(format!("failed to read GitOps inventory {name}: {stderr}").into()); + } + let config_map: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let raw = config_map + .pointer("/data/resources.json") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("GitOps inventory {name} has no data.resources.json"))?; + Ok(Some(serde_json::from_str(raw)?)) } /// Missing CRDs / unknown types are expected until platform packs are installed. @@ -161,10 +378,7 @@ fn is_soft_apply_error(msg: &str) -> bool { /// 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 { +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 { @@ -255,7 +469,10 @@ fn inject_labels_into_metadata_map( }; let labels_key = Value::String("labels".into()); if !meta.contains_key(&labels_key) { - meta.insert(labels_key.clone(), Value::Mapping(serde_yaml::Mapping::new())); + 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; @@ -293,18 +510,13 @@ fn values_to_yaml(values: &Value) -> Result> { 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.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()), - ); + sd.insert(Value::String("mode".into()), Value::String(mode.clone())); } if let Some(host) = opts.app_delivery_host_paths.get(app_name) { sd.insert( @@ -349,10 +561,7 @@ pub fn reconcile_applications( "app.kubernetes.io/managed-by".to_string(), MANAGED_BY_VALUE.to_string(), ); - m.insert( - WORKSPACE_ENV_LABEL.to_string(), - opts.workspace_name.clone(), - ); + m.insert(WORKSPACE_ENV_LABEL.to_string(), opts.workspace_name.clone()); m }; @@ -412,7 +621,18 @@ fn reconcile_one( let applied = if opts.dry_run { false } else { + if app.spec.sync_policy.prune { + kubectl.prune(&app.metadata.name, &opts.namespace, &labeled)?; + } kubectl.apply(&labeled)?; + if app.spec.sync_policy.prune { + kubectl.record_inventory( + &app.metadata.name, + &opts.workspace_name, + &opts.namespace, + &labeled, + )?; + } true }; @@ -581,7 +801,9 @@ image: 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), + labels + .get("app.kubernetes.io/managed-by") + .map(String::as_str), Some(MANAGED_BY_VALUE) ); assert_eq!( @@ -624,13 +846,95 @@ spec: 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(); + let dep = docs + .iter() + .find(|d| d.contains("kind: Deployment")) + .unwrap(); assert!( dep.contains("local-env: ws"), "deployment/pod template missing local-env: {dep}" ); } + #[test] + fn inventory_diff_prunes_only_removed_exact_objects() { + let previous = managed_object_refs( + r#" +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: e2e-ui-alice-web + namespace: alice +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: e2e-ui + namespace: default +"#, + ) + .unwrap(); + let desired = managed_object_refs( + r#" +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: e2e-ui-alice-web-g1 + namespace: alice +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: e2e-ui + namespace: default +"#, + ) + .unwrap(); + + let stale = stale_object_refs(&previous, &desired); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0].kind, "Oidc"); + assert_eq!(stale[0].name, "e2e-ui-alice-web"); + assert_eq!(stale[0].namespace.as_deref(), Some("alice")); + + let delete_yaml = object_refs_as_delete_yaml(&stale).unwrap(); + assert!(delete_yaml.contains("application.zitadel.m.crossplane.io/v1alpha1")); + assert!(delete_yaml.contains("name: e2e-ui-alice-web")); + assert!(delete_yaml.contains("namespace: alice")); + assert!(!delete_yaml.contains("e2e-ui-alice-web-g1")); + assert!(!delete_yaml.contains("kind: Project")); + } + + #[test] + fn prune_inventory_is_scoped_to_the_workspace_namespace() { + let refs = managed_object_refs( + r#" +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: e2e-ui-alice-web-g1 + namespace: alice +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: e2e-ui + namespace: default +--- +apiVersion: example.org/v1 +kind: ClusterThing +metadata: + name: shared +"#, + ) + .unwrap(); + + let scoped = object_refs_in_namespace(refs, "alice"); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].kind, "Oidc"); + assert_eq!(scoped[0].namespace.as_deref(), Some("alice")); + } + struct MockHelm { body: String, } @@ -650,6 +954,8 @@ spec: struct MockKubectl { applied: Mutex>, namespaces: Mutex>, + pruned: Mutex>, + inventories: Mutex>, } impl KubectlApplier for MockKubectl { @@ -665,6 +971,25 @@ spec: self.applied.lock().unwrap().push(yaml.to_string()); Ok(()) } + fn prune( + &self, + app_name: &str, + _inventory_namespace: &str, + _desired_yaml: &str, + ) -> Result<(), Box> { + self.pruned.lock().unwrap().push(app_name.to_string()); + Ok(()) + } + fn record_inventory( + &self, + app_name: &str, + _workspace_name: &str, + _inventory_namespace: &str, + _desired_yaml: &str, + ) -> Result<(), Box> { + self.inventories.lock().unwrap().push(app_name.to_string()); + Ok(()) + } } #[test] @@ -704,6 +1029,8 @@ spec: local: true destination: namespace: should-be-overridden + syncPolicy: + prune: true "#; std::fs::write(env.join("app.yaml"), app_yaml).unwrap(); @@ -713,6 +1040,8 @@ spec: let kubectl = MockKubectl { applied: Mutex::new(Vec::new()), namespaces: Mutex::new(Vec::new()), + pruned: Mutex::new(Vec::new()), + inventories: Mutex::new(Vec::new()), }; let opts = ReconcileOptions { namespace: "alice".into(), @@ -735,6 +1064,14 @@ spec: kubectl.namespaces.lock().unwrap().as_slice(), &["alice".to_string()] ); + assert_eq!( + kubectl.pruned.lock().unwrap().as_slice(), + &["demo-app".to_string()] + ); + assert_eq!( + kubectl.inventories.lock().unwrap().as_slice(), + &["demo-app".to_string()] + ); let _ = std::fs::remove_dir_all(&dir); } @@ -787,8 +1124,11 @@ metadata: "#; let out = ensure_namespace_on_docs(yaml, "alice").unwrap(); // Workload without ns → worktree - assert!(out.contains("name: e2e-ui-ui\n namespace: alice") || out.contains("namespace: alice\n name: e2e-ui-ui") - || (out.contains("kind: Service") && out.contains("namespace: alice"))); + assert!( + out.contains("name: e2e-ui-ui\n namespace: alice") + || out.contains("namespace: alice\n name: e2e-ui-ui") + || (out.contains("kind: Service") && out.contains("namespace: alice")) + ); // Already set preserved assert!(out.contains("namespace: already-set")); // Shared identity preserved diff --git a/src/commands/local/workbench/registry.rs b/src/commands/local/workbench/registry.rs index 22d3e17..bffd76a 100644 --- a/src/commands/local/workbench/registry.rs +++ b/src/commands/local/workbench/registry.rs @@ -25,6 +25,104 @@ pub struct WorkspaceRecord { /// ISO-ish timestamp of last up. #[serde(default)] pub updated_at: Option, + /// Bound local cluster name (kind `--name` / logical CP id). LWB-REQ-256. + #[serde(default)] + pub cluster_name: Option, + /// Resolved kube context for that cluster (e.g. `kind-hops`). + #[serde(default)] + pub kube_context: Option, +} + +/// Resolve cluster binding for a workspace up/down/status operation. +/// +/// - **Sticky:** when the workspace is already bound and `requested_cluster` is +/// omitted, return the **bound** cluster (do not fall back to process default). +/// - No prior record: accept `requested` (or default) and bind. +/// - Explicit request matching bound: keep bound context. +/// - Explicit request differing without `rebind`: error. +/// - Explicit request differing with `rebind`: accept new. +pub fn resolve_cluster_binding( + existing: Option<&WorkspaceRecord>, + requested_cluster: Option<&str>, + default_cluster: &str, + default_kube_context: &str, + rebind: bool, +) -> Result<(String, String), String> { + let explicit = requested_cluster.map(str::trim).filter(|s| !s.is_empty()); + + // Sticky core: no explicit --cluster-name → keep bound cluster if any. + if explicit.is_none() { + if let Some(rec) = existing { + if let Some(bound) = rec.cluster_name.as_deref().filter(|s| !s.is_empty()) { + let ctx = rec + .kube_context + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + kube_context_for_cluster(bound, default_cluster, default_kube_context) + }); + return Ok((bound.to_string(), ctx)); + } + } + } + + let requested = explicit.unwrap_or(default_cluster); + let kube = kube_context_for_cluster(requested, default_cluster, default_kube_context); + + match existing.and_then(|e| e.cluster_name.as_deref().filter(|s| !s.is_empty())) { + None => Ok((requested.to_string(), kube)), + // rebind always refreshes name + kube context (even if name is unchanged — + // e.g. same logical "hops" but context dory → kind-hops). + Some(_bound) if rebind => Ok((requested.to_string(), kube)), + Some(bound) if bound == requested => { + let ctx = existing + .and_then(|e| e.kube_context.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or(kube); + Ok((bound.to_string(), ctx)) + } + Some(bound) => Err(format!( + "workspace is bound to cluster `{bound}`; pass `--rebind-cluster` to move to `{requested}`" + )), + } +} + +/// Derive kube context for a logical cluster name. +pub fn kube_context_for_cluster( + cluster_name: &str, + default_cluster: &str, + default_kube_context: &str, +) -> String { + if cluster_name == default_cluster { + return default_kube_context.to_string(); + } + // kind contexts are kind-; product dory/colima keep their default context + // only when the name matches the default cluster identity. + if default_kube_context.starts_with("kind-") || default_kube_context.is_empty() { + format!("kind-{cluster_name}") + } else { + default_kube_context.to_string() + } +} + +/// Activate process kube context (+ kind cluster name) from a workspace record. +/// Returns the bound cluster name and context when present. +pub fn activate_workspace_cluster(record: &WorkspaceRecord) -> Option<(String, String)> { + let cluster = record.cluster_name.as_deref()?.trim(); + if cluster.is_empty() { + return None; + } + let ctx = record + .kube_context + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("kind-{cluster}")); + + std::env::set_var(crate::commands::local::HOPS_KUBE_CONTEXT_ENV, &ctx); + crate::commands::local::backend::kind::set_active_cluster_name(cluster); + Some((cluster.to_string(), ctx)) } /// DNS-1123-ish slug for a workspace name. @@ -89,7 +187,10 @@ fn record_path(state_dir: &Path, name: &str) -> PathBuf { .join(format!("{}.json", slugify_name(name))) } -pub fn save_workspace(state_dir: &Path, record: &WorkspaceRecord) -> Result> { +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)?; @@ -97,7 +198,10 @@ pub fn save_workspace(state_dir: &Path, record: &WorkspaceRecord) -> Result Result, Box> { +pub fn load_workspace( + state_dir: &Path, + name: &str, +) -> Result, Box> { let path = record_path(state_dir, name); if !path.exists() { return Ok(None); @@ -165,18 +269,22 @@ mod tests { let a = WorkspaceRecord { name: "alice".into(), namespace: namespace_for_name("alice"), - env_path: "/proj/gitops/env/local".into(), + env_path: "/proj/gitops/envs/local".into(), project_root: Some("/proj".into()), delivery_mode: Some("hostPath".into()), updated_at: None, + cluster_name: Some("hops".into()), + kube_context: Some("kind-hops".into()), }; let b = WorkspaceRecord { name: "bob".into(), namespace: namespace_for_name("bob"), - env_path: "/proj/gitops/env/local".into(), + env_path: "/proj/gitops/envs/local".into(), project_root: Some("/proj".into()), delivery_mode: Some("sync".into()), updated_at: None, + cluster_name: Some("dogfood".into()), + kube_context: Some("kind-dogfood".into()), }; save_workspace(&dir, &a).unwrap(); save_workspace(&dir, &b).unwrap(); @@ -201,4 +309,106 @@ mod tests { "my-feature" ); } + + #[test] + fn resolve_cluster_binding_sticky_and_rebind() { + let existing = WorkspaceRecord { + name: "alice".into(), + namespace: "alice".into(), + env_path: "/p".into(), + project_root: None, + delivery_mode: None, + updated_at: None, + cluster_name: Some("hops".into()), + kube_context: Some("kind-hops".into()), + }; + // Same cluster: ok + let (c, k) = + resolve_cluster_binding(Some(&existing), Some("hops"), "hops", "kind-hops", false) + .unwrap(); + assert_eq!(c, "hops"); + assert_eq!(k, "kind-hops"); + // Different without rebind: err + assert!(resolve_cluster_binding( + Some(&existing), + Some("dogfood"), + "hops", + "kind-hops", + false + ) + .is_err()); + // Rebind: ok + let (c2, k2) = + resolve_cluster_binding(Some(&existing), Some("dogfood"), "hops", "kind-hops", true) + .unwrap(); + assert_eq!(c2, "dogfood"); + assert_eq!(k2, "kind-dogfood"); + // Rebind same name refreshes stale kube context (dory → kind-hops) + let stale = WorkspaceRecord { + cluster_name: Some("hops".into()), + kube_context: Some("dory".into()), + ..existing.clone() + }; + let (c4, k4) = + resolve_cluster_binding(Some(&stale), Some("hops"), "hops", "kind-hops", true).unwrap(); + assert_eq!(c4, "hops"); + assert_eq!(k4, "kind-hops", "rebind must refresh kube context"); + // First bind persists default + let (c3, k3) = resolve_cluster_binding(None, None, "hops", "kind-hops", false).unwrap(); + assert_eq!(c3, "hops"); + assert_eq!(k3, "kind-hops"); + } + + #[test] + fn sticky_omitted_request_keeps_bound_cluster_not_process_default() { + // Core sticky case: bound to dogfood; process default is hops; no --cluster-name. + let existing = WorkspaceRecord { + name: "alice".into(), + namespace: "alice".into(), + env_path: "/p".into(), + project_root: None, + delivery_mode: None, + updated_at: None, + cluster_name: Some("dogfood".into()), + kube_context: Some("kind-dogfood".into()), + }; + let (c, k) = + resolve_cluster_binding(Some(&existing), None, "hops", "kind-hops", false).unwrap(); + assert_eq!(c, "dogfood", "must keep sticky bind when request omitted"); + assert_eq!(k, "kind-dogfood"); + // Without stored kube_context, still derive kind- + let no_ctx = WorkspaceRecord { + kube_context: None, + ..existing.clone() + }; + let (c2, k2) = + resolve_cluster_binding(Some(&no_ctx), None, "hops", "kind-hops", false).unwrap(); + assert_eq!(c2, "dogfood"); + assert_eq!(k2, "kind-dogfood"); + } + + #[test] + fn cluster_fields_round_trip() { + let dir = std::env::temp_dir().join(format!( + "lwb-reg-cluster-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&dir).unwrap(); + let r = WorkspaceRecord { + name: "alice".into(), + namespace: "alice".into(), + env_path: "/p".into(), + project_root: None, + delivery_mode: Some("hostPath".into()), + updated_at: None, + cluster_name: Some("dogfood".into()), + kube_context: Some("kind-dogfood".into()), + }; + save_workspace(&dir, &r).unwrap(); + let loaded = load_workspace(&dir, "alice").unwrap().unwrap(); + assert_eq!(loaded.cluster_name.as_deref(), Some("dogfood")); + assert_eq!(loaded.kube_context.as_deref(), Some("kind-dogfood")); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/commands/local/workbench/watch.rs b/src/commands/local/workbench/watch.rs index f176d18..dda80d0 100644 --- a/src/commands/local/workbench/watch.rs +++ b/src/commands/local/workbench/watch.rs @@ -35,9 +35,7 @@ pub fn should_ignore_watch_path(path: &Path) -> bool { } /// Build the set of roots to watch: env dir + each Application chart path. -pub fn watch_roots_for_applications( - env_path: &Path, -) -> Result, Box> { +pub fn watch_roots_for_applications(env_path: &Path) -> Result, Box> { let mut roots = Vec::new(); let env_canon = env_path .canonicalize() @@ -126,7 +124,9 @@ mod tests { 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/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" @@ -135,14 +135,14 @@ mod tests { #[test] fn chart_and_env_trigger_reconcile_source_does_not() { - let env = PathBuf::from("/proj/gitops/env/local"); + let env = PathBuf::from("/proj/gitops/envs/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"), + Path::new("/proj/gitops/envs/local/api.yaml"), &env, &charts )); @@ -169,11 +169,7 @@ mod tests { )); // Ignored even if under chart-ish names assert_eq!( - is_chart_or_env_path( - Path::new("/proj/ui/node_modules/x"), - &env, - &charts - ), + is_chart_or_env_path(Path::new("/proj/ui/node_modules/x"), &env, &charts), WatchPathClass::Ignored ); } diff --git a/src/commands/provider/install.rs b/src/commands/provider/install.rs index de0a2da..a25dfa5 100644 --- a/src/commands/provider/install.rs +++ b/src/commands/provider/install.rs @@ -1,8 +1,8 @@ -use crate::commands::local::backend::{self, Backend}; +use crate::commands::local::backend::{self, Backend, ClusterProvider, DockerProvider}; use crate::commands::local::package_install::{ - docker_arch, ensure_cached_repo_checkout_at, ensure_registry, parse_repo_spec, - resolve_repo_install_target, run_watch, sanitize_name_component, RepoInstallTarget, RepoSpec, - registry_pull, registry_push, + docker_arch, ensure_cached_repo_checkout_at, ensure_registry, parse_repo_spec, registry_pull, + registry_push, resolve_repo_install_target, run_watch, sanitize_name_component, + RepoInstallTarget, RepoSpec, }; use crate::commands::local::{ kubectl_apply_stdin, run_cmd, run_cmd_output, MANAGED_BY_LABEL, PROVIDER_INSTALL_MANAGED_BY, @@ -53,9 +53,17 @@ pub struct ProviderInstallArgs { #[arg(long)] pub context: Option, - /// Local cluster backend whose node should be wired for local package pulls. - #[arg(long, value_enum)] - pub backend: Option, + /// How Kubernetes nodes are provisioned: `kind`, `dory`, or `colima`. + #[arg(long = "cluster-provider", value_enum)] + pub cluster_provider: Option, + + /// Container engine for kind/tools: `dory`, `colima`, or `docker`. + #[arg(long = "docker-provider", value_enum)] + pub docker_provider: Option, + + /// Named hops-managed kind cluster. Default `hops` uses context `kind-hops`. + #[arg(long = "cluster-name", value_name = "NAME")] + pub cluster_name: Option, /// Watch the project directory for changes and re-run install automatically #[arg(long, conflicts_with = "repo")] @@ -77,7 +85,13 @@ struct PackageMetadata { } pub fn run(args: &ProviderInstallArgs) -> Result<(), Box> { - let backend = backend::activate(args.backend, args.context.as_deref()); + let provider_selected = args.cluster_provider.is_some() || args.docker_provider.is_some(); + let backend = backend::activate_with_providers( + args.cluster_provider, + args.docker_provider, + args.cluster_name.as_deref(), + args.context.as_deref(), + )?; match (args.repo.as_deref(), args.version.as_deref()) { (Some(repo), Some(version)) => { @@ -89,13 +103,13 @@ pub fn run(args: &ProviderInstallArgs) -> Result<(), Box> { args.version_prefix.as_deref(), args.branch.as_deref(), backend, - args.backend, + provider_selected, args.context.as_deref(), ), (None, _) => { let path = args.path.as_deref().unwrap_or("."); let prefix = args.version_prefix.clone(); - prepare_local_registry(backend, args.backend, args.context.as_deref())?; + prepare_local_registry(backend, provider_selected, args.context.as_deref())?; run_local_path(path, args.skip_dependency_resolution, prefix.as_deref())?; if args.watch { @@ -118,14 +132,14 @@ fn run_repo_install( version_prefix: Option<&str>, branch: Option<&str>, backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { let spec = parse_repo_spec(repo)?; match resolve_repo_install_target(&spec)? { RepoInstallTarget::SourceBuild => { let cache_path = ensure_cached_repo_checkout_at(&spec, branch)?; - prepare_local_registry(backend, backend_flag, context)?; + prepare_local_registry(backend, provider_selected, context)?; run_local_path( &cache_path.to_string_lossy(), skip_dependency_resolution, @@ -140,11 +154,11 @@ fn run_repo_install( fn prepare_local_registry( backend: Backend, - backend_flag: Option, + provider_selected: bool, context: Option<&str>, ) -> Result<(), Box> { ensure_registry()?; - backend::wire_local_registry_for_target(backend, backend_flag, context) + backend::wire_local_registry_for_target(backend, provider_selected, context) } fn apply_repo_version( @@ -1123,19 +1137,19 @@ mod tests { } #[test] - fn local_runtime_image_ref_uses_nodeport_registry() { + fn local_runtime_image_ref_uses_selected_nodeport_registry() { let image = local_runtime_image_ref("provider-helm", "arm64", "v1.999.3"); assert_eq!( image, - "localhost:30500/hops-ops/provider-helm-arm64:v1.999.3" + format!("{}/hops-ops/provider-helm-arm64:v1.999.3", registry_push()) ); assert!(!image.contains(registry_pull())); } #[test] - fn local_registry_wiring_skips_foreign_context_without_backend_flag() { + fn local_registry_wiring_skips_foreign_context_without_provider_selection() { assert!(!backend::should_wire_local_registry( - None, + false, Some("kind-hops"), Backend::Colima )); diff --git a/tests/colima_smoke_workflow.rs b/tests/colima_smoke_workflow.rs index 5eaef20..1a28a61 100644 --- a/tests/colima_smoke_workflow.rs +++ b/tests/colima_smoke_workflow.rs @@ -37,9 +37,7 @@ fn colima_smoke_workflow_exists_and_pins_nested_virt_runner() { "must pin nested-virt-capable Intel runner" ); assert!( - !text - .lines() - .any(|l| l.trim() == "runs-on: macos-latest"), + !text.lines().any(|l| l.trim() == "runs-on: macos-latest"), "must not use bare macos-latest as sole runs-on" ); assert!( @@ -53,7 +51,7 @@ fn colima_smoke_workflow_kind_parity_sequence() { let text = workflow_text(); for needle in [ "cargo build", - "start --backend colima", + "start --cluster-provider colima --docker-provider colima", "local doctor", "localhost:30500", "registry.crossplane-system.svc.cluster.local:5000", @@ -66,16 +64,16 @@ fn colima_smoke_workflow_kind_parity_sequence() { "colima smoke missing kind-parity fragment: {needle}" ); } - // stop/start resume: start without --backend after stop + // stop/start resume: persisted providers allow start without flags after stop. assert!( - text.contains("local start\n") || text.lines().any(|l| l.trim() == "./target/debug/hops-cli local start"), - "must start again without --backend after stop" + text.contains("local start\n") + || text + .lines() + .any(|l| l.trim() == "./target/debug/hops-cli local start"), + "must start again from persisted providers after stop" ); for tool in ["colima", "docker", "kubectl", "helm"] { - assert!( - text.contains(tool), - "prereq install must mention {tool}" - ); + assert!(text.contains(tool), "prereq install must mention {tool}"); } } @@ -89,7 +87,9 @@ fn colima_smoke_workflow_sizes_vm_for_gha_intel_runner() { ); // 8Gi left CoreDNS thrashing; smoke needs headroom above that floor. assert!( - text.contains("--memory 10") || text.contains("--memory 11") || text.contains("--memory 12"), + text.contains("--memory 10") + || text.contains("--memory 11") + || text.contains("--memory 12"), "must allocate at least 10Gi to the colima VM on GHA intel runners" ); assert!( diff --git a/tests/dory_smoke_workflow.rs b/tests/dory_smoke_workflow.rs index b005d51..683587d 100644 --- a/tests/dory_smoke_workflow.rs +++ b/tests/dory_smoke_workflow.rs @@ -125,7 +125,7 @@ fn dory_smoke_workflow_hops_integration_core() { let text = workflow_text(); for needle in [ "cargo build", - "start --backend dory", + "start --cluster-provider dory --docker-provider dory", "local doctor", "registry.crossplane-system.svc.cluster.local:5000", "30500", diff --git a/tests/fixtures/config-smoke/README.md b/tests/fixtures/config-smoke/README.md index 0ce3048..78579b3 100644 --- a/tests/fixtures/config-smoke/README.md +++ b/tests/fixtures/config-smoke/README.md @@ -3,8 +3,8 @@ Minimal Crossplane configuration fixture for **path-based** hops integration tests. ```bash -hops local start --backend dory # or kind/colima -hops config install --path tests/fixtures/config-smoke --backend dory +hops local start --cluster-provider dory --docker-provider dory +hops config install --path tests/fixtures/config-smoke --cluster-provider dory --docker-provider dory kubectl apply -f tests/fixtures/config-smoke/local/ci-xr.yaml kubectl -n hops-ci wait --for=condition=Ready configsmoke/hops-ci-smoke --timeout=300s kubectl -n hops-ci get configmap hops-ci-smoke