diff --git a/bootstrap/providers/helm.yaml b/bootstrap/providers/helm.yaml index 6eb6449..5bb1c66 100644 --- a/bootstrap/providers/helm.yaml +++ b/bootstrap/providers/helm.yaml @@ -3,6 +3,6 @@ kind: Provider metadata: name: crossplane-contrib-provider-helm spec: - package: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.1.0 + package: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.3.0 runtimeConfigRef: name: local-dev-helm diff --git a/charts/oci-registry/Chart.yaml b/charts/oci-registry/Chart.yaml new file mode 100644 index 0000000..9487244 --- /dev/null +++ b/charts/oci-registry/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: oci-registry +description: Private Crossplane development artifacts, uploaded through the Kubernetes API +type: application +version: 0.1.0 +appVersion: "2.8.3" diff --git a/charts/oci-registry/README.md b/charts/oci-registry/README.md new file mode 100644 index 0000000..e1e05a6 --- /dev/null +++ b/charts/oci-registry/README.md @@ -0,0 +1,95 @@ +# Crossplane development registry + +One Distribution chart for local and remote control planes. This is artifact +storage, not an application deployment framework or a Crossplane XR. + +The single pod has two Distribution processes over one content store: + +- `registry-upload:5001` selects a **loopback-only** HTTP writer. Reach it using + `kubectl --context CONTEXT -n NAMESPACE port-forward --address 127.0.0.1 service/registry-upload :5001`. + A host-side OCI client uses the printed local port. Docker's remote daemon + must not be used to push to this workstation loopback address. +- `registry:5000` serves HTTPS in Distribution read-only mode. Both Crossplane + and node container runtimes must resolve this name and trust its certificate. + The reader disables redirects, deletion, upload purging, and proxy caching. + +The upload Service deliberately has no reachable pod-IP write listener. +Kubelet port-forward connects inside the selected pod network namespace. +NetworkPolicy additionally permits only declared read peers on port 5000. +No NodePort, LoadBalancer, Ingress, DNS, or Gateway resource is created. +Do not add a public route as a workaround for node DNS/trust failures. + +A StatefulSet keeps the upload RBAC bound to one stable pod name. Upload +subjects can read pod metadata in this namespace but can port-forward only +`registry-0`; they receive no exec, Secret, or package-manager write rights. +Use a dedicated namespace. Existing cluster-wide RBAC grants are additive and +cannot be revoked by this chart. NetworkPolicy needs an enforcing CNI and does +not isolate a compromised node or cluster administrator. + +## Inputs and rendering + +Provide an existing TLS Secret, explicit allowed read peers (including runtime +node CIDRs), and encrypted storage. The chart creates no certificates or cloud +infrastructure and accepts no static S3 credentials. + +```sh +helm lint charts/oci-registry -f charts/oci-registry/ci/local.yaml +helm template registry charts/oci-registry -n crossplane-dev \ + -f charts/oci-registry/ci/remote.yaml +cargo test --test oci_registry_chart +``` + +The optional native protocol fixture uses the same rendered Distribution +configuration, with temporary filesystem paths, loopback ports, and a test CA: + +```sh +HOPS_DISTRIBUTION_BINARY=/absolute/path/to/registry \ + cargo test --test distribution_protocol -- --ignored +``` + +Build that binary from the pinned Distribution v2.8.3 source. This verifies +upload/digest readback, TLS trust failure/success, read-side mutation rejection, +and process-restart durability without Docker or a local control plane. It does +not substitute for Kubernetes RBAC, NetworkPolicy, node pulls, PVC replacement, +or S3/workload-identity testing. + +The files in `ci/` are **render fixtures**, not deployment-ready values: replace +documentation CIDRs, sample bucket/IAM names, and TLS Secret names. PVC +encryption is an operator attestation, not something Helm can verify. S3 uses +server-side encryption and HTTPS with existing workload identity; bucket +policies, isolation, and node trust remain infrastructure responsibilities. +A cloud credential plugin already used by kubeconfig is not a new Hops AWS +authentication requirement. + +An Argo Application can select `charts/oci-registry` from this repository at a +pinned revision. The chart can also be rendered by a local controller or Helm; +Argo itself is not required. The chart and its runtime image must remain +fetchable without this development registry. + +## Durability and cleanup + +There is no automatic garbage collector or raw object TTL. Deletion and upload +purging are disabled pending the session-aware reachability/retention workflow. +Do not enable TTL on the bucket prefix. Retain every active and restorable +manifest and its transitive blob closure. Offline Distribution GC must only run +after stopping uploads and establishing that complete retention set; this chart +does not yet automate or claim that proof. PVCs have Helm and Argo retention +annotations, but other inventory controllers must separately honor retention. + +## Local adoption boundary + +This chart does **not yet replace** the embedded local NodePort installer. +Migrating a live local registry also needs host-side upload transport and node +trust integration. Do not run both writers on the existing claim or apply the +new Service over the old Deployment. + +The intended migration reuses `registry-pvc`, the existing TLS Secret, and the +pull hostname, with the old writer stopped first and its manifests retained +for rollback. `storage.pvc.existingClaim` avoids creating/adopting the PVC. +Snapshot/backup the data and verify digest pulls before and after migration. +Neither that migration nor a shared-cluster deployment is performed by render +tests. Registry replacement, strict-TLS node pulls, RBAC denial, and +NetworkPolicy enforcement need the disposable Kubernetes integration fixture. + +Configuration references: [Distribution configuration](https://distribution.github.io/distribution/about/configuration/), +[S3 driver](https://distribution.github.io/distribution/storage-drivers/s3/). diff --git a/charts/oci-registry/ci/local.yaml b/charts/oci-registry/ci/local.yaml new file mode 100644 index 0000000..c9d7e87 --- /dev/null +++ b/charts/oci-registry/ci/local.yaml @@ -0,0 +1,17 @@ +# Render fixture: encryption must be verified on the actual laptop storage. +tls: + existingSecret: hops-local-registry-tls +storage: + pvc: + encryptionConfirmed: true +access: + uploadSubjects: + - kind: Group + apiGroup: rbac.authorization.k8s.io + name: crossplane-developers + pullPeers: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: crossplane-system + - ipBlock: + cidr: 192.0.2.0/24 diff --git a/charts/oci-registry/ci/remote.yaml b/charts/oci-registry/ci/remote.yaml new file mode 100644 index 0000000..7f13a7c --- /dev/null +++ b/charts/oci-registry/ci/remote.yaml @@ -0,0 +1,24 @@ +# Render fixture only: replace documentation CIDRs and existing infra bindings. +tls: + existingSecret: development-registry-tls +storage: + type: s3 + s3: + bucket: example-existing-development-artifacts + region: us-east-2 + kmsKeyId: alias/development-artifacts +serviceAccount: + automountServiceAccountToken: true + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/development-registry +access: + uploadSubjects: + - kind: Group + apiGroup: rbac.authorization.k8s.io + name: crossplane-developers + pullPeers: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: crossplane-system + - ipBlock: + cidr: 192.0.2.0/24 diff --git a/charts/oci-registry/templates/_helpers.tpl b/charts/oci-registry/templates/_helpers.tpl new file mode 100644 index 0000000..11b03c2 --- /dev/null +++ b/charts/oci-registry/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{- define "oci.labels" -}} +app.kubernetes.io/name: oci-registry +app.kubernetes.io/instance: {{ .Release.Name }} +hops.ops.com.ai/registry-mode: push +{{- end -}} + +{{- define "oci.selector" -}} +app.kubernetes.io/name: oci-registry +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "oci.storage" -}} +{{- if eq .Values.storage.type "pvc" }} +filesystem: + rootdirectory: /var/lib/registry +{{- else }} +s3: + bucket: {{ required "storage.s3.bucket is required" .Values.storage.s3.bucket | quote }} + region: {{ required "storage.s3.region is required" .Values.storage.s3.region | quote }} + rootdirectory: {{ .Values.storage.s3.rootDirectory | quote }} + encrypt: true + secure: true + {{- with .Values.storage.s3.kmsKeyId }} + keyid: {{ . | quote }} + {{- end }} +{{- end }} +# Prevent clients being redirected around the internal read endpoint. +redirect: + disable: true +delete: + enabled: false +{{- end -}} diff --git a/charts/oci-registry/templates/access.yaml b/charts/oci-registry/templates/access.yaml new file mode 100644 index 0000000..ab51575 --- /dev/null +++ b/charts/oci-registry/templates/access.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "oci.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Values.name }} + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + {{- include "oci.selector" . | nindent 6 }} + policyTypes: ["Ingress"] + ingress: + - from: + {{- toYaml .Values.access.pullPeers | nindent 8 }} + ports: + - protocol: TCP + port: 5000 +{{- if .Values.access.uploadSubjects }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Values.name }}-upload + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: [""] + resources: ["services"] + resourceNames: [{{ printf "%s-upload" .Values.name | quote }}] + verbs: ["get"] + # kubectl service port-forward resolves pods with a label selector. + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/portforward"] + resourceNames: [{{ printf "%s-0" .Values.name | quote }}] + # SPDY uses POST; WebSocket upgrade uses GET (plus a CREATE check on newer APIs). + verbs: ["get", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Values.name }}-upload + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Values.name }}-upload +subjects: + {{- toYaml .Values.access.uploadSubjects | nindent 2 }} +{{- end }} diff --git a/charts/oci-registry/templates/config.yaml b/charts/oci-registry/templates/config.yaml new file mode 100644 index 0000000..87b81f6 --- /dev/null +++ b/charts/oci-registry/templates/config.yaml @@ -0,0 +1,57 @@ +{{- if and (eq .Values.storage.type "pvc") (not .Values.storage.pvc.encryptionConfirmed) }} +{{- fail "PVC storage requires storage.pvc.encryptionConfirmed=true after verifying backing-storage encryption" }} +{{- end }} +{{- if empty .Values.access.pullPeers }} +{{- fail "access.pullPeers must explicitly allow Crossplane and runtime-node read traffic" }} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.name }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "oci.labels" . | nindent 4 }} +data: + write.yml: | + version: 0.1 + log: + level: warn + accesslog: + disabled: true + storage: + {{- include "oci.storage" . | nindent 6 }} + maintenance: + uploadpurging: + enabled: false + http: + # Only kubelet port-forward can reach this listener from outside the pod. + addr: 127.0.0.1:5001 + relativeurls: true + health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 + read.yml: | + version: 0.1 + log: + level: warn + accesslog: + disabled: true + storage: + {{- include "oci.storage" . | nindent 6 }} + maintenance: + readonly: + enabled: true + uploadpurging: + enabled: false + http: + addr: :5000 + tls: + certificate: /certs/tls.crt + key: /certs/tls.key + health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 diff --git a/charts/oci-registry/templates/service.yaml b/charts/oci-registry/templates/service.yaml new file mode 100644 index 0000000..81e1f1e --- /dev/null +++ b/charts/oci-registry/templates/service.yaml @@ -0,0 +1,19 @@ +{{- range $mode := list "read" "write" }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $.Values.name }}{{ if eq $mode "write" }}-upload{{ end }} + namespace: {{ $.Release.Namespace }} + labels: + {{- include "oci.labels" $ | nindent 4 }} + hops.ops.com.ai/registry-access: {{ $mode }} +spec: + type: ClusterIP + selector: + {{- include "oci.selector" $ | nindent 4 }} + ports: + - name: {{ $mode }} + port: {{ if eq $mode "read" }}5000{{ else }}5001{{ end }} + targetPort: {{ $mode }} +{{- end }} diff --git a/charts/oci-registry/templates/storage.yaml b/charts/oci-registry/templates/storage.yaml new file mode 100644 index 0000000..f4e1b6d --- /dev/null +++ b/charts/oci-registry/templates/storage.yaml @@ -0,0 +1,20 @@ +{{- if and (eq .Values.storage.type "pvc") (empty .Values.storage.pvc.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.name }}-pvc + namespace: {{ .Release.Namespace }} + annotations: + helm.sh/resource-policy: keep + argocd.argoproj.io/sync-options: Prune=false,Delete=false + labels: + {{- include "oci.labels" . | nindent 4 }} +spec: + accessModes: ["ReadWriteOnce"] + {{- if ne .Values.storage.pvc.storageClassName nil }} + storageClassName: {{ .Values.storage.pvc.storageClassName | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.storage.pvc.size }} +{{- end }} diff --git a/charts/oci-registry/templates/workload.yaml b/charts/oci-registry/templates/workload.yaml new file mode 100644 index 0000000..28662a0 --- /dev/null +++ b/charts/oci-registry/templates/workload.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Values.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "oci.labels" . | nindent 4 }} +spec: + serviceName: {{ .Values.name }} + replicas: 1 + selector: + matchLabels: + {{- include "oci.selector" . | nindent 6 }} + template: + metadata: + labels: + {{- include "oci.labels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }} + spec: + serviceAccountName: {{ .Values.name }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + {{- range $mode := list "read" "write" }} + - name: {{ $mode }} + image: {{ $.Values.image | quote }} + imagePullPolicy: IfNotPresent + args: ["serve", "/etc/distribution/{{ $mode }}.yml"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + ports: + - name: {{ $mode }} + containerPort: {{ if eq $mode "read" }}5000{{ else }}5001{{ end }} + resources: + {{- toYaml $.Values.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/distribution + readOnly: true + {{- if eq $.Values.storage.type "pvc" }} + - name: data + mountPath: /var/lib/registry + readOnly: {{ eq $mode "read" }} + {{- end }} + {{- if eq $mode "read" }} + - name: tls + mountPath: /certs + readOnly: true + {{- end }} + readinessProbe: + {{- if eq $mode "read" }} + httpGet: + scheme: HTTPS + path: /v2/ + port: read + {{- else }} + exec: + command: ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5001/v2/"] + {{- end }} + periodSeconds: 5 + timeoutSeconds: 15 + failureThreshold: 6 + {{- end }} + volumes: + - name: config + configMap: + name: {{ .Values.name }}-config + - name: tls + secret: + secretName: {{ required "tls.existingSecret is required" .Values.tls.existingSecret }} + {{- if eq .Values.storage.type "pvc" }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.storage.pvc.existingClaim | default (printf "%s-pvc" .Values.name) }} + {{- end }} diff --git a/charts/oci-registry/values.schema.json b/charts/oci-registry/values.schema.json new file mode 100644 index 0000000..96c7e5e --- /dev/null +++ b/charts/oci-registry/values.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "maxLength": 50 + }, + "image": { + "type": "string", + "pattern": "^[^\\s]+(:[^\\s/]+|@sha256:[a-f0-9]{64})$" + }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingSecret": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "maxLength": 50 + } + }, + "required": [ + "existingSecret" + ] + }, + "storage": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "pvc", + "s3" + ] + }, + "pvc": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingClaim": { + "type": "string" + }, + "size": { + "type": "string", + "pattern": "^[1-9][0-9]*(Mi|Gi|Ti)$" + }, + "storageClassName": { + "type": [ + "string", + "null" + ] + }, + "encryptionConfirmed": { + "type": "boolean" + } + }, + "required": [ + "existingClaim", + "size", + "encryptionConfirmed" + ] + }, + "s3": { + "type": "object", + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "region": { + "type": "string" + }, + "rootDirectory": { + "type": "string", + "pattern": "^/[a-zA-Z0-9/_-]+$" + }, + "kmsKeyId": { + "type": "string" + } + }, + "required": [ + "bucket", + "region", + "rootDirectory", + "kmsKeyId" + ] + } + }, + "required": [ + "type", + "pvc", + "s3" + ] + }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "automountServiceAccountToken": { + "type": "boolean" + } + }, + "required": [ + "annotations", + "automountServiceAccountToken" + ] + }, + "access": { + "type": "object", + "additionalProperties": false, + "properties": { + "uploadSubjects": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "User", + "Group", + "ServiceAccount" + ] + }, + "apiGroup": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "kind", + "name" + ] + } + }, + "pullPeers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "namespaceSelector": { + "type": "object" + }, + "podSelector": { + "type": "object" + }, + "ipBlock": { + "type": "object", + "additionalProperties": false, + "properties": { + "cidr": { + "type": "string", + "minLength": 1 + }, + "except": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "cidr" + ] + } + } + } + } + }, + "required": [ + "uploadSubjects", + "pullPeers" + ] + }, + "resources": { + "type": "object" + }, + "global": { + "type": "object" + }, + "local": { + "type": "boolean" + }, + "localDomain": { + "type": "string" + }, + "environment": { + "type": "object" + }, + "source": { + "type": "object" + } + }, + "required": [ + "name", + "image", + "tls", + "storage", + "serviceAccount", + "access", + "resources" + ] +} diff --git a/charts/oci-registry/values.yaml b/charts/oci-registry/values.yaml new file mode 100644 index 0000000..38fd9a5 --- /dev/null +++ b/charts/oci-registry/values.yaml @@ -0,0 +1,35 @@ +# This chart does not provision clusters, cloud storage, DNS, or certificates. +name: registry +image: registry:2.8.3 +tls: + # Must contain tls.crt and tls.key, trusted by Crossplane AND runtime nodes. + existingSecret: "" +storage: + type: pvc + pvc: + existingClaim: "" + size: 20Gi + storageClassName: null + # Explicit operator attestation: the backing volume/host is encrypted. + encryptionConfirmed: false + s3: + bucket: "" + region: "" + rootDirectory: /hops-development + kmsKeyId: "" +serviceAccount: + annotations: {} + # Enable only when the selected workload identity requires a projected token. + automountServiceAccountToken: false +access: + # Kubernetes RBAC subjects; no credentials. Empty means no upload grants. + uploadSubjects: [] + # Namespaces/pods and node CIDRs allowed to reach the HTTPS read endpoint. + # Explicitly include node networks: runtime image pulls originate on nodes. + pullPeers: [] +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi diff --git a/src/commands/local/backend/kind.rs b/src/commands/local/backend/kind.rs index 90b2f16..07e79a1 100644 --- a/src/commands/local/backend/kind.rs +++ b/src/commands/local/backend/kind.rs @@ -798,21 +798,29 @@ pub fn resize(_size: &SizeArgs) -> Result<(), Box> { /// Whether the hops kind cluster exists (running or stopped). Missing binary /// or failing command reads as "no cluster". pub fn cluster_exists() -> bool { + let name = active_cluster_name(); + list_cluster_names().iter().any(|line| line == &name) +} + +/// Names reported by `kind get clusters`. Missing binary reads as empty. +pub fn list_cluster_names() -> Vec { if !command_exists("kind") { - return false; + return Vec::new(); } let mut cmd = kind_cmd(&["get", "clusters"]); let output = match cmd.output() { Ok(o) => o, - Err(_) => return false, + Err(_) => return Vec::new(), }; if !output.status.success() { - return false; + return Vec::new(); } - let name = active_cluster_name(); String::from_utf8_lossy(&output.stdout) .lines() - .any(|line| line.trim() == name) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() } fn node_running() -> bool { diff --git a/src/commands/local/configure.rs b/src/commands/local/configure.rs new file mode 100644 index 0000000..50c92e5 --- /dev/null +++ b/src/commands/local/configure.rs @@ -0,0 +1,204 @@ +//! `hops local configure` — show or change machine Cluster settings. + +use super::gitops::{self, ClusterArgs}; +use super::local_state_dir; +use super::workbench::cluster_template; +use super::workbench::definition::ClusterOverrides; +use super::workbench::machine::{self, kube_context_for_name, MachineClusterRecord}; +use crate::commands::local::backend::kind; +use clap::Args; +use std::error::Error; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +#[derive(Args, Debug)] +pub struct ConfigureArgs { + /// Set a cluster field (`hostPath=...`, `localDomain=...`). Repeatable. + #[arg(long = "set", value_name = "KEY=VALUE")] + pub set: Vec, + + /// Apply restart/reset without prompting. + #[arg(long, default_value_t = false)] + pub yes: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApplyKind { + Write, + Restart, + Reset, +} + +pub fn run(args: &ConfigureArgs, overrides: ClusterOverrides<'_>) -> Result<(), Box> { + let state_dir = local_state_dir()?; + if args.set.is_empty() { + return print_config(&state_dir); + } + let mut record = machine::load(&state_dir)?.ok_or( + "No machine Cluster record. Run `hops local up` or `hops local init cluster` first.", + )?; + let mut apply = ApplyKind::Write; + for spec in &args.set { + let (key, value) = spec.split_once('=').ok_or_else(|| { + format!("invalid --set {spec:?}; expected KEY=VALUE (e.g. hostPath=/Users/me/dev)") + })?; + apply = apply.max_kind(apply_set(&mut record, key.trim(), value.trim())?); + } + if apply != ApplyKind::Write && !args.yes && !confirm(apply, &record)? { + println!("Aborted."); + return Ok(()); + } + let home = PathBuf::from(std::env::var("HOME")?); + let source = cluster_template::materialize( + &home, + None, + &record.name, + record.host_path.as_deref(), + record.local_domain.as_deref(), + )?; + record.source = source; + record.kube_context = kube_context_for_name(&record.name); + machine::save(&state_dir, &record)?; + println!( + "Wrote {} hostPath={}", + record.source.display(), + record + .host_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "$HOME".into()) + ); + match apply { + ApplyKind::Write => Ok(()), + ApplyKind::Restart | ApplyKind::Reset => { + if apply == ApplyKind::Reset && kind::cluster_exists() { + kind::destroy()?; + } + let cluster_args = ClusterArgs { + path: Some(record.source.clone()), + down: false, + once: true, + watch: false, + debounce: 1, + dry_run: false, + }; + let overrides = ClusterOverrides { + machine_name: Some(record.name.as_str()), + ..overrides + }; + gitops::run_cluster(&cluster_args, overrides) + } + } +} + +impl ApplyKind { + fn max_kind(self, other: Self) -> Self { + match (self, other) { + (ApplyKind::Reset, _) | (_, ApplyKind::Reset) => ApplyKind::Reset, + (ApplyKind::Restart, _) | (_, ApplyKind::Restart) => ApplyKind::Restart, + _ => ApplyKind::Write, + } + } +} + +fn apply_set( + record: &mut MachineClusterRecord, + key: &str, + value: &str, +) -> Result> { + match key { + "hostPath" | "mountRoot" | "host-path" => { + record.host_path = Some(machine::expand_host_path(value)?); + Ok(ApplyKind::Reset) + } + "localDomain" | "local-domain" => { + record.local_domain = Some(value.to_string()); + Ok(ApplyKind::Restart) + } + "name" => { + if value.trim().is_empty() { + return Err("name must not be empty".into()); + } + record.name = value.to_string(); + record.kube_context = kube_context_for_name(value); + Ok(ApplyKind::Reset) + } + other => Err(format!( + "unknown cluster setting {other:?}; known: hostPath, localDomain, name" + ) + .into()), + } +} + +fn confirm(apply: ApplyKind, record: &MachineClusterRecord) -> Result> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err("this change recreates or restarts the cluster; re-run with --yes".into()); + } + let prompt = match apply { + ApplyKind::Reset => format!( + "hostPath/name change recreates kind cluster '{}' (delete + up). Continue?", + record.name + ), + ApplyKind::Restart => format!( + "This restarts cluster '{}' to apply the new settings. Continue?", + record.name + ), + ApplyKind::Write => return Ok(true), + }; + Ok(dialoguer::Confirm::new() + .with_prompt(prompt) + .default(false) + .interact()?) +} + +fn print_config(state_dir: &Path) -> Result<(), Box> { + println!( + "files:\n record {}\n cluster ~/.gitops/local/cluster.yaml", + machine::record_path(state_dir).display() + ); + let Some(record) = machine::load(state_dir)? else { + println!("No machine Cluster yet. Run `hops local up`."); + return Ok(()); + }; + let host = record + .host_path + .as_ref() + .map(|path| path.display().to_string()) + .or_else(|| yaml_field(&record.source, "mountRoot")) + .unwrap_or_else(|| "$HOME".into()); + let domain = record + .local_domain + .clone() + .or_else(|| yaml_field(&record.source, "localDomain")) + .unwrap_or_else(|| "localhost".into()); + let provider = yaml_field(&record.source, "clusterProvider").unwrap_or_else(|| "kind".into()); + let docker = yaml_field(&record.source, "dockerProvider").unwrap_or_else(|| "dory".into()); + println!(" name {}", record.name); + println!(" kubeContext {}", record.kube_context); + println!(" hostPath {host}"); + println!(" clusterProvider {provider}"); + println!(" dockerProvider {docker}"); + println!(" localDomain {domain}"); + if let Some(chart) = yaml_nested(&record.source, &["controlPlane", "crossplane", "chart"]) { + let version = yaml_nested(&record.source, &["controlPlane", "crossplane", "version"]) + .unwrap_or_else(|| "-".into()); + println!(" crossplane {chart}:{version}"); + } + println!("reset required to change: hostPath, name, clusterProvider, dockerProvider"); + println!("restart required to change: localDomain"); + Ok(()) +} + +fn yaml_field(source: &Path, field: &str) -> Option { + yaml_nested(source, &[field]) +} + +fn yaml_nested(source: &Path, path: &[&str]) -> Option { + let raw = std::fs::read_to_string(source).ok()?; + let mut value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + value = value.get("spec")?.clone(); + for key in path { + value = value.get(*key)?.clone(); + } + value.as_str().map(ToOwned::to_owned) +} diff --git a/src/commands/local/dns.rs b/src/commands/local/dns.rs index 30f3563..62b7ce7 100644 --- a/src/commands/local/dns.rs +++ b/src/commands/local/dns.rs @@ -1,4 +1,4 @@ -//! Explicit opt-in direct access to Kubernetes Service FQDNs from the host. +//! `hops local fwd` — opt-in port-forwards for Kubernetes Service FQDNs. use super::local_state_dir; use super::workbench::net::{ diff --git a/src/commands/local/doctor.rs b/src/commands/local/doctor.rs index 35437bc..b8b75aa 100644 --- a/src/commands/local/doctor.rs +++ b/src/commands/local/doctor.rs @@ -18,6 +18,8 @@ pub fn run() -> Result<(), Box> { _ => log::info!("Checking local cluster setup (current kube context)..."), } + report_machine_cluster_warnings(); + let mut d = Doctor::new(); d.section("Crossplane"); @@ -101,6 +103,48 @@ pub fn run() -> Result<(), Box> { } } +fn report_machine_cluster_warnings() { + use super::workbench::definition::{self, DEFAULT_DEFINITION_FILE}; + use super::workbench::machine; + use std::io::{self, Write}; + + let names = super::backend::kind::list_cluster_names(); + if names.len() > 1 { + let _ = writeln!( + io::stderr(), + "warning: multiple hops-managed kind clusters are present ({}); happy path is one machine cluster. --cluster-name is an escape hatch.", + names.join(", ") + ); + } + if let Ok(state_dir) = super::local_state_dir() { + if let Ok(Some(record)) = machine::load(&state_dir) { + let cwd_yaml = std::env::current_dir() + .ok() + .map(|cwd| cwd.join(DEFAULT_DEFINITION_FILE)); + if let Some(path) = cwd_yaml.filter(|path| path.exists()) { + if let Ok(leaf) = definition::load_cluster_document_name(&path) { + if leaf != record.name { + let _ = writeln!( + io::stderr(), + "warning: {} names Cluster {leaf:?} but the machine cluster is {:?}; `hops local up` reconnects and does not create a second kind cluster.", + path.display(), + record.name + ); + } + } + } + if names.len() > 1 { + let _ = writeln!( + io::stderr(), + "warning: machine cluster is {:?}; extra clusters: {}", + record.name, + names.join(", ") + ); + } + } + } +} + /// What a bootstrapped provider should look like once `hops local start` ran. struct ProviderExpectation<'a> { title: &'a str, diff --git a/src/commands/local/down.rs b/src/commands/local/down.rs index 3e6523d..b186825 100644 --- a/src/commands/local/down.rs +++ b/src/commands/local/down.rs @@ -1,28 +1,61 @@ -//! `hops local down` — stop workspace host access, delivery, and optionally purge namespace. +//! `hops local down` — stop the machine Cluster, or one Environment with `--name`. +use super::gitops::{self, ClusterArgs}; +use super::local_state_dir; +use super::run_cmd; +use super::workbench::definition::ClusterOverrides; use super::workbench::delivery::stop_delivery_runtime; use super::workbench::ingress::stop_ingress_access; +use super::workbench::machine; use super::workbench::net::stop_host_access; use super::workbench::registry::{ activate_workspace_cluster, list_workspaces, load_workspace, namespace_for_name, remove_workspace, }; -use super::{local_state_dir, run_cmd}; use clap::Args; use std::error::Error; #[derive(Args, Debug)] pub struct DownArgs { - /// Workspace name (default: only workspace if exactly one registered). + /// Environment name. Without this flag, stop the machine Cluster. #[arg(long)] pub name: Option, - /// Delete the workspace namespace and labeled resources. + /// Delete the Environment namespace (requires `--name`). #[arg(long, default_value_t = false)] pub purge: bool, } -pub fn run(args: &DownArgs) -> Result<(), Box> { +pub fn run(args: &DownArgs, overrides: ClusterOverrides<'_>) -> Result<(), Box> { + if args.name.is_none() { + if args.purge { + return Err("`hops local down --purge` requires --name ".into()); + } + return down_machine_cluster(overrides); + } + down_environment(args) +} + +fn down_machine_cluster(overrides: ClusterOverrides<'_>) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let record = machine::load(&state_dir)? + .ok_or("No machine Cluster record; nothing to stop. Run `hops local up` first.")?; + let cluster_args = ClusterArgs { + path: Some(record.source.clone()), + down: true, + once: true, + watch: false, + debounce: 1, + dry_run: false, + }; + let overrides = ClusterOverrides { + machine_name: Some(&record.name), + ..overrides + }; + gitops::run_cluster(&cluster_args, overrides) +} + +fn down_environment(args: &DownArgs) -> Result<(), Box> { let state_dir = local_state_dir()?; let name = match &args.name { Some(n) => n.clone(), diff --git a/src/commands/local/env.rs b/src/commands/local/env.rs new file mode 100644 index 0000000..af818a5 --- /dev/null +++ b/src/commands/local/env.rs @@ -0,0 +1,448 @@ +//! `hops local env` — catalog discover / list / enable / disable. + +use super::gitops::{self, EnvironmentArgs}; +use super::local_state_dir; +use super::workbench::definition::ClusterOverrides; +use super::workbench::machine::{self, DEFAULT_MACHINE_CLUSTER_NAME}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const CATALOG_DIR: &str = "catalog"; + +#[derive(Args, Debug)] +pub struct EnvArgs { + #[command(subcommand)] + pub command: EnvCommands, +} + +#[derive(Subcommand, Debug)] +pub enum EnvCommands { + /// Copy discovered Environment documents into `~/.hops/local/catalog` (off) + Discover(DiscoverArgs), + /// List catalogued Environments + List, + /// Reconcile a catalogued Environment + Enable(NameArgs), + /// Unregister and prune a catalogued Environment + Disable(NameArgs), +} + +#[derive(Args, Debug)] +pub struct DiscoverArgs { + /// Root to scan. Defaults to cwd. `$HOME` is rejected. + #[arg(value_name = "PATH")] + pub path: Option, +} + +#[derive(Args, Debug)] +pub struct NameArgs { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogEntry { + /// Unique catalog id derived from the Environment file path. + #[serde(default)] + pub id: String, + /// Template `metadata.name` (may repeat across worktrees). + pub name: String, + /// Runtime / enable name (checkout or worktree label). Unique per source. + #[serde(default)] + pub runtime_name: String, + pub source: PathBuf, + pub enabled: bool, + #[serde(default)] + pub last_error: Option, +} + +pub fn run(args: &EnvArgs, overrides: ClusterOverrides<'_>) -> Result<(), Box> { + match &args.command { + EnvCommands::Discover(discover) => discover_into_catalog(discover), + EnvCommands::List => list_catalog(), + EnvCommands::Enable(name) => set_enabled(&name.name, true, overrides), + EnvCommands::Disable(name) => set_enabled(&name.name, false, overrides), + } +} + +pub fn catalog_dir(state_dir: &Path) -> PathBuf { + state_dir.join(CATALOG_DIR) +} + +pub fn load_entries(state_dir: &Path) -> Result, Box> { + let dir = catalog_dir(state_dir); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec = Vec::new(); + for file in fs::read_dir(&dir)? { + let file = file?; + let path = file.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&path)?; + let mut entry: CatalogEntry = serde_json::from_str(&raw)?; + fill_identity(&mut entry); + entries.push(entry); + } + entries.sort_by(|a, b| a.runtime_name.cmp(&b.runtime_name)); + Ok(entries) +} + +pub fn save_entry(state_dir: &Path, entry: &CatalogEntry) -> Result<(), Box> { + let dir = catalog_dir(state_dir); + fs::create_dir_all(&dir)?; + let path = dir.join(format!("{}.json", slug(&entry.id))); + fs::write(path, serde_json::to_string_pretty(entry)?)?; + Ok(()) +} + +fn fill_identity(entry: &mut CatalogEntry) { + if entry.id.is_empty() { + entry.id = catalog_id(&entry.source); + } + if entry.runtime_name.is_empty() { + entry.runtime_name = runtime_name_for_source(&entry.source); + } +} + +fn discover_into_catalog(args: &DiscoverArgs) -> Result<(), Box> { + let root = match &args.path { + Some(path) => path.clone(), + None => std::env::current_dir()?, + }; + let root = root.canonicalize().unwrap_or(root); + reject_home_crawl(&root)?; + let state_dir = local_state_dir()?; + let mut found = Vec::new(); + walk_gitops(&root, 0, 6, &mut found)?; + if found.is_empty() { + println!("No Environment documents found under {}", root.display()); + return Ok(()); + } + for source in found { + let template_name = environment_name_from_file(&source).unwrap_or_else(|| { + source + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or("environment") + .to_string() + }); + let mut entry = CatalogEntry { + id: catalog_id(&source), + name: template_name, + runtime_name: runtime_name_for_source(&source), + source: source.clone(), + enabled: false, + last_error: None, + }; + if let Some(existing) = load_entries(&state_dir)? + .into_iter() + .find(|item| item.source == entry.source) + { + entry.enabled = existing.enabled; + entry.last_error = existing.last_error; + } + save_entry(&state_dir, &entry)?; + println!( + "catalogued {} [{}] ({}) enabled={}", + entry.runtime_name, + entry.name, + entry.source.display(), + entry.enabled + ); + } + Ok(()) +} + +fn list_catalog() -> Result<(), Box> { + let entries = load_entries(&local_state_dir()?)?; + if entries.is_empty() { + println!("No catalogued Environments. Run `hops local env discover`."); + return Ok(()); + } + for entry in entries { + let flag = if entry.enabled { "on " } else { "off" }; + println!( + "[{flag}] {:<40} {}", + entry.runtime_name, + entry.source.display() + ); + } + Ok(()) +} + +fn set_enabled( + name: &str, + enabled: bool, + overrides: ClusterOverrides<'_>, +) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let mut entries = load_entries(&state_dir)?; + let index = resolve_catalog_index(&entries, name)?; + let machine = machine::load(&state_dir)?; + let machine_name = machine + .as_ref() + .map(|record| record.name.as_str()) + .unwrap_or(DEFAULT_MACHINE_CLUSTER_NAME); + let overrides = ClusterOverrides { + machine_name: Some(machine_name), + ..overrides + }; + let entry = &mut entries[index]; + if !enabled && !entry.enabled { + println!("already disabled {}", entry.runtime_name); + return Ok(()); + } + let env_args = EnvironmentArgs { + path: Some(entry.source.clone()), + down: !enabled, + namespace: None, + name: Some(entry.runtime_name.clone()), + once: true, + watch: false, + debounce: 1, + dry_run: false, + }; + let result = gitops::run_environment_command( + &gitops::GitopsArgs { + command: gitops::GitopsCommands::Environment(env_args), + }, + overrides, + ); + match result { + Ok(()) => { + entry.enabled = enabled; + entry.last_error = None; + save_entry(&state_dir, entry)?; + println!( + "{} {}", + if enabled { "enabled" } else { "disabled" }, + entry.runtime_name + ); + Ok(()) + } + Err(error) => { + entry.last_error = Some(error.to_string()); + save_entry(&state_dir, entry)?; + Err(error) + } + } +} + +fn reject_home_crawl(root: &Path) -> Result<(), Box> { + if let Ok(home) = std::env::var("HOME") { + let home = PathBuf::from(home); + if let (Ok(root), Ok(home)) = (root.canonicalize(), home.canonicalize()) { + if root == home { + return Err( + "refusing to crawl $HOME; pass an explicit project or meta root".into(), + ); + } + } + } + Ok(()) +} + +fn walk_gitops( + dir: &Path, + depth: usize, + max_depth: usize, + found: &mut Vec, +) -> io::Result<()> { + if depth > max_depth { + return Ok(()); + } + let skip = matches!( + dir.file_name().and_then(|name| name.to_str()), + Some(".git" | "node_modules" | "target" | ".kb") + ); + if skip && depth > 0 { + return Ok(()); + } + collect_environment_documents(&dir.join(".gitops/local"), found); + if depth == max_depth { + return Ok(()); + } + let read = match fs::read_dir(dir) { + Ok(read) => read, + Err(_) => return Ok(()), + }; + for child in read.flatten() { + let path = child.path(); + if path.is_dir() { + walk_gitops(&path, depth + 1, max_depth, found)?; + } + } + Ok(()) +} + +fn collect_environment_documents(local_dir: &Path, found: &mut Vec) { + let read = match fs::read_dir(local_dir) { + Ok(read) => read, + Err(_) => return, + }; + for entry in read.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("yaml") { + continue; + } + if path.file_name().and_then(|name| name.to_str()) == Some("cluster.yaml") { + continue; + } + if !path.is_file() { + continue; + } + if environment_kind(&path).as_deref() != Some("Environment") { + continue; + } + found.push(path); + } +} + +fn environment_kind(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + value.get("kind")?.as_str().map(ToOwned::to_owned) +} + +fn environment_scope_from_file(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + value + .get("spec")? + .get("scope")? + .as_str() + .map(ToOwned::to_owned) +} + +fn environment_name_from_file(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + value + .get("metadata")? + .get("name")? + .as_str() + .map(ToOwned::to_owned) +} + +fn resolve_catalog_index(entries: &[CatalogEntry], query: &str) -> Result> { + let exact: Vec = entries + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.runtime_name == query || entry.id == query || entry.source.as_os_str() == query + }) + .map(|(index, _)| index) + .collect(); + if exact.len() == 1 { + return Ok(exact[0]); + } + if exact.len() > 1 { + return Err(format!( + "Environment {query:?} is ambiguous; use a runtime name from `hops local env list`" + ) + .into()); + } + let by_template: Vec = entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.name == query) + .map(|(index, _)| index) + .collect(); + match by_template.as_slice() { + [index] => Ok(*index), + [] => Err(format!( + "Environment {query:?} is not in the catalog; run `hops local env discover`" + ) + .into()), + _ => Err(format!( + "template name {query:?} matches multiple Environments ({}); enable the runtime name from `hops local env list`", + by_template + .iter() + .map(|index| entries[*index].runtime_name.as_str()) + .collect::>() + .join(", ") + ) + .into()), + } +} + +fn catalog_id(source: &Path) -> String { + slug(&source.to_string_lossy()) +} + +fn runtime_name_for_source(source: &Path) -> String { + if environment_scope_from_file(source).as_deref() == Some("cluster") { + if let Some(name) = environment_name_from_file(source) { + let slug = slug(&name); + if !slug.is_empty() { + return slug; + } + } + } + let checkout = source.ancestors().nth(3).unwrap_or(source); + if let Some(label) = worktree_label(checkout) { + return slug(&label); + } + checkout + .file_name() + .and_then(|name| name.to_str()) + .map(slug) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "environment".to_string()) +} + +fn worktree_label(checkout: &Path) -> Option { + let mut parts = Vec::new(); + let mut seen = false; + for component in checkout.components() { + let name = component.as_os_str(); + if seen { + parts.push(name.to_string_lossy().into_owned()); + } + if name == ".worktrees" { + seen = true; + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("-")) + } +} + +fn slug(name: &str) -> String { + let slug: String = name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + slug.trim_matches('-').to_string() +} + +pub fn print_status_card(state_dir: &Path) -> io::Result<()> { + let machine = machine::load(state_dir).ok().flatten(); + match machine { + Some(record) => writeln!( + io::stdout(), + "Cluster {} ({}) source={}", + record.name, + record.kube_context, + record.source.display() + )?, + None => writeln!(io::stdout(), "Cluster: (none) — run `hops local up`")?, + } + Ok(()) +} diff --git a/src/commands/local/gitops.rs b/src/commands/local/gitops.rs index 225d10c..23daeae 100644 --- a/src/commands/local/gitops.rs +++ b/src/commands/local/gitops.rs @@ -35,7 +35,10 @@ use notify::{RecursiveMode, Watcher}; use serde_yaml::Value; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; +#[cfg(test)] use std::fs; +#[cfg(test)] +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -264,6 +267,9 @@ fn run_cluster_reconcile_loop( r.pruned.len(), r.errors.len() ); + for error in &r.errors { + log::warn!("cluster gitops: {error}"); + } if !r.errors.is_empty() && r.applied.is_empty() { return Err(format!( "cluster gitops failed ({} error(s)); first: {}", @@ -385,7 +391,7 @@ fn reconcile_cluster_environments( definition: &super::workbench::definition::LoadedDefinition, dry_run: bool, ) -> Result<(), Box> { - let environment_files = discover_environment_definitions(&definition.cluster.mount_root)?; + let environment_files = enabled_environment_sources()?; let kube_context = super::kube_context_from_env() .unwrap_or_else(|| format!("kind-{}", definition.cluster.name)); let mut errors = Vec::new(); @@ -456,6 +462,7 @@ fn reconcile_cluster_environments( app_delivery_host_paths: hosts, delivery_mode: Some(delivery_strategy.as_str().into()), dry_run, + run_setup: false, }; match reconcile_environment(loaded, &opts, &SystemHelm, &SystemKustomize, &SystemKubectl) { Ok(results) => { @@ -563,7 +570,32 @@ fn reconcile_cluster_environments_with_retry( Err(last_error.unwrap_or_else(|| "Environment reconcile failed".into())) } +fn enabled_environment_sources() -> Result, Box> { + Ok(super::env::load_entries(&local_state_dir()?)? + .into_iter() + .filter(|entry| entry.enabled) + .map(|entry| entry.source) + .collect()) +} + +fn is_home_path(path: &Path) -> bool { + let Ok(home) = std::env::var("HOME") else { + return false; + }; + let Ok(home) = PathBuf::from(home).canonicalize() else { + return false; + }; + path.canonicalize().ok().as_ref() == Some(&home) +} + +#[cfg(test)] fn discover_environment_definitions(root: &Path) -> Result, Box> { + if is_home_path(root) { + return Err( + "refusing to crawl $HOME for Environment definitions; enable Environments from the catalog (`hops local env enable`)" + .into(), + ); + } let mut found = Vec::new(); discover_environment_definitions_rec(root, &mut found)?; found.sort(); @@ -571,6 +603,7 @@ fn discover_environment_definitions(root: &Path) -> Result, Box, @@ -578,7 +611,12 @@ fn discover_environment_definitions_rec( if !directory.is_dir() { return Ok(()); } - for entry in fs::read_dir(directory)? { + let read = match fs::read_dir(directory) { + Ok(read) => read, + Err(error) if error.kind() == ErrorKind::PermissionDenied => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in read { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; @@ -590,7 +628,17 @@ fn discover_environment_definitions_rec( .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); - if matches!(name, ".git" | "node_modules" | "target" | "dist" | "build") { + if matches!( + name, + ".git" + | "node_modules" + | "target" + | "dist" + | "build" + | "Library" + | ".Trash" + | ".hops" + ) { continue; } discover_environment_definitions_rec(&path, found)?; @@ -700,6 +748,7 @@ fn run_environment_definition( app_delivery_host_paths, delivery_mode: Some(delivery_strategy.as_str().into()), dry_run: args.dry_run, + run_setup: !args.dry_run && !args.down, }; let reconcile = || -> Result<(), Box> { @@ -998,11 +1047,31 @@ where })?; watcher.watch(cluster, RecursiveMode::Recursive)?; - if project_root != cluster && project_root.is_dir() { + if project_root != cluster && project_root.is_dir() && !is_home_path(project_root) { watcher.watch(project_root, RecursiveMode::Recursive)?; + } else if is_home_path(project_root) { + log::info!( + "not watching Cluster.mountRoot $HOME; watching the Cluster tree and enabled Environments only" + ); + for source in enabled_environment_sources()? { + if let Some(parent) = source.parent() { + if parent.is_dir() { + if let Err(error) = watcher.watch(parent, RecursiveMode::Recursive) { + log::warn!("watch {}: {error}", parent.display()); + } + } + } + } + } + if let Some(secret_root) = secret_sync_root { + if secret_root.is_dir() && !secret_root.starts_with(cluster) { + if let Err(error) = watcher.watch(secret_root, RecursiveMode::Recursive) { + log::warn!("watch {}: {error}", secret_root.display()); + } + } } log::info!( - "Watching Cluster tree {}, project Environment/deploy paths, and configured secret inputs (debounce {}s). Ctrl+C to stop.", + "Watching Cluster tree {}, enabled Environment paths, and configured secret inputs (debounce {}s). Ctrl+C to stop.", cluster.display(), debounce_secs ); diff --git a/src/commands/local/init.rs b/src/commands/local/init.rs new file mode 100644 index 0000000..b8f2ed8 --- /dev/null +++ b/src/commands/local/init.rs @@ -0,0 +1,230 @@ +//! `hops local init` — write committed Cluster / platform / Environment files. + +use super::local_state_dir; +use super::workbench::definition::{ + DEFAULT_CROSSPLANE_CHART, DEFAULT_CROSSPLANE_VERSION, DEFAULT_DEFINITION_FILE, + DEFAULT_ENVIRONMENT_FILE, +}; +use super::workbench::machine::{self, DEFAULT_MACHINE_CLUSTER_NAME}; +use clap::{Args, Subcommand}; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Args, Debug)] +pub struct InitArgs { + #[command(subcommand)] + pub command: InitCommands, +} + +#[derive(Subcommand, Debug)] +pub enum InitCommands { + /// Write `.gitops/local/cluster.yaml` and the `cluster/` tree + Cluster(InitPathArgs), + /// Write `.gitops/local/platform.yaml` and platform charts + Platform(InitPathArgs), + /// Write `.gitops/local/environment.yaml` + Environment(InitPathArgs), +} + +#[derive(Args, Debug)] +pub struct InitPathArgs { + /// Directory to initialize (defaults to cwd). + #[arg(long)] + pub path: Option, + + /// Host directory bind-mounted into the kind node (Cluster.spec.mountRoot). + #[arg(long = "host-path")] + pub host_path: Option, + + /// Overwrite existing files. + #[arg(long, default_value_t = false)] + pub force: bool, +} + +pub fn run(args: &InitArgs) -> Result<(), Box> { + match &args.command { + InitCommands::Cluster(path) => init_cluster(path), + InitCommands::Platform(path) => init_platform(path), + InitCommands::Environment(path) => init_environment(path), + } +} + +fn target_root(args: &InitPathArgs) -> Result> { + match &args.path { + Some(path) => Ok(path.clone()), + None => Ok(std::env::current_dir()?), + } +} + +fn init_cluster(args: &InitPathArgs) -> Result<(), Box> { + let root = target_root(args)?; + let yaml = root.join(DEFAULT_DEFINITION_FILE); + let manifests = root.join(".gitops/local/cluster"); + fail_if_exists(&yaml, args.force)?; + fs::create_dir_all(&manifests)?; + let overlay_readme = manifests.join("README.md"); + if args.force || !overlay_readme.exists() { + fs::write( + overlay_readme, + "# Cluster overlay\n\n\ + Machine Cluster manifests come from hops-cli (`hops local up`).\n\ + Add extra YAML here; it overlays the CLI template by relative path.\n\ + Do not put shared app workloads here — use a cluster-scoped Environment.\n", + )?; + } + let host_path = match &args.host_path { + Some(path) => machine::expand_host_path(&path.display().to_string())?, + None => machine::prompt_host_path(&machine::default_host_path())?, + }; + let mount_root = mount_root_for_yaml(&host_path); + let body = format!( + r#"apiVersion: hops.local/v1alpha1 +kind: Cluster +metadata: + name: {name} +spec: + clusterProvider: kind + dockerProvider: dory + mountRoot: {mount_root} + manifests: + path: .gitops/local/cluster + controlPlane: + crossplane: + chart: {chart} + version: "{version}" +"#, + name = DEFAULT_MACHINE_CLUSTER_NAME, + chart = DEFAULT_CROSSPLANE_CHART, + version = DEFAULT_CROSSPLANE_VERSION, + ); + fs::create_dir_all(yaml.parent().unwrap())?; + fs::write(&yaml, body)?; + persist_host_path(&host_path)?; + println!("Wrote {}", yaml.display()); + println!("Wrote {}", manifests.display()); + println!("hostPath {}", host_path.display()); + Ok(()) +} + +fn mount_root_for_yaml(host_path: &Path) -> String { + let home = std::env::var("HOME") + .ok() + .and_then(|home| PathBuf::from(home).canonicalize().ok()); + let host = host_path + .canonicalize() + .unwrap_or_else(|_| host_path.to_path_buf()); + if home.as_ref() == Some(&host) { + "$HOME".to_string() + } else { + host.display().to_string() + } +} + +fn persist_host_path(host_path: &Path) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let Some(mut record) = machine::load(&state_dir)? else { + return Ok(()); + }; + record.host_path = Some(host_path.to_path_buf()); + machine::save(&state_dir, &record) +} + +fn init_environment(args: &InitPathArgs) -> Result<(), Box> { + let root = target_root(args)?; + let yaml = root.join(DEFAULT_ENVIRONMENT_FILE); + fail_if_exists(&yaml, args.force)?; + fs::create_dir_all(yaml.parent().unwrap())?; + let body = format!( + r#"apiVersion: hops.local/v1alpha1 +kind: Environment +metadata: + name: local +spec: + clusterRef: + name: {name} + root: . + values: + local: true + deploys: [] +"#, + name = DEFAULT_MACHINE_CLUSTER_NAME, + ); + fs::write(&yaml, body)?; + println!("Wrote {}", yaml.display()); + Ok(()) +} + +fn init_platform(args: &InitPathArgs) -> Result<(), Box> { + let root = target_root(args)?; + let yaml = root.join(".gitops/local/platform.yaml"); + fail_if_exists(&yaml, args.force)?; + fs::create_dir_all(root.join(".gitops/local/platform/minio/templates"))?; + fs::create_dir_all(root.join(".gitops/local/platform/mailpit/templates"))?; + fs::write( + &yaml, + format!( + r#"apiVersion: hops.local/v1alpha1 +kind: Environment +metadata: + name: hops-platform +spec: + scope: cluster + clusterRef: + name: {name} + root: . + namespace: hops-platform + deploys: + - path: .gitops/local/platform/minio + type: helm + - path: .gitops/local/platform/mailpit + type: helm +"#, + name = DEFAULT_MACHINE_CLUSTER_NAME, + ), + )?; + write_chart(&root.join(".gitops/local/platform/minio"), "minio", 9000)?; + write_chart( + &root.join(".gitops/local/platform/mailpit"), + "mailpit", + 8025, + )?; + println!("Wrote {}", yaml.display()); + Ok(()) +} + +fn write_chart(dir: &Path, name: &str, port: u16) -> Result<(), Box> { + fs::write( + dir.join("Chart.yaml"), + format!("apiVersion: v2\nname: {name}\nversion: 0.1.0\n"), + )?; + fs::write( + dir.join("templates/service.yaml"), + format!( + r#"apiVersion: v1 +kind: Service +metadata: + name: {name} +spec: + selector: + app.kubernetes.io/name: {name} + ports: + - name: http + port: {port} + targetPort: http +"# + ), + )?; + Ok(()) +} + +fn fail_if_exists(path: &Path, force: bool) -> Result<(), Box> { + if path.exists() && !force { + return Err(format!( + "{} already exists; pass --force to overwrite", + path.display() + ) + .into()); + } + Ok(()) +} diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index f40f569..0621e52 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -1,13 +1,16 @@ mod aws; pub mod backend; mod cloudflare; +mod configure; mod destroy; mod dns; mod doctor; mod down; +mod env; mod github; mod gitops; pub mod gitops_write; +mod init; mod install; mod listmonk; pub mod package_install; @@ -15,7 +18,9 @@ mod reset; mod resize; mod start; mod status; +mod tui; mod uninstall; +mod up; pub mod workbench; mod zitadel; @@ -145,14 +150,26 @@ pub enum LocalCommands { Start(start::StartArgs), /// 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 + /// Check Cluster health and report machine-cluster identity drift Doctor, - /// Bring down a local workbench workspace + /// Create or reconnect the one machine Cluster + Up(up::UpArgs), + /// Show or change machine Cluster settings (hostPath, localDomain, name) + Configure(configure::ConfigureArgs), + /// Stop the machine Cluster (no --name) or one Environment (`--name`) Down(down::DownArgs), - /// Show local workbench workspace status and app URLs + /// Write committed Cluster / platform / Environment files + Init(init::InitArgs), + /// Catalog, enable, and disable Environments (off until enable) + Env(env::EnvArgs), + /// Toggle catalogued Environments + #[command(name = "envs")] + Envs(tui::TuiArgs), + /// Show live cluster + workspace status (`--urls` for HTTPRoute URLs only) Status(status::StatusArgs), - /// Explicitly enable or repair direct Kubernetes Service DNS on this host - Dns(dns::DnsArgs), + /// Port-forward Kubernetes Service FQDNs onto this host + #[command(name = "fwd")] + Fwd(dns::DnsArgs), /// Local gitops: `cluster` (shared CP) or `environment` (app namespaces) Gitops(gitops::GitopsArgs), /// Configure crossplane-contrib provider-family-aws and AWS ProviderConfig @@ -172,21 +189,30 @@ pub enum LocalCommands { } pub fn run(args: &LocalArgs) -> Result<(), Box> { - if let LocalCommands::Gitops(gitops::GitopsArgs { - command: gitops::GitopsCommands::Cluster(cluster), - }) = &args.command - { - return gitops::run_cluster( - cluster, - workbench::definition::ClusterOverrides { - cluster_provider: args.cluster_provider, - docker_provider: args.docker_provider, - legacy_backend: args.backend, - cluster_name: args.cluster_name.as_deref(), - context: args.context.as_deref(), - dory_name: args.dory_name.as_deref(), - }, - ); + let overrides = workbench::definition::ClusterOverrides { + cluster_provider: args.cluster_provider, + docker_provider: args.docker_provider, + legacy_backend: args.backend, + cluster_name: args.cluster_name.as_deref(), + context: args.context.as_deref(), + dory_name: args.dory_name.as_deref(), + machine_name: None, + }; + match &args.command { + LocalCommands::Gitops(gitops::GitopsArgs { + command: gitops::GitopsCommands::Cluster(cluster), + }) => return gitops::run_cluster(cluster, overrides), + LocalCommands::Up(up_args) => return up::run(up_args, overrides), + LocalCommands::Configure(configure_args) => { + return configure::run(configure_args, overrides) + } + LocalCommands::Down(down_args) if down_args.name.is_none() => { + return down::run(down_args, overrides) + } + LocalCommands::Init(init_args) => return init::run(init_args), + LocalCommands::Env(env_args) => return env::run(env_args, overrides), + LocalCommands::Envs(tui_args) => return tui::run(tui_args, overrides), + _ => {} } // Observation and explicit Service-DNS access use each Environment's @@ -194,7 +220,7 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { // selection as a side effect of status/access inspection. match &args.command { LocalCommands::Status(status_args) => return status::run(status_args), - LocalCommands::Dns(dns_args) => return dns::run(dns_args), + LocalCommands::Fwd(dns_args) => return dns::run(dns_args), _ => {} } @@ -240,9 +266,9 @@ 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::Down(down_args) => down::run(down_args), - LocalCommands::Status(_) | LocalCommands::Dns(_) => { - unreachable!("status and dns return before provider activation") + LocalCommands::Down(down_args) => down::run(down_args, overrides), + LocalCommands::Status(_) | LocalCommands::Fwd(_) => { + unreachable!("status and fwd return before provider activation") } LocalCommands::Gitops(gitops_args) => gitops::run_environment_command( gitops_args, @@ -253,8 +279,16 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { cluster_name: args.cluster_name.as_deref(), context: args.context.as_deref(), dory_name: args.dory_name.as_deref(), + machine_name: None, }, ), + LocalCommands::Up(_) + | LocalCommands::Configure(_) + | LocalCommands::Init(_) + | LocalCommands::Env(_) + | LocalCommands::Envs(_) => { + unreachable!("up/configure/init/env/envs return before provider activation") + } LocalCommands::Aws(aws_args) => aws::run(aws_args), LocalCommands::Cloudflare(cloudflare_args) => cloudflare::run(cloudflare_args), LocalCommands::Github(github_args) => github::run(github_args), @@ -654,7 +688,17 @@ mod tests { other => panic!("expected GitOps Cluster, got {other:?}"), } - for removed in ["up", "open", "stop"] { + match Cli::try_parse_from(["hops-local-test", "up"]).expect("parse local up") { + Cli { + local: + LocalArgs { + command: LocalCommands::Up(_), + .. + }, + } => {} + other => panic!("expected local up, got {other:?}"), + } + for removed in ["open", "stop"] { assert!( Cli::try_parse_from(["hops-local-test", removed]).is_err(), "interim command {removed:?} must stay removed" @@ -679,18 +723,32 @@ mod tests { LocalCommands::Status(status) => { assert_eq!(status.name.as_deref(), Some("feature")); assert!(!status.no_heal); + assert!(!status.urls); + assert!(!status.all); } other => panic!("expected status, got {other:?}"), } - let dns = Cli::try_parse_from(["hops-local-test", "dns", "--name", "feature", "--down"]) - .expect("parse explicit Service DNS teardown"); - match dns.local.command { - LocalCommands::Dns(dns) => { + let fwd = Cli::try_parse_from(["hops-local-test", "fwd", "--name", "feature", "--down"]) + .expect("parse explicit Service port-forward teardown"); + match fwd.local.command { + LocalCommands::Fwd(dns) => { assert_eq!(dns.name.as_deref(), Some("feature")); assert!(dns.down); } - other => panic!("expected dns, got {other:?}"), + other => panic!("expected fwd, got {other:?}"), + } + let envs = Cli::try_parse_from(["hops-local-test", "envs"]).expect("parse envs"); + match envs.local.command { + LocalCommands::Envs(_) => {} + other => panic!("expected envs, got {other:?}"), + } + + let urls = + Cli::try_parse_from(["hops-local-test", "status", "--urls"]).expect("parse urls"); + match urls.local.command { + LocalCommands::Status(status) => assert!(status.urls), + other => panic!("expected status --urls, got {other:?}"), } } } diff --git a/src/commands/local/status.rs b/src/commands/local/status.rs index e09a1ee..dbde7c1 100644 --- a/src/commands/local/status.rs +++ b/src/commands/local/status.rs @@ -2,15 +2,19 @@ use super::workbench::ingress::{ discover_ingress_routes, format_ingress_status, ingress_access_matches_plan, - load_ingress_access_runtime, plan_from_routes, IngressAccessRuntime, + ingress_routes_from_value, load_ingress_access_runtime, plan_from_routes, IngressAccessRuntime, }; +use super::workbench::machine; use super::workbench::net::{ format_status_card_with_listen, host_access_needs_heal, host_access_status_line, load_host_access_runtime, plan_from_runtime as host_plan_from_runtime, url_listen_status, }; -use super::workbench::registry::{activate_workspace_cluster, list_workspaces, load_workspace}; -use super::{local_state_dir, run_cmd_output}; +use super::workbench::registry::{ + activate_workspace_cluster, list_workspaces, load_workspace, WorkspaceRecord, +}; +use super::{local_state_dir, run_cmd_output, HOPS_KUBE_CONTEXT_ENV}; use clap::Args; +use std::collections::BTreeSet; use std::error::Error; use std::path::Path; @@ -20,6 +24,14 @@ pub struct StatusArgs { #[arg(long)] pub name: Option, + /// Print only public *.localhost URLs from HTTPRoutes. + #[arg(long, default_value_t = false)] + pub urls: bool, + + /// Include stale workspaces and missing kube contexts. + #[arg(long, default_value_t = false)] + pub all: bool, + /// Deprecated compatibility flag; status is always read-only. #[arg(long, default_value_t = false, hide = true)] pub no_heal: bool, @@ -46,6 +58,212 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { return Ok(()); } + if args.urls { + return print_urls(&workspaces, args.all); + } + + print_cluster_section(&state_dir)?; + println!(); + + if args.all { + return print_verbose(&state_dir, &workspaces, args); + } + + let mut all_ok = true; + let mut shown = 0usize; + for ws in workspaces.iter() { + if args.name.is_none() && !workspace_is_live(ws) { + continue; + } + let _ = activate_workspace_cluster(ws); + let pods = match discover_pods(&ws.namespace) { + Ok(pods) => pods, + Err(_) => { + if args.name.is_some() { + println!("{}: cluster unreachable", ws.name); + all_ok = false; + } + continue; + } + }; + let running = pods.iter().any(|p| p.phase == "Running"); + if args.name.is_none() && !running { + continue; + } + let urls = discover_ingress_routes(&ws.namespace) + .ok() + .and_then(|routes| plan_from_routes(&ws.namespace, &routes).ok()) + .map(|plan| plan.urls.into_values().collect::>()) + .unwrap_or_default(); + if shown > 0 { + println!(); + } + shown += 1; + let ready = pods.iter().filter(|p| p.ready).count(); + let total = pods + .iter() + .filter(|p| p.phase == "Running" || p.phase == "Pending") + .count(); + let cluster = ws.cluster_name.as_deref().unwrap_or("-"); + println!("{} {cluster} {ready}/{total} ready", ws.name); + if urls.is_empty() { + println!(" (no public URLs)"); + } else { + for url in &urls { + println!(" {url}"); + } + } + for p in pods.iter().filter(|p| p.phase == "Running" && !p.ready) { + all_ok = false; + println!( + " not ready: {} {}/{}", + p.name, p.ready_containers, p.total_containers + ); + } + if !running { + all_ok = false; + println!(" (no running pods)"); + } + } + + if shown == 0 { + println!( + "No running workspaces. Pass --all for stale records, or --urls for HTTPRoute URLs." + ); + } + + if args.check && !all_ok { + return Err("one or more workspaces are not ready (see above)".into()); + } + Ok(()) +} + +fn print_cluster_section(state_dir: &Path) -> Result<(), Box> { + let Some(record) = machine::load(state_dir)? else { + println!("cluster (none) run `hops local up`"); + return Ok(()); + }; + println!("cluster {} {}", record.name, record.kube_context); + if let Some(host_path) = cluster_host_path(&record.source) { + println!(" hostPath {}", host_path.display()); + } + std::env::set_var(HOPS_KUBE_CONTEXT_ENV, &record.kube_context); + if let Ok(nodes) = kubectl_json(&["get", "nodes", "-o", "json"]) { + for item in items(&nodes) { + let name = meta_name(item); + let version = item + .pointer("/status/nodeInfo/kubeletVersion") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let ready = condition_ready(item, "Ready"); + println!(" node {name} {version} {ready}"); + } + } else { + println!(" (cluster unreachable)"); + return Ok(()); + } + if let Ok(authstacks) = kubectl_json(&["get", "authstack", "-A", "-o", "json"]) { + for item in items(&authstacks) { + let name = meta_name(item); + let ready = condition_ready(item, "Ready"); + println!(" authstack {name} {ready}"); + } + } + if let Ok(configs) = kubectl_json(&["get", "configurations.pkg.crossplane.io", "-o", "json"]) { + for item in items(&configs) { + let name = meta_name(item); + let package = package_ref(item); + let ready = pkg_ready(item); + println!(" configuration {name} {package} {ready}"); + } + } + if let Ok(providers) = kubectl_json(&["get", "providers.pkg.crossplane.io", "-o", "json"]) { + for item in items(&providers) { + let name = meta_name(item); + let package = package_ref(item); + let ready = pkg_ready(item); + println!(" provider {name} {package} {ready}"); + } + } + Ok(()) +} + +fn cluster_host_path(source: &Path) -> Option { + let raw = std::fs::read_to_string(source).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + let mount = value.get("spec")?.get("mountRoot")?.as_str()?; + if mount == "$HOME" || mount == "~" { + return std::env::var("HOME") + .ok() + .and_then(|home| std::path::PathBuf::from(home).canonicalize().ok()); + } + let path = std::path::PathBuf::from(mount); + path.canonicalize().ok().or(Some(path)) +} + +fn kubectl_json(args: &[&str]) -> Result> { + let raw = run_cmd_output("kubectl", args)?; + Ok(serde_json::from_str(&raw)?) +} + +fn items(value: &serde_json::Value) -> &[serde_json::Value] { + value + .get("items") + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()) + .unwrap_or(&[]) +} + +fn meta_name(item: &serde_json::Value) -> &str { + item.pointer("/metadata/name") + .and_then(|v| v.as_str()) + .unwrap_or("-") +} + +fn package_ref(item: &serde_json::Value) -> String { + let raw = item + .pointer("/spec/package") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + raw.rsplit('/').next().unwrap_or(raw).to_string() +} + +fn condition_ready(item: &serde_json::Value, ty: &str) -> &'static str { + let Some(conditions) = item + .pointer("/status/conditions") + .and_then(|v| v.as_array()) + else { + return "-"; + }; + for condition in conditions { + if condition.get("type").and_then(|v| v.as_str()) == Some(ty) { + return if condition.get("status").and_then(|v| v.as_str()) == Some("True") { + "Ready" + } else { + "NotReady" + }; + } + } + "-" +} + +fn pkg_ready(item: &serde_json::Value) -> &'static str { + let healthy = condition_ready(item, "Healthy"); + let installed = condition_ready(item, "Installed"); + if healthy == "Ready" && installed == "Ready" { + "Ready" + } else if installed == "Ready" { + "Installed" + } else { + "NotReady" + } +} + +fn print_verbose( + state_dir: &Path, + workspaces: &[WorkspaceRecord], + args: &StatusArgs, +) -> Result<(), Box> { let mut all_ok = true; for (i, ws) in workspaces.iter().enumerate() { if i > 0 { @@ -55,9 +273,8 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { 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 host_access = load_host_access_runtime(&state_dir, &ws.name)?; + let host_access = load_host_access_runtime(state_dir, &ws.name)?; let listen = if let Some(runtime) = &host_access { let plan = host_plan_from_runtime(runtime); let listen = url_listen_status(&plan); @@ -72,11 +289,10 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { } else { println!("workspace: {}", ws.name); println!("namespace: {}", ws.namespace); - println!("service access: disabled (enable explicitly with `hops local dns`)"); + println!("service access: disabled (enable explicitly with `hops local fwd`)"); Default::default() }; - // Pods match discover_pods(&ws.namespace) { Ok(pods) if !pods.is_empty() => { println!("pods:"); @@ -104,23 +320,19 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { if let Some(d) = &ws.delivery_mode { println!("delivery: {d}"); } - println!("{}", delivery_status_line(&state_dir, &ws.name)); + println!("{}", delivery_status_line(state_dir, &ws.name)); println!("env: {}", ws.env_path); if let Some(rt) = &host_access { println!("{}", host_access_status_line(&rt)); } - let ingress_runtime = load_ingress_access_runtime(&state_dir, &ws.name)?; + let ingress_runtime = load_ingress_access_runtime(state_dir, &ws.name)?; match discover_ingress_routes(&ws.namespace) { Ok(routes) => match plan_from_routes(&ws.namespace, &routes) { Ok(plan) => { if plan.urls.is_empty() { println!("ingress: (no HTTPRoute hostnames)"); - if ingress_runtime.is_some() { - all_ok = false; - println!("warn: stale ingress runtime is still recorded"); - } } else if let Some(runtime) = &ingress_runtime { if !ingress_access_matches_plan(&plan, runtime) { all_ok = false; @@ -155,13 +367,73 @@ pub fn run(args: &StatusArgs) -> Result<(), Box> { } } } - if args.check && !all_ok { return Err("one or more workspaces are not ready (see above)".into()); } Ok(()) } +fn print_urls(workspaces: &[WorkspaceRecord], all: bool) -> Result<(), Box> { + let mut seen_ctx = BTreeSet::new(); + let mut urls = BTreeSet::new(); + for ws in workspaces { + if !all && !workspace_is_live(ws) { + continue; + } + let ctx = ws.kube_context.as_deref().unwrap_or(""); + if ctx.is_empty() || !seen_ctx.insert(ctx.to_string()) { + continue; + } + if !kube_context_exists(ctx) { + continue; + } + let _ = activate_workspace_cluster(ws); + match run_cmd_output("kubectl", &["get", "httproute", "-A", "-o", "json"]) { + Ok(json) => { + let value: serde_json::Value = serde_json::from_str(&json)?; + for route in ingress_routes_from_value("", &value) { + if route.hostname.ends_with(".localhost") { + urls.insert(format!("https://{}", route.hostname)); + } + } + } + Err(_) => continue, + } + } + if urls.is_empty() { + if let Ok(json) = run_cmd_output("kubectl", &["get", "httproute", "-A", "-o", "json"]) { + if let Ok(value) = serde_json::from_str::(&json) { + for route in ingress_routes_from_value("", &value) { + if route.hostname.ends_with(".localhost") { + urls.insert(format!("https://{}", route.hostname)); + } + } + } + } + } + if urls.is_empty() { + println!("(no HTTPRoute *.localhost hostnames on live clusters)"); + return Ok(()); + } + for url in urls { + println!("{url}"); + } + Ok(()) +} + +fn workspace_is_live(ws: &WorkspaceRecord) -> bool { + match ws.kube_context.as_deref().filter(|ctx| !ctx.is_empty()) { + Some(ctx) => kube_context_exists(ctx), + None => true, + } +} + +fn kube_context_exists(ctx: &str) -> bool { + run_cmd_output("kubectl", &["config", "get-contexts", "-o", "name"]) + .ok() + .is_some_and(|out| out.lines().any(|line| line.trim() == ctx)) +} + #[derive(Debug)] struct PodStatus { name: String, diff --git a/src/commands/local/tui.rs b/src/commands/local/tui.rs new file mode 100644 index 0000000..5d547e3 --- /dev/null +++ b/src/commands/local/tui.rs @@ -0,0 +1,126 @@ +//! `hops local envs` — catalog view that toggles through the env engine. + +use super::env::{self, CatalogEntry}; +use super::local_state_dir; +use super::workbench::definition::ClusterOverrides; +use clap::Args; +use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use std::error::Error; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const BOLD: &str = "\x1b[1m"; +const RESET: &str = "\x1b[0m"; + +#[derive(Args, Debug, Default)] +pub struct TuiArgs { + /// Print the catalog and exit (no interactive toggle). + #[arg(long, default_value_t = false)] + pub once: bool, +} + +pub fn run(args: &TuiArgs, overrides: ClusterOverrides<'_>) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let entries = env::load_entries(&state_dir)?; + if entries.is_empty() { + println!("No catalogued Environments. Run `hops local env discover`."); + return Ok(()); + } + let mount_root = host_path(); + if args.once || !atty() { + print_entries(&entries, &mount_root)?; + return Ok(()); + } + apply_multiselect(&entries, &mount_root, overrides) +} + +fn host_path() -> PathBuf { + std::env::var("HOME") + .ok() + .and_then(|home| PathBuf::from(home).canonicalize().ok()) + .unwrap_or_else(|| PathBuf::from("/")) +} + +fn worktree_label(source: &Path, mount_root: &Path) -> String { + let checkout = source + .ancestors() + .nth(3) + .filter(|_| { + source + .components() + .rev() + .nth(1) + .is_some_and(|c| c.as_os_str() == "local") + && source + .components() + .rev() + .nth(2) + .is_some_and(|c| c.as_os_str() == ".gitops") + }) + .unwrap_or_else(|| source.parent().unwrap_or(source)); + let rel = checkout + .strip_prefix(mount_root) + .unwrap_or(checkout) + .display() + .to_string(); + match source.file_stem().and_then(|s| s.to_str()) { + Some("environment") | None => rel, + Some(stem) => format!("{rel} {stem}"), + } +} + +fn styled_label(entry: &CatalogEntry, mount_root: &Path) -> String { + let path = worktree_label(&entry.source, mount_root); + if entry.enabled { + format!("{BOLD}{path}{RESET}") + } else { + path + } +} + +fn print_entries(entries: &[CatalogEntry], mount_root: &Path) -> io::Result<()> { + for entry in entries { + writeln!(io::stdout(), "{}", styled_label(entry, mount_root))?; + } + Ok(()) +} + +fn apply_multiselect( + entries: &[CatalogEntry], + mount_root: &Path, + overrides: ClusterOverrides<'_>, +) -> Result<(), Box> { + let items: Vec = entries + .iter() + .map(|entry| styled_label(entry, mount_root)) + .collect(); + let defaults: Vec = entries.iter().map(|entry| entry.enabled).collect(); + let selected = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Environments (space toggles, enter applies)") + .items(&items) + .defaults(&defaults) + .interact()?; + let selected: std::collections::BTreeSet = selected.into_iter().collect(); + for (index, entry) in entries.iter().enumerate() { + let want = selected.contains(&index); + if want == entry.enabled { + continue; + } + let command = if want { + env::EnvCommands::Enable(env::NameArgs { + name: entry.runtime_name.clone(), + }) + } else { + env::EnvCommands::Disable(env::NameArgs { + name: entry.runtime_name.clone(), + }) + }; + env::run(&env::EnvArgs { command }, ClusterOverrides { ..overrides })?; + } + Ok(()) +} + +fn atty() -> bool { + use std::io::IsTerminal; + io::stdin().is_terminal() && io::stdout().is_terminal() +} diff --git a/src/commands/local/up.rs b/src/commands/local/up.rs new file mode 100644 index 0000000..f11dcf8 --- /dev/null +++ b/src/commands/local/up.rs @@ -0,0 +1,151 @@ +//! `hops local up` — create or reconnect the one machine Cluster. + +use super::gitops::{self, ClusterArgs}; +use super::local_state_dir; +use super::workbench::cluster_template; +use super::workbench::definition::{self, ClusterOverrides, DEFAULT_DEFINITION_FILE}; +use super::workbench::machine::{ + self, kube_context_for_name, MachineClusterRecord, DEFAULT_MACHINE_CLUSTER_NAME, +}; +use clap::Args; +use std::error::Error; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Args, Debug)] +pub struct UpArgs { + /// Optional overlay Cluster document. The machine profile is always + /// `$HOME/.gitops/local/cluster.yaml` (CLI template + project extras). + #[arg(value_name = "PATH")] + pub path: Option, + + /// Run a single reconcile and exit (disables the default watch). + #[arg(long, default_value_t = false)] + pub once: bool, + + /// Server/client dry-run; do not persist to the cluster. + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +pub fn run(args: &UpArgs, overrides: ClusterOverrides<'_>) -> Result<(), Box> { + let state_dir = local_state_dir()?; + let cwd = std::env::current_dir()?; + let escape = overrides + .cluster_name + .map(str::trim) + .filter(|name| !name.is_empty()); + if escape.is_some() { + warn_escape_hatch()?; + } + + let record = machine::load(&state_dir)?; + let machine_name = escape + .map(str::to_string) + .or_else(|| record.as_ref().map(|record| record.name.clone())) + .unwrap_or_else(|| DEFAULT_MACHINE_CLUSTER_NAME.to_string()); + + let cwd_yaml = cwd.join(DEFAULT_DEFINITION_FILE); + let overlay = args + .path + .clone() + .or_else(|| cwd_yaml.exists().then_some(cwd_yaml.clone())); + let home = + PathBuf::from(std::env::var("HOME").map_err(|_| "HOME is required for hops local up")?); + let host_path = resolve_up_host_path(record.as_ref(), &home)?; + let local_domain = record + .as_ref() + .and_then(|record| record.local_domain.clone()); + let source = cluster_template::materialize( + &home, + overlay.as_deref(), + &machine_name, + Some(&host_path), + local_domain.as_deref(), + )?; + + if cwd_yaml.exists() && args.path.is_none() { + if let Ok(leaf) = definition::load_cluster_document_name(&cwd_yaml) { + if leaf != machine_name { + warn_leaf_name(&leaf, &machine_name, &cwd_yaml)?; + } + } + } + + warn_multiple_kind_clusters(&machine_name)?; + + let record = MachineClusterRecord { + name: machine_name.clone(), + kube_context: kube_context_for_name(&machine_name), + source: source.clone(), + host_path: Some(host_path), + local_domain, + }; + if !args.dry_run { + machine::save(&state_dir, &record)?; + } + + let cluster_args = ClusterArgs { + path: Some(source), + down: false, + once: args.once, + watch: false, + debounce: 1, + dry_run: args.dry_run, + }; + let overrides = ClusterOverrides { + machine_name: Some(record.name.as_str()), + ..overrides + }; + gitops::run_cluster(&cluster_args, overrides) +} + +fn resolve_up_host_path( + record: Option<&MachineClusterRecord>, + home: &Path, +) -> Result> { + if let Some(path) = record.and_then(|record| record.host_path.clone()) { + return Ok(path); + } + if let Some(record) = record { + if let Some(path) = host_path_from_cluster_yaml(&record.source) { + return Ok(path); + } + return Ok(home.canonicalize().unwrap_or_else(|_| home.to_path_buf())); + } + machine::prompt_host_path(&machine::default_host_path()) +} + +fn host_path_from_cluster_yaml(source: &Path) -> Option { + let raw = std::fs::read_to_string(source).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + let mount = value.get("spec")?.get("mountRoot")?.as_str()?; + machine::expand_host_path(mount).ok() +} + +fn warn_escape_hatch() -> io::Result<()> { + writeln!( + io::stderr(), + "warning: --cluster-name is an escape hatch; the happy path is one machine cluster (`hops local up`)." + ) +} + +fn warn_leaf_name(leaf: &str, machine: &str, path: &Path) -> io::Result<()> { + writeln!( + io::stderr(), + "warning: {} names Cluster {leaf:?} but the machine cluster is {machine:?}; reconnecting does not create a second kind cluster.", + path.display() + ) +} + +fn warn_multiple_kind_clusters(machine: &str) -> Result<(), Box> { + let names = super::backend::kind::list_cluster_names(); + if names.len() > 1 { + writeln!( + io::stderr(), + "warning: multiple hops-managed kind clusters are present ({}); happy path is one machine cluster {machine:?}. --cluster-name is an escape hatch.", + names.join(", ") + )?; + } + Ok(()) +} diff --git a/src/commands/local/workbench/cluster_template.rs b/src/commands/local/workbench/cluster_template.rs new file mode 100644 index 0000000..a2af726 --- /dev/null +++ b/src/commands/local/workbench/cluster_template.rs @@ -0,0 +1,447 @@ +//! CLI-owned local Cluster template. +//! +//! `hops local up` materializes this embed to `$HOME/.gitops/local/cluster` +//! (the path `Cluster.spec.mountRoot: $HOME` + `manifests.path: +//! .gitops/local/cluster` already resolves to). Project +//! `.gitops/local/cluster/` extras overlay on top. `shared/` and Harmony +//! identity fixtures are never copied into Cluster manifests. + +use super::definition::{ + CLUSTER_MANIFESTS_PATH, DEFAULT_CROSSPLANE_CHART, DEFAULT_CROSSPLANE_VERSION, + DEFAULT_LOCAL_DOMAIN, +}; +use super::machine::DEFAULT_MACHINE_CLUSTER_NAME; +use serde_yaml::Value; +use std::error::Error; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +pub const MANAGED_MARKER: &str = ".hops-managed"; + +macro_rules! cluster_file { + ($path:literal) => { + ( + $path, + include_str!(concat!("../../../../templates/local/cluster/", $path)), + ) + }; +} + +pub const FILES: &[(&str, &str)] = &[ + cluster_file!("README.md"), + cluster_file!("SECRETS.md"), + cluster_file!("configurations/auth-stack.yaml"), + cluster_file!("configurations/gateway-api-stack.yaml"), + cluster_file!("configurations/istio-stack.yaml"), + cluster_file!("configurations/psql-stack.yaml"), + cluster_file!("configurations/secret-stack.yaml"), + cluster_file!("providerconfigs/helm.yaml"), + cluster_file!("providerconfigs/kubernetes.yaml"), + cluster_file!("providerconfigs/zitadel.yaml"), + cluster_file!("providers/00-namespaces.yaml"), + cluster_file!("providers/helm-drc.yaml"), + cluster_file!("providers/helm.yaml"), + cluster_file!("providers/kubernetes-drc.yaml"), + cluster_file!("providers/kubernetes.yaml"), + cluster_file!("providers/zitadel.yaml"), + cluster_file!("secrets/stack.yaml"), + cluster_file!("secrets/vault-auth-delegator.yaml"), + cluster_file!("stacks/auth.yaml"), + cluster_file!("stacks/gateway-api.yaml"), + cluster_file!("stacks/istio-gateway-defaults.yaml"), + cluster_file!("stacks/istio.yaml"), + cluster_file!("stacks/psql.yaml"), +]; + +/// Materialize the CLI template plus optional project overlay. +/// +/// Returns the Cluster document path (`$HOME/.gitops/local/cluster.yaml`). +pub fn materialize( + home: &Path, + overlay_cluster_yaml: Option<&Path>, + machine_name: &str, + host_path: Option<&Path>, + local_domain: Option<&str>, +) -> Result> { + let home = home.canonicalize().map_err(|error| { + format!( + "unable to canonicalize HOME for cluster template {}: {error}", + home.display() + ) + })?; + let gitops_local = home.join(".gitops/local"); + let manifests = gitops_local.join("cluster"); + let yaml_path = gitops_local.join("cluster.yaml"); + prepare_manifests_dir(&manifests)?; + write_embed(&manifests)?; + if let Some(overlay) = overlay_cluster_yaml { + if let Some(parent) = overlay.parent() { + let extras = parent.join("cluster"); + if extras.is_dir() { + overlay_manifests(&extras, &manifests)?; + } + } + } + fs::write( + manifests.join(MANAGED_MARKER), + "Materialized by hops local up from the CLI local-cluster template.\n\ + Project extras overlay from /.gitops/local/cluster/. Do not edit in place.\n", + )?; + let body = cluster_document_yaml( + machine_name, + overlay_cluster_yaml, + &home, + host_path.unwrap_or(&home), + local_domain, + )?; + fs::write(&yaml_path, body)?; + Ok(yaml_path) +} + +fn prepare_manifests_dir(manifests: &Path) -> Result<(), Box> { + if manifests.exists() { + let marker = manifests.join(MANAGED_MARKER); + if !marker.is_file() { + return Err(format!( + "{} exists and is not hops-managed; move it aside before `hops local up` (expected {MANAGED_MARKER})", + manifests.display() + ) + .into()); + } + fs::remove_dir_all(manifests).map_err(|error| { + format!( + "unable to refresh cluster template {}: {error}", + manifests.display() + ) + })?; + } + fs::create_dir_all(manifests)?; + Ok(()) +} + +fn write_embed(manifests: &Path) -> Result<(), Box> { + for (relative, contents) in FILES { + let dest = manifests.join(relative); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&dest, contents) + .map_err(|error| format!("write cluster template {}: {error}", dest.display()))?; + } + Ok(()) +} + +fn overlay_manifests(src: &Path, dest: &Path) -> Result<(), Box> { + overlay_walk(src, src, dest) +} + +fn overlay_walk(root: &Path, dir: &Path, dest: &Path) -> Result<(), Box> { + let read = match fs::read_dir(dir) { + Ok(read) => read, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in read { + let entry = entry?; + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(&path); + if should_skip_overlay(rel) { + continue; + } + if path.is_dir() { + overlay_walk(root, &path, dest)?; + continue; + } + if !path.is_file() { + continue; + } + let target = dest.join(rel); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&path, &target).map_err(|error| { + format!( + "overlay {} onto {}: {error}", + path.display(), + target.display() + ) + })?; + } + Ok(()) +} + +pub fn should_skip_overlay(relative: &Path) -> bool { + let mut components = relative.components(); + match components.next() { + Some(Component::Normal(first)) if first == "shared" => true, + Some(Component::Normal(first)) + if first == MANAGED_MARKER && components.next().is_none() => + { + true + } + _ => false, + } +} + +fn cluster_document_yaml( + machine_name: &str, + overlay_cluster_yaml: Option<&Path>, + home: &Path, + host_path: &Path, + configured_domain: Option<&str>, +) -> Result> { + let name = if machine_name.trim().is_empty() { + DEFAULT_MACHINE_CLUSTER_NAME + } else { + machine_name + }; + let overlay = overlay_cluster_yaml + .map(load_overlay_spec) + .transpose()? + .unwrap_or_default(); + let domain = overlay + .local_domain + .as_deref() + .filter(|value| !value.is_empty()) + .or(configured_domain.filter(|value| !value.is_empty())) + .unwrap_or(DEFAULT_LOCAL_DOMAIN); + let mount_root = mount_root_yaml(home, host_path); + let mut body = format!( + r#"apiVersion: hops.local/v1alpha1 +kind: Cluster +metadata: + name: {name} +spec: + clusterProvider: kind + dockerProvider: dory + mountRoot: {mount_root} + manifests: + path: {manifests} + controlPlane: + crossplane: + chart: {chart} + version: "{version}" + localDomain: {domain} + browserIngress: + namespaces: +"#, + manifests = CLUSTER_MANIFESTS_PATH, + chart = DEFAULT_CROSSPLANE_CHART, + version = DEFAULT_CROSSPLANE_VERSION, + ); + let ingress_namespaces = if overlay.browser_ingress_namespaces.is_empty() { + vec!["auth".to_string()] + } else { + overlay.browser_ingress_namespaces.clone() + }; + for namespace in &ingress_namespaces { + body.push_str(&format!(" - {namespace}\n")); + } + if let Some(secret) = overlay.secret_sync_path.as_ref() { + if let Some(relative) = rewrite_secret_sync(overlay_cluster_yaml, secret, home) { + body.push_str(&format!( + " secretSync:\n path: {}\n", + relative.display() + )); + } + } + Ok(body) +} + +fn mount_root_yaml(home: &Path, host_path: &Path) -> String { + let home = home.canonicalize().unwrap_or_else(|_| home.to_path_buf()); + let host = host_path + .canonicalize() + .unwrap_or_else(|_| host_path.to_path_buf()); + if host == home { + "$HOME".to_string() + } else { + host.display().to_string() + } +} + +#[derive(Default)] +struct OverlaySpec { + local_domain: Option, + browser_ingress_namespaces: Vec, + secret_sync_path: Option, +} + +fn load_overlay_spec(path: &Path) -> Result> { + let raw = fs::read_to_string(path) + .map_err(|error| format!("read overlay {}: {error}", path.display()))?; + let value: Value = serde_yaml::from_str(&raw) + .map_err(|error| format!("parse overlay {}: {error}", path.display()))?; + let spec = value.get("spec").cloned().unwrap_or(Value::Null); + let local_domain = spec + .get("localDomain") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let browser_ingress_namespaces = spec + .get("browserIngress") + .and_then(|ingress| ingress.get("namespaces")) + .and_then(Value::as_sequence) + .map(|namespaces| { + namespaces + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let secret_sync_path = spec + .get("secretSync") + .and_then(|secret| secret.get("path")) + .and_then(Value::as_str) + .map(PathBuf::from); + Ok(OverlaySpec { + local_domain, + browser_ingress_namespaces, + secret_sync_path, + }) +} + +fn rewrite_secret_sync(overlay_yaml: Option<&Path>, secret: &Path, home: &Path) -> Option { + if secret.is_absolute() { + return pathdiff(secret, home); + } + let overlay = overlay_yaml?; + let checkout = overlay.ancestors().nth(3)?; + let resolved = checkout.join(secret); + pathdiff(&resolved, home) +} + +fn pathdiff(path: &Path, base: &Path) -> Option { + let path = path + .canonicalize() + .ok() + .unwrap_or_else(|| path.to_path_buf()); + let base = base + .canonicalize() + .ok() + .unwrap_or_else(|| base.to_path_buf()); + path.strip_prefix(&base).ok().map(Path::to_path_buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_home() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "hops-cluster-template-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).unwrap(); + root.canonicalize().unwrap() + } + + fn cleanup(root: &Path) { + let _ = fs::remove_dir_all(root); + } + + #[test] + fn embed_has_providers_and_no_shared() { + assert!(FILES.iter().any(|(path, _)| *path == "providers/helm.yaml")); + assert!(FILES + .iter() + .any(|(path, contents)| *path == "providers/helm.yaml" + && contents.contains("provider-helm:v1.3.0"))); + assert!(FILES + .iter() + .any(|(path, _)| *path == "providerconfigs/kubernetes.yaml")); + assert!(FILES + .iter() + .any(|(path, contents)| *path == "stacks/auth.yaml" + && contents.contains("namespace: auth") + && contents.contains("fullnameOverride: zitadel"))); + assert!(FILES + .iter() + .any(|(path, _)| *path == "providers/zitadel.yaml")); + assert!(FILES + .iter() + .any(|(path, _)| *path == "providerconfigs/zitadel.yaml")); + assert!(!FILES.iter().any(|(path, _)| path.starts_with("shared/"))); + assert!(!FILES.iter().any(|(path, _)| path.starts_with("identity/"))); + } + + #[test] + fn materialize_writes_profile_and_skips_shared_overlay() { + let home = temp_home(); + let project = home.join("project"); + fs::create_dir_all(project.join(".gitops/local/cluster/shared")).unwrap(); + fs::create_dir_all(project.join(".gitops/local/cluster/extra")).unwrap(); + fs::write( + project.join(".gitops/local/cluster.yaml"), + r#"apiVersion: hops.local/v1alpha1 +kind: Cluster +metadata: + name: harmony +spec: + clusterProvider: kind + dockerProvider: dory + mountRoot: $HOME + localDomain: gitkb.localhost + browserIngress: + namespaces: + - auth + manifests: + path: .gitops/local/cluster +"#, + ) + .unwrap(); + fs::write( + project.join(".gitops/local/cluster/extra/addon.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: extra\n", + ) + .unwrap(); + fs::write( + project.join(".gitops/local/cluster/shared/minio.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: minio-should-not-land\n", + ) + .unwrap(); + + let yaml = materialize( + &home, + Some(&project.join(".gitops/local/cluster.yaml")), + "hops", + None, + None, + ) + .unwrap(); + assert_eq!(yaml, home.join(".gitops/local/cluster.yaml")); + let body = fs::read_to_string(&yaml).unwrap(); + assert!(body.contains("name: hops"), "{body}"); + assert!(!body.contains("name: harmony"), "{body}"); + assert!(body.contains("localDomain: gitkb.localhost"), "{body}"); + assert!(body.contains("- auth"), "{body}"); + assert!(body.contains("mountRoot: $HOME")); + let manifests = home.join(".gitops/local/cluster"); + assert!(manifests.join(MANAGED_MARKER).is_file()); + assert!(manifests.join("providers/helm.yaml").is_file()); + assert!(manifests.join("extra/addon.yaml").is_file()); + assert!(!manifests.join("shared/minio.yaml").exists()); + cleanup(&home); + } + + #[test] + fn refuses_unmanaged_existing_cluster_dir() { + let home = temp_home(); + let manifests = home.join(".gitops/local/cluster"); + fs::create_dir_all(&manifests).unwrap(); + fs::write(manifests.join("stray.yaml"), "kind: ConfigMap\n").unwrap(); + let err = materialize(&home, None, "hops", None, None).unwrap_err(); + assert!(err.to_string().contains("not hops-managed"), "{err}"); + cleanup(&home); + } + + #[test] + fn skip_overlay_shared_and_marker() { + assert!(should_skip_overlay(Path::new("shared/minio.yaml"))); + assert!(should_skip_overlay(Path::new("shared"))); + assert!(should_skip_overlay(Path::new(MANAGED_MARKER))); + assert!(!should_skip_overlay(Path::new("extra/addon.yaml"))); + assert!(!should_skip_overlay(Path::new("providers/helm.yaml"))); + } +} diff --git a/src/commands/local/workbench/controller.rs b/src/commands/local/workbench/controller.rs index e1dfb66..00d99bf 100644 --- a/src/commands/local/workbench/controller.rs +++ b/src/commands/local/workbench/controller.rs @@ -598,6 +598,45 @@ fn environment_helm_values( values } +fn run_environment_setup(loaded: &LoadedEnvironment) -> Result<(), Box> { + use std::process::Command; + for (index, script) in loaded.environment.setup.iter().enumerate() { + log::info!( + "Environment {} setup[{}]: {}", + loaded.environment.name, + index, + script.display() + ); + let status = Command::new("bash") + .arg(script) + .current_dir(&loaded.environment.root) + .env( + "HOPS_LOCAL_CONTEXT", + std::env::var("HOPS_KUBE_CONTEXT").unwrap_or_default(), + ) + .status() + .map_err(|error| { + format!( + "Environment {} setup[{}] {}: {error}", + loaded.environment.name, + index, + script.display() + ) + })?; + if !status.success() { + return Err(format!( + "Environment {} setup[{}] {} exited {}", + loaded.environment.name, + index, + script.display(), + status + ) + .into()); + } + } + Ok(()) +} + pub fn reconcile_environment( loaded: &LoadedEnvironment, opts: &ReconcileOptions, @@ -606,6 +645,20 @@ pub fn reconcile_environment Result, Box> { ensure_environment_namespace(opts, kubectl)?; + if !opts.dry_run && opts.run_setup { + run_environment_setup(loaded)?; + } + if !opts.dry_run { + if let Some(secret_sync) = &loaded.environment.secret_sync { + crate::commands::secrets::sync_vault_path(&secret_sync.path).map_err(|error| { + format!( + "Environment {} secretSync {}: {error}", + loaded.environment.name, + secret_sync.path.display() + ) + })?; + } + } let mut results = Vec::new(); let mut errors = Vec::new(); for deploy in &loaded.environment.deploys { @@ -886,10 +939,13 @@ mod tests { name: "feature-auth".into(), namespace: "feature-auth-ns".into(), cluster_ref: "project-dev".into(), + scope: super::super::definition::EnvironmentScope::Project, local_domain: "gitkb.localhost".into(), root: PathBuf::from("/project"), values: environment_values, deploys: vec![deploy.clone()], + secret_sync: None, + setup: Vec::new(), }, }; diff --git a/src/commands/local/workbench/definition.rs b/src/commands/local/workbench/definition.rs index 89a9c2d..6c3d205 100644 --- a/src/commands/local/workbench/definition.rs +++ b/src/commands/local/workbench/definition.rs @@ -43,6 +43,20 @@ pub const DEFAULT_CROSSPLANE_VERSION: &str = "2.4.0"; pub const DEFAULT_LOCAL_DOMAIN: &str = "localhost"; pub const CLUSTER_MANIFESTS_PATH: &str = ".gitops/local/cluster"; +/// Read `metadata.name` from a Cluster document without activating a backend. +pub fn load_cluster_document_name(path: &Path) -> Result> { + let raw = + fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))?; + let value: Value = + serde_yaml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display()))?; + let name = value + .get("metadata") + .and_then(|metadata| metadata.get("name")) + .and_then(Value::as_str) + .ok_or_else(|| format!("{}: Cluster.metadata.name is required", path.display()))?; + Ok(name.to_string()) +} + #[derive(Debug, Clone, Copy, Default)] pub struct ClusterOverrides<'a> { pub cluster_provider: Option, @@ -51,6 +65,9 @@ pub struct ClusterOverrides<'a> { pub cluster_name: Option<&'a str>, pub context: Option<&'a str>, pub dory_name: Option<&'a str>, + /// When set by `hops local up`, this is the machine Cluster identity. + /// Leaf `Cluster.metadata.name` is not used to create a second kind cluster. + pub machine_name: Option<&'a str>, } #[derive(Debug, Clone, PartialEq)] @@ -109,10 +126,22 @@ pub struct EnvironmentDefinition { pub name: String, pub namespace: String, pub cluster_ref: String, + pub scope: EnvironmentScope, pub local_domain: String, pub root: PathBuf, pub values: Mapping, pub deploys: Vec, + pub secret_sync: Option, + /// Checkout-relative scripts run on `hops local env enable`, before secretSync. + pub setup: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EnvironmentScope { + #[default] + Project, + Cluster, } /// Renderer for one explicit Environment deploy directory. @@ -329,10 +358,22 @@ struct EnvironmentSpec { cluster_ref: ClusterReference, root: PathBuf, #[serde(default)] + scope: EnvironmentScope, + #[serde(default)] namespace: Option, #[serde(default)] values: Mapping, deploys: Vec, + #[serde(default)] + secret_sync: Option, + #[serde(default)] + setup: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SetupSpec { + path: PathBuf, } #[derive(Debug, Deserialize)] @@ -384,7 +425,21 @@ fn prepare_cluster_with_mount_validation( // All parsing, identity, provider, and filesystem validation happens // before process state, local state, or the cluster can be mutated. - let definition = load_definition(&source)?; + let mut definition = load_definition(&source)?; + if let Some(machine) = overrides + .machine_name + .map(str::trim) + .filter(|name| !name.is_empty()) + { + if definition.cluster.name != machine { + log::warn!( + "Cluster.metadata.name {:?} in {} differs from machine cluster {machine:?}; using {machine:?} so a second kind cluster is not created", + definition.cluster.name, + definition.source.display() + ); + definition.cluster.name = machine.to_string(); + } + } validate_overrides(&definition, overrides)?; if let Some(name) = overrides @@ -696,21 +751,15 @@ pub fn load_environment_definition( debug_assert_eq!(raw.api_version, API_VERSION); debug_assert_eq!(raw.kind, "Environment"); validate_dns_label("Environment.metadata.name", &raw.metadata.name)?; - let checkout_root = if source.ends_with(DEFAULT_ENVIRONMENT_FILE) { - source.ancestors().nth(3).ok_or_else(|| { - format!( - "Environment definition has no containing checkout: {}", - source.display() - ) - })? - } else { - definition_root.as_path() - }; + let checkout_root = gitops_local_checkout_root(&source, &definition_root)?; let name = name_override .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(|| default_name_from_cwd(checkout_root)); + .unwrap_or_else(|| match raw.spec.scope { + EnvironmentScope::Cluster => raw.metadata.name.clone(), + EnvironmentScope::Project => default_name_from_cwd(checkout_root), + }); validate_dns_label("Environment runtime name", &name)?; if raw.spec.cluster_ref.name != cluster.cluster.name { return Err(format!( @@ -724,7 +773,10 @@ pub fn load_environment_definition( .filter(|value| !value.is_empty()) .map(str::to_string) .or(raw.spec.namespace) - .unwrap_or_else(|| name.clone()); + .unwrap_or_else(|| match raw.spec.scope { + EnvironmentScope::Cluster => "hops-platform".to_string(), + EnvironmentScope::Project => name.clone(), + }); validate_dns_label("Environment namespace", &namespace)?; let root = resolve_bounded_path( &cluster.cluster.mount_root, @@ -776,16 +828,53 @@ pub fn load_environment_definition( deploys.push(deploy_definition); } + let secret_sync = raw + .spec + .secret_sync + .map(|secret| { + resolve_bounded_path( + &cluster.cluster.mount_root, + checkout_root, + &secret.path, + &format!("Environment {name:?} spec.secretSync.path"), + true, + ) + .map(|path| SecretSyncDefinition { path }) + }) + .transpose()?; + + let mut setup = Vec::new(); + for (index, hook) in raw.spec.setup.into_iter().enumerate() { + let path = resolve_bounded_path( + &cluster.cluster.mount_root, + checkout_root, + &hook.path, + &format!("Environment {name:?} spec.setup[{index}].path"), + false, + )?; + if !path.is_file() { + return Err(format!( + "Environment {name:?} spec.setup[{index}].path is not a file: {}", + path.display() + ) + .into()); + } + setup.push(path); + } + Ok(LoadedEnvironment { source, environment: EnvironmentDefinition { name, namespace, cluster_ref: raw.spec.cluster_ref.name, + scope: raw.spec.scope, local_domain: cluster.cluster.local_domain.clone(), root, values: raw.spec.values, deploys, + secret_sync, + setup, }, }) } @@ -949,6 +1038,42 @@ fn normalize_local_domain(value: Option<&str>) -> Result> Ok(normalized.to_string()) } +/// `.gitops/local/.yaml` lives two levels under the checkout, whether +/// the file is `environment.yaml` or an extra Environment like `harmony-system.yaml`. +fn gitops_local_checkout_root<'a>( + source: &'a Path, + definition_root: &'a Path, +) -> Result<&'a Path, Box> { + if is_gitops_local_yaml(source) { + source.ancestors().nth(3).ok_or_else(|| { + format!( + "Environment definition has no containing checkout: {}", + source.display() + ) + .into() + }) + } else { + Ok(definition_root) + } +} + +fn is_gitops_local_yaml(source: &Path) -> bool { + let mut components = source.components().rev(); + let Some(Component::Normal(file)) = components.next() else { + return false; + }; + if !file.to_string_lossy().ends_with(".yaml") { + return false; + } + matches!( + components.next(), + Some(Component::Normal(name)) if name == "local" + ) && matches!( + components.next(), + Some(Component::Normal(name)) if name == ".gitops" + ) +} + fn resolve_bounded_path( boundary: &Path, base: &Path, @@ -1047,8 +1172,41 @@ fn resolve_mount_root( relative: &Path, field: &str, ) -> Result> { + if relative == Path::new("$HOME") || relative == Path::new("~") { + let home = std::env::var("HOME") + .map_err(|_| format!("{field} $HOME requires the HOME environment variable"))?; + let resolved = PathBuf::from(home) + .canonicalize() + .map_err(|error| format!("unable to canonicalize HOME for {field}: {error}"))?; + ensure_within(&resolved, definition_root, field)?; + return Ok(resolved); + } if relative.is_absolute() { - return Err(format!("{field} must be relative, got {}", relative.display()).into()); + let home = std::env::var("HOME").map_err(|_| { + format!( + "{field} absolute path requires HOME; got {}", + relative.display() + ) + })?; + let home = PathBuf::from(home) + .canonicalize() + .map_err(|error| format!("unable to canonicalize HOME for {field}: {error}"))?; + let resolved = relative.canonicalize().map_err(|error| { + format!( + "unable to canonicalize {field} {}: {error}", + relative.display() + ) + })?; + if resolved != home { + return Err(format!( + "{field} absolute path must be $HOME ({}); got {}", + home.display(), + resolved.display() + ) + .into()); + } + ensure_within(&resolved, definition_root, field)?; + return Ok(resolved); } let candidate = definition_root.join(relative); @@ -1196,6 +1354,40 @@ spec: ); } + #[test] + fn extra_environment_yaml_under_gitops_local_uses_checkout_root() { + let fixture = Fixture::new(); + let loaded = load_definition(&fixture.write(valid_yaml())).unwrap(); + fs::create_dir_all(fixture.root.join(".gitops/local/harmony-system")).unwrap(); + let source = fixture.root.join(".gitops/local/harmony-system.yaml"); + fs::write( + &source, + r#"apiVersion: hops.local/v1alpha1 +kind: Environment +metadata: + name: harmony-system +spec: + scope: cluster + clusterRef: + name: project-dev + namespace: harmony-system + root: . + deploys: + - path: .gitops/local/harmony-system + type: k8s + recursive: true +"#, + ) + .unwrap(); + let environment = load_environment_definition(&source, &loaded, None, None).unwrap(); + assert_eq!(environment.environment.name, "harmony-system"); + assert_eq!(environment.environment.root, fixture.root); + assert_eq!( + environment.environment.deploys[0].source_path, + fixture.root.join(".gitops/local/harmony-system") + ); + } + #[test] fn reusable_environment_derives_identity_and_sources_from_each_worktree() { let fixture = Fixture::new(); @@ -1605,10 +1797,16 @@ spec: fn rejects_absolute_traversal_and_symlink_escape() { let fixture = Fixture::new(); let absolute = valid_yaml().replacen("mountRoot: ../..", "mountRoot: /tmp", 1); - assert!(load_definition(&fixture.write(&absolute)) - .unwrap_err() - .to_string() - .contains("must be relative")); + assert!( + load_definition(&fixture.write(&absolute)) + .unwrap_err() + .to_string() + .contains("must be $HOME") + || load_definition(&fixture.write(&absolute)) + .unwrap_err() + .to_string() + .contains("unable to canonicalize") + ); let loaded = load_definition(&fixture.write(valid_yaml())).unwrap(); let traversal = valid_environment_yaml().replacen("root: .", "root: ../outside", 1); diff --git a/src/commands/local/workbench/machine.rs b/src/commands/local/workbench/machine.rs new file mode 100644 index 0000000..441e5f7 --- /dev/null +++ b/src/commands/local/workbench/machine.rs @@ -0,0 +1,103 @@ +//! Machine-level Cluster identity persisted under `~/.hops/local/`. +//! +//! `hops local up` uses this record to reconnect instead of creating a second +//! kind cluster from a leaf `.gitops/local/cluster.yaml`. + +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_MACHINE_CLUSTER_NAME: &str = "hops"; +const RECORD_FILE: &str = "cluster.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineClusterRecord { + pub name: String, + pub kube_context: String, + pub source: PathBuf, + /// Host directory bind-mounted into the kind node (Cluster.spec.mountRoot). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_domain: Option, +} + +pub fn record_path(state_dir: &Path) -> PathBuf { + state_dir.join(RECORD_FILE) +} + +pub fn load(state_dir: &Path) -> Result, Box> { + let path = record_path(state_dir); + if !path.exists() { + return Ok(None); + } + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + let record: MachineClusterRecord = + serde_json::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display()))?; + if record.name.trim().is_empty() { + return Err(format!("{}: Cluster name must not be empty", path.display()).into()); + } + Ok(Some(record)) +} + +pub fn save(state_dir: &Path, record: &MachineClusterRecord) -> Result<(), Box> { + fs::create_dir_all(state_dir)?; + let path = record_path(state_dir); + let raw = serde_json::to_string_pretty(record)?; + fs::write(&path, raw).map_err(|error| format!("write {}: {error}", path.display()))?; + Ok(()) +} + +pub fn kube_context_for_name(name: &str) -> String { + format!("kind-{name}") +} + +/// Prefer `~/dev` when it exists; otherwise `$HOME`. +pub fn default_host_path() -> PathBuf { + let home = std::env::var("HOME") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let dev = home.join("dev"); + if dev.is_dir() { + dev + } else { + home + } +} + +pub fn prompt_host_path(default: &Path) -> Result> { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Ok(default.to_path_buf()); + } + let raw: String = dialoguer::Input::new() + .with_prompt("Directory to mount into the cluster (hostPath)") + .default(default.display().to_string()) + .interact_text()?; + expand_host_path(&raw) +} + +pub fn expand_host_path(raw: &str) -> Result> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("hostPath must not be empty".into()); + } + let path = if trimmed == "$HOME" || trimmed == "~" { + PathBuf::from(std::env::var("HOME")?) + } else if let Some(rest) = trimmed.strip_prefix("~/") { + PathBuf::from(std::env::var("HOME")?).join(rest) + } else { + PathBuf::from(trimmed) + }; + if !path.exists() { + return Err(format!("hostPath does not exist: {}", path.display()).into()); + } + if !path.is_dir() { + return Err(format!("hostPath is not a directory: {}", path.display()).into()); + } + Ok(path.canonicalize().unwrap_or(path)) +} diff --git a/src/commands/local/workbench/mod.rs b/src/commands/local/workbench/mod.rs index ccb126b..bf71cc7 100644 --- a/src/commands/local/workbench/mod.rs +++ b/src/commands/local/workbench/mod.rs @@ -7,10 +7,12 @@ pub mod cluster_dns; pub mod cluster_gitops; +pub mod cluster_template; pub mod controller; pub mod definition; pub mod delivery; pub mod ingress; +pub mod machine; pub mod net; pub mod reconcile; pub mod registry; diff --git a/src/commands/local/workbench/net.rs b/src/commands/local/workbench/net.rs index 9e5fe75..443426f 100644 --- a/src/commands/local/workbench/net.rs +++ b/src/commands/local/workbench/net.rs @@ -1402,13 +1402,9 @@ mod tests { #[test] fn parse_cluster_dns_from_env_value() { - let refs = regex_lite_cluster_dns( - "http://zitadel-zitadel.auth.svc.cluster.local:8080/oauth/v2/keys", - ); - assert_eq!( - refs, - vec![("auth".into(), "zitadel-zitadel".into(), Some(8080))] - ); + let refs = + regex_lite_cluster_dns("http://zitadel.auth.svc.cluster.local:8080/oauth/v2/keys"); + assert_eq!(refs, vec![("auth".into(), "zitadel".into(), Some(8080))]); } #[test] diff --git a/src/commands/local/workbench/reconcile.rs b/src/commands/local/workbench/reconcile.rs index ecdbe09..0882570 100644 --- a/src/commands/local/workbench/reconcile.rs +++ b/src/commands/local/workbench/reconcile.rs @@ -33,6 +33,8 @@ pub struct ReconcileOptions { pub delivery_mode: Option, /// When true, only render (no apply). Used by tests. pub dry_run: bool, + /// Run Environment.spec.setup scripts (enable only, not watch). + pub run_setup: bool, } #[derive(Debug, Clone)] @@ -206,7 +208,9 @@ impl KubectlApplier for SystemKubectl { // when the pack is not installed) does not prevent core Deploy/Service apply. let mut hard_errors = Vec::new(); for doc in parse_yaml_docs(yaml)? { - let doc = serde_yaml::to_string(&doc)?; + // JSON, not YAML: serde_yaml round-trips `yes`/`on`/`no` as unquoted + // YAML 1.1 booleans, and kubectl then rejects container args. + let doc = serde_json::to_string(&doc)?; match crate::commands::local::kubectl_apply_stdin(&doc) { Ok(()) => {} Err(e) => { @@ -422,6 +426,7 @@ fn is_soft_apply_error(msg: &str) -> bool { || lower.contains("no matches for") || lower.contains("ensure crds are installed") || lower.contains("the server doesn't have a resource type") + || (lower.contains("the job") && lower.contains("field is immutable")) } /// Merge chart-level deploy values with runtime inject. @@ -1220,6 +1225,7 @@ metadata: app_delivery_host_paths: BTreeMap::new(), delivery_mode: None, dry_run: true, + run_setup: false, }; let result = reconcile_deploy_chart( @@ -1304,6 +1310,7 @@ metadata: app_delivery_host_paths: BTreeMap::new(), delivery_mode: None, dry_run: false, + run_setup: false, }; let raw = reconcile_deploy( @@ -1487,6 +1494,7 @@ metadata: app_delivery_host_paths: hosts, delivery_mode: Some("hostPath".into()), dry_run: true, + run_setup: false, }; let ui = build_runtime_values(&opts, "e2e-ui-ui"); let api = build_runtime_values(&opts, "e2e-ui-api"); diff --git a/src/commands/secrets/mod.rs b/src/commands/secrets/mod.rs index 06bbfc9..db4bd7f 100644 --- a/src/commands/secrets/mod.rs +++ b/src/commands/secrets/mod.rs @@ -225,7 +225,7 @@ fn configured_github_settings() -> Result Result> { +pub(super) fn configured_vault_settings() -> Result> { let config = load_config()?; let vault = config.secrets.vault; let env_address = std::env::var("VAULT_ADDR") @@ -257,7 +257,11 @@ fn configured_vault_settings() -> Result Result<(), Box> { } pub(super) fn sync_vault_path(path: &Path) -> Result<(), Box> { + let secret_source = path.canonicalize().map_err(|error| { + format!( + "Vault secrets path {} is unavailable: {error}", + path.display() + ) + })?; + let git_root = git_toplevel(&secret_source)?; + let _cwd = CwdGuard::enter(&git_root)?; + ensure_vault_token_from_cluster()?; run_vault(&VaultSyncArgs { - secret_path: Some(path.display().to_string()), + secret_path: Some(secret_source.display().to_string()), address: None, mount: None, path_prefix: None, - port_forward: false, + port_forward: true, no_port_forward: false, yes: true, }) } +struct CwdGuard(PathBuf); + +impl CwdGuard { + fn enter(dir: &Path) -> Result> { + let previous = env::current_dir()?; + env::set_current_dir(dir).map_err(|error| { + format!( + "unable to use Git worktree {} for Vault sync: {error}", + dir.display() + ) + })?; + Ok(Self(previous)) + } +} + +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.0); + } +} + +fn ensure_vault_token_from_cluster() -> Result<(), Box> { + if env::var("VAULT_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()) + { + return Ok(()); + } + let settings = super::configured_vault_settings()?; + let mut command = Command::new("kubectl"); + if let Some(context) = &settings.kube_context { + command.arg("--context").arg(context); + } + let output = command + .args([ + "--namespace", + &settings.kube_namespace, + "exec", + "vault-0", + "--", + "cat", + "/vault/data/.hops-init", + ]) + .output() + .map_err(|error| format!("failed to read local Vault init token: {error}"))?; + if !output.status.success() { + return Err(format!( + "Vault token not in {}; kubectl exec vault-0 .hops-init failed: {}", + settings.token_env, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + let stdout = String::from_utf8(output.stdout)?; + let token = stdout + .lines() + .find_map(|line| line.strip_prefix("Initial Root Token:").map(str::trim)) + .filter(|value| !value.is_empty()) + .ok_or("Vault init file has no Initial Root Token")?; + env::set_var(&settings.token_env, token); + log::info!( + "using local Vault root token from {}/vault-0", + settings.kube_namespace + ); + Ok(()) +} + +fn git_toplevel(path: &Path) -> Result> { + let dir = if path.is_dir() { + path + } else { + path.parent().ok_or("Vault secrets path has no parent")? + }; + let output = Command::new("git") + .current_dir(dir) + .args(["rev-parse", "--show-toplevel"]) + .output() + .map_err(|error| format!("failed to inspect Git repository for Vault inputs: {error}"))?; + if !output.status.success() { + return Err(format!("Vault sync requires a Git worktree at {}", dir.display()).into()); + } + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()).canonicalize()?) +} + #[cfg(test)] fn collect_desired_vault_secrets( root: &Path, diff --git a/src/commands/secrets/vault.rs b/src/commands/secrets/vault.rs index c76e33d..a1622ad 100644 --- a/src/commands/secrets/vault.rs +++ b/src/commands/secrets/vault.rs @@ -330,7 +330,7 @@ impl VaultSession { } if !(200..300).contains(&code) { return Err( - format!("Vault read failed for {secret_path:?} with HTTP {code}").into() + format!("Vault read failed for {secret_path:?} with HTTP {code}").into(), ); } let body: JsonValue = response.body_mut().read_json().map_err(|_| { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..da74a61 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +//! Shared building blocks for Crossplane package development. +pub mod package_dev; diff --git a/src/package_dev/archive.rs b/src/package_dev/archive.rs new file mode 100644 index 0000000..f97f632 --- /dev/null +++ b/src/package_dev/archive.rs @@ -0,0 +1,275 @@ +//! Read the current builder's Docker-save .uppkg output without docker load. +//! No archive entry is extracted onto the workstation filesystem. +use super::registry::{validate_image_name, Blob, Image, MANIFEST_MEDIA_TYPE}; +use flate2::read::GzDecoder; +use serde::Deserialize; +use std::collections::HashMap; +use std::error::Error; +use std::io::{BufRead, BufReader, Read}; +use tar::Archive; + +type Result = std::result::Result>; + +pub struct BuiltArchive { + pub source: String, + pub image: Image, + /// Package-only artifacts need not carry a runtime architecture. + pub platform: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Platform { + pub os: String, + pub architecture: String, +} + +impl Platform { + /// Only use for runnable Function/controller artifacts, not package metadata. + pub fn require_compatible(&self, node_architectures: &[String]) -> Result<()> { + if self.os != "linux" + || node_architectures.is_empty() + || node_architectures + .iter() + .any(|node| node != &self.architecture) + { + return Err("runtime artifact is not compatible with every eligible target-node architecture; build a matching or multi-platform artifact".into()); + } + Ok(()) + } +} + +#[derive(Deserialize)] +struct DockerManifest { + #[serde(rename = "Config")] + config: String, + #[serde(rename = "RepoTags")] + repo_tags: Vec, + #[serde(rename = "Layers")] + layers: Vec, +} + +/// Memory-bounded initial adapter. Large runtime archives are rejected explicitly +/// rather than risking unbounded memory use; disk-streamed blobs remain separate work. +pub fn read_uppkg(input: impl Read, max_bytes: u64) -> Result { + if max_bytes == 0 { + return Err("archive byte limit must be nonzero".into()); + } + let mut input = BufReader::new(input); + let magic = input.fill_buf()?; + if magic.starts_with(&[0x1f, 0x8b]) { + read_tar(GzDecoder::new(input), max_bytes) + } else { + read_tar(input, max_bytes) + } +} + +fn normalized_path(path: &str) -> Result { + let path = path.strip_prefix("./").unwrap_or(path); + if path.is_empty() + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || path.chars().any(char::is_control) + { + return Err("unsafe path in package archive".into()); + } + Ok(path.into()) +} + +fn read_tar(input: impl Read, max_bytes: u64) -> Result { + // Bound even GNU/PAX metadata that tar processes before yielding entries. + let mut archive = Archive::new(input.take(max_bytes)); + let mut files = HashMap::new(); + let mut total = 0u64; + let mut entries = 0; + for entry in archive.entries().map_err(|_| "invalid package archive")? { + let mut entry = entry.map_err(|_| "invalid package archive entry")?; + entries += 1; + if entries > 4096 { + return Err("package archive has too many entries".into()); + } + if entry.header().entry_type().is_dir() { + continue; + } + if !entry.header().entry_type().is_file() { + return Err("package archive links and special files are forbidden".into()); + } + let path = entry.path().map_err(|_| "invalid archive path")?; + let path = normalized_path(path.to_str().ok_or("non-UTF8 archive path")?)?; + let size = entry.size(); + total = total + .checked_add(size) + .ok_or("package archive exceeds byte limit")?; + if total > max_bytes { + return Err("package archive exceeds byte limit".into()); + } + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|_| "truncated package archive entry")?; + if bytes.len() as u64 != size || files.insert(path, bytes).is_some() { + return Err("truncated or duplicate package archive entry".into()); + } + } + let manifests: Vec = serde_json::from_slice( + files + .get("manifest.json") + .ok_or("Docker-save manifest.json missing")?, + ) + .map_err(|_| "invalid Docker-save manifest")?; + if manifests.len() != 1 || manifests[0].repo_tags.len() != 1 { + return Err("package archive must identify exactly one image and source tag".into()); + } + let manifest = manifests.into_iter().next().unwrap(); + let source = manifest.repo_tags.into_iter().next().unwrap(); + let (repository, tag) = source + .rsplit_once(':') + .ok_or("builder image is missing its source tag")?; + validate_image_name(repository)?; + if tag.is_empty() + || tag.len() > 128 + || !tag + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err("invalid source tag in package archive".into()); + } + let config_bytes = files + .remove(&normalized_path(&manifest.config)?) + .ok_or("package image config missing")?; + let config: serde_json::Value = + serde_json::from_slice(&config_bytes).map_err(|_| "invalid image config JSON")?; + let platform = match (config["os"].as_str(), config["architecture"].as_str()) { + (Some(os), Some(architecture)) => Some(Platform { + os: os.into(), + architecture: architecture.into(), + }), + (None, None) => None, + _ => return Err("incomplete runtime platform in image config".into()), + }; + let config = Blob { + bytes: config_bytes, + media_type: "application/vnd.oci.image.config.v1+json".into(), + }; + let config_descriptor = config.descriptor(); + let mut blobs = vec![config]; + let mut layers = Vec::new(); + let mut used: HashMap = HashMap::new(); + let mut blob_digests = + std::collections::HashSet::from([config_descriptor["digest"].as_str().unwrap().to_owned()]); + for path in manifest.layers { + let path = normalized_path(&path)?; + if let Some(descriptor) = used.get(&path) { + layers.push(descriptor.clone()); + continue; + } + let bytes = files.remove(&path).ok_or("package layer missing")?; + let media_type = if bytes.starts_with(&[0x1f, 0x8b]) { + "application/vnd.oci.image.layer.v1.tar+gzip" + } else { + "application/vnd.oci.image.layer.v1.tar" + }; + let blob = Blob { + bytes, + media_type: media_type.into(), + }; + let descriptor = blob.descriptor(); + used.insert(path, descriptor.clone()); + // OCI permits identical layers at different archive paths. + if blob_digests.insert(descriptor["digest"].as_str().unwrap().to_owned()) { + blobs.push(blob); + } + layers.push(descriptor); + } + let manifest = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, "mediaType": MANIFEST_MEDIA_TYPE, + "config": config_descriptor, "layers": layers + }))?; + let image = Image { manifest, blobs }; + image.validate()?; + Ok(BuiltArchive { + source, + image, + platform, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (name, bytes) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, name, *bytes).unwrap(); + } + builder.into_inner().unwrap() + } + + const MANIFEST: &[u8] = br#"[{"Config":"config.json","RepoTags":["ghcr.io/hops-ops/example:configuration"],"Layers":["layer.tar"]}]"#; + const CONFIG: &[u8] = br#"{"architecture":"arm64","os":"linux","config":{"Labels":{"io.crossplane.xpkg":"true"}}}"#; + + fn fixture() -> Vec { + archive(&[ + ("manifest.json", MANIFEST), + ("config.json", CONFIG), + ("layer.tar", b"fixture-layer"), + ]) + } + + #[test] + fn plain_and_gzip_builder_archives_have_identical_immutable_images() { + let bytes = fixture(); + let plain = read_uppkg(bytes.as_slice(), 1024 * 1024).unwrap(); + let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gzip.write_all(&bytes).unwrap(); + let compressed = read_uppkg(gzip.finish().unwrap().as_slice(), 1024 * 1024).unwrap(); + assert_eq!(plain.source, "ghcr.io/hops-ops/example:configuration"); + assert_eq!(plain.image.manifest, compressed.image.manifest); + assert_eq!( + plain.image.validate().unwrap(), + compressed.image.validate().unwrap() + ); + assert_eq!( + plain.platform.unwrap(), + Platform { + os: "linux".into(), + architecture: "arm64".into() + } + ); + assert_eq!(plain.image.blobs[0].bytes, CONFIG); + } + + #[test] + fn rejects_limits_duplicates_missing_layers_and_traversal_without_extracting() { + assert!(read_uppkg(fixture().as_slice(), 10).is_err()); + let duplicate = archive(&[("manifest.json", MANIFEST), ("manifest.json", MANIFEST)]); + assert!(read_uppkg(duplicate.as_slice(), 1024 * 1024).is_err()); + let missing = archive(&[("manifest.json", MANIFEST), ("config.json", CONFIG)]); + assert!(read_uppkg(missing.as_slice(), 1024 * 1024).is_err()); + for path in ["/outside", "../outside", "./../outside", "a//b", "a\\b"] { + assert!(normalized_path(path).is_err()); + } + assert!(read_uppkg(b"invalid archive".as_slice(), 1024).is_err()); + } + + #[test] + fn runtime_platform_checks_every_eligible_architecture() { + let runtime = Platform { + os: "linux".into(), + architecture: "arm64".into(), + }; + runtime.require_compatible(&["arm64".into()]).unwrap(); + assert!(runtime.require_compatible(&["amd64".into()]).is_err()); + assert!(runtime + .require_compatible(&["arm64".into(), "amd64".into()]) + .is_err()); + assert!(runtime.require_compatible(&[]).is_err()); + } +} diff --git a/src/package_dev/mod.rs b/src/package_dev/mod.rs new file mode 100644 index 0000000..71a4928 --- /dev/null +++ b/src/package_dev/mod.rs @@ -0,0 +1,6 @@ +//! Destination-independent artifact transport. No local backend or KRM writer. +pub mod archive; +pub mod registry; +pub mod routing; +pub mod target; +pub mod tunnel; diff --git a/src/package_dev/registry.rs b/src/package_dev/registry.rs new file mode 100644 index 0000000..f400ac3 --- /dev/null +++ b/src/package_dev/registry.rs @@ -0,0 +1,403 @@ +//! OCI Distribution v2 transport over a caller-owned Kubernetes API tunnel. +//! No Docker daemon, registry credentials, redirect, or environment proxy. +use sha2::{Digest, Sha256}; +use std::error::Error; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::time::Duration; + +type Result = std::result::Result>; + +pub const MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json"; + +pub fn digest(bytes: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +fn valid_digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + }) +} + +/// Reject paths, userinfo, tags, query strings, and shell-shaped repository input. +pub fn validate_repository(repository: &str) -> Result<()> { + if repository.is_empty() + || repository.len() > 255 + || !repository.split('/').all(|part| { + !part.is_empty() + && part.as_bytes()[0].is_ascii_alphanumeric() + && part.as_bytes()[part.len() - 1].is_ascii_alphanumeric() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)) + && part != "." + && part != ".." + }) + { + return Err("invalid OCI repository path".into()); + } + Ok(()) +} + +/// Fully qualified image repository, including an optional registry port. +pub fn validate_image_name(image: &str) -> Result<()> { + let (authority, repository) = image + .split_once('/') + .ok_or("image must include registry and repository")?; + let (host, port) = authority + .split_once(':') + .map(|(h, p)| (h, Some(p))) + .unwrap_or((authority, None)); + if !host.contains('.') + || host.len() > 253 + || !host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + }) + || port.is_some_and(|p| p.parse::().ok().filter(|p| *p != 0).is_none()) + { + return Err("invalid image registry authority".into()); + } + validate_repository(repository) +} + +/// An OCI blob whose identity is computed from the exact bytes being uploaded. +pub struct Blob { + pub bytes: Vec, + pub media_type: String, +} + +impl Blob { + pub fn descriptor(&self) -> serde_json::Value { + serde_json::json!({ + "mediaType": self.media_type, + "digest": digest(&self.bytes), + "size": self.bytes.len() + }) + } +} + +/// OCI image manifest plus its complete direct blob closure. +/// Image indexes are deliberately rejected here; callers must publish children first. +pub struct Image { + pub manifest: Vec, + pub blobs: Vec, +} + +impl Image { + pub fn validate(&self) -> Result { + let manifest: serde_json::Value = + serde_json::from_slice(&self.manifest).map_err(|_| "invalid OCI manifest JSON")?; + if manifest["schemaVersion"] != 2 || manifest["mediaType"] != MANIFEST_MEDIA_TYPE { + return Err("expected an OCI image manifest (not an index or Docker schema1)".into()); + } + let layers = manifest["layers"].as_array().ok_or("missing OCI layers")?; + let mut descriptors = vec![&manifest["config"]]; + descriptors.extend(layers); + let mut blobs = std::collections::HashMap::new(); + for blob in &self.blobs { + let descriptor = blob.descriptor(); + let hash = descriptor["digest"].as_str().unwrap().to_owned(); + if blobs.insert(hash, descriptor).is_some() { + return Err("duplicate blob identity".into()); + } + } + let mut referenced = std::collections::HashSet::new(); + for descriptor in descriptors { + let expected = descriptor["digest"].as_str().ok_or("missing blob digest")?; + if !valid_digest(expected) { + return Err("invalid blob digest".into()); + } + let actual = blobs.get(expected).ok_or("incomplete OCI blob closure")?; + if descriptor != actual { + return Err("OCI descriptor media type, size, or fields do not match blob".into()); + } + referenced.insert(expected); + } + if referenced.len() != blobs.len() { + return Err("image includes unreferenced blob content".into()); + } + Ok(digest(&self.manifest)) + } +} + +/// Always loopback. Constructing this client cannot select a public write host. +pub struct RegistryClient { + base: String, + agent: ureq::Agent, +} + +fn content_digest(headers: &ureq::http::HeaderMap) -> Option<&str> { + headers + .get("Docker-Content-Digest") + .and_then(|value| value.to_str().ok()) +} + +impl RegistryClient { + pub fn loopback(port: u16, timeout: Duration) -> Result { + if port == 0 || timeout.is_zero() { + return Err("registry port and timeout must be nonzero".into()); + } + let config = ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .max_redirects(0) + .proxy(None) + .build(); + Ok(Self { + base: format!("http://{}", SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)), + agent: ureq::Agent::new_with_config(config), + }) + } + + pub fn ready(&self) -> Result<()> { + let response = self + .agent + .get(&format!("{}/v2/", self.base)) + .call() + .map_err(|_| { + "registry API unavailable through tunnel; retry without changing the pin" + })?; + if response.status() != 200 { + return Err("registry API is not ready".into()); + } + Ok(()) + } + + fn exists(&self, repository: &str, kind: &str, expected: &str) -> Result { + let url = format!("{}/v2/{repository}/{kind}/{expected}", self.base); + match self + .agent + .head(&url) + .header("Accept", MANIFEST_MEDIA_TYPE) + .call() + { + Ok(response) if response.status() == 200 => { + if content_digest(response.headers()) != Some(expected) { + return Err("registry HEAD returned a different digest".into()); + } + Ok(true) + } + Err(ureq::Error::StatusCode(404)) => Ok(false), + _ => Err("registry HEAD failed; retry without changing the pin".into()), + } + } + + /// Upload and verify immutable content. Returns false when already present. + /// This function never changes a Kubernetes resource or Git package pin. + pub fn publish(&self, repository: &str, image: &Image) -> Result { + validate_repository(repository)?; + let expected = image.validate()?; + if self.exists(repository, "manifests", &expected)? { + self.verify_manifest(repository, &image.manifest)?; + return Ok(false); + } + for blob in &image.blobs { + let expected_blob = digest(&blob.bytes); + if self.exists(repository, "blobs", &expected_blob)? { + continue; + } + let response = self + .agent + .post(&format!("{}/v2/{repository}/blobs/uploads/", self.base)) + .header("Content-Length", "0") + .send(&[] as &[u8]) + .map_err(|_| "registry upload start failed; retry without changing the pin")?; + if response.status() != 202 { + return Err("registry rejected blob upload start".into()); + } + let location = response + .headers() + .get("Location") + .and_then(|value| value.to_str().ok()) + .ok_or("upload Location missing")?; + let location = upload_location(repository, location, &expected_blob)?; + let response = self + .agent + .put(&format!("{}{location}", self.base)) + .header("Content-Type", "application/octet-stream") + .send(blob.bytes.as_slice()) + .map_err(|_| "registry blob upload interrupted; retry without changing the pin")?; + if response.status() != 201 + || content_digest(response.headers()) != Some(&expected_blob) + { + return Err("registry did not confirm uploaded blob digest".into()); + } + if !self.exists(repository, "blobs", &expected_blob)? { + return Err("uploaded blob is not readable by digest".into()); + } + } + let response = self + .agent + .put(&format!( + "{}/v2/{repository}/manifests/{expected}", + self.base + )) + .header("Content-Type", MANIFEST_MEDIA_TYPE) + .send(image.manifest.as_slice()) + .map_err(|_| "registry manifest upload failed; retry without changing the pin")?; + if response.status() != 201 || content_digest(response.headers()) != Some(&expected) { + return Err("registry did not confirm uploaded manifest digest".into()); + } + self.verify_manifest(repository, &image.manifest)?; + Ok(true) + } + + fn verify_manifest(&self, repository: &str, manifest: &[u8]) -> Result<()> { + let expected = digest(manifest); + let mut response = self + .agent + .get(&format!( + "{}/v2/{repository}/manifests/{expected}", + self.base + )) + .header("Accept", MANIFEST_MEDIA_TYPE) + .call() + .map_err(|_| "uploaded manifest cannot be read back; do not activate")?; + if response.status() != 200 || content_digest(response.headers()) != Some(&expected) { + return Err("manifest readback returned a different digest".into()); + } + let mut bytes = Vec::new(); + response + .body_mut() + .as_reader() + .take(manifest.len() as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "manifest readback interrupted; do not activate")?; + if bytes != manifest { + return Err("manifest readback bytes do not match upload; do not activate".into()); + } + Ok(()) + } +} + +/// Distribution is configured with relativeurls. Never follow an upload URL to +/// another host, repository, route, or an existing query's conflicting digest. +fn upload_location(repository: &str, location: &str, expected: &str) -> Result { + let prefix = format!("/v2/{repository}/blobs/uploads/"); + let remainder = location + .strip_prefix(&prefix) + .ok_or("unsafe registry upload Location")?; + let (id, query) = remainder.split_once('?').unwrap_or((remainder, "")); + let state = query.strip_prefix("_state="); + // Distribution URL-encodes base64 padding. Only the opaque state value may + // be encoded; accepting arbitrary parameter names permits digest override. + let safe_query = query.is_empty() + || state.is_some_and(|value| { + let decoded = value.replace("%3D", "=").replace("%3d", "="); + let unpadded = decoded.trim_end_matches('='); + !unpadded.is_empty() + && decoded.len() - unpadded.len() <= 2 + && unpadded + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) + }); + if id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') || !safe_query { + return Err("unsafe registry upload Location".into()); + } + let separator = if location.contains('?') { '&' } else { '?' }; + Ok(format!("{location}{separator}digest={expected}")) +} + +/// Stream-safe hash helper for archive preparation; does not load a large file. +pub fn sha256_reader(mut reader: impl Read) -> Result { + let mut hash = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let size = reader.read(&mut buffer)?; + if size == 0 { + break; + } + hash.write_all(&buffer[..size])?; + } + Ok(format!("sha256:{:x}", hash.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_locations_cannot_escape_tunnel_or_repository() { + let expected = digest(b"data"); + assert_eq!( + upload_location( + "org/pkg", + "/v2/org/pkg/blobs/uploads/abc-123?_state=abc_DEF-12", + &expected + ) + .unwrap(), + format!("/v2/org/pkg/blobs/uploads/abc-123?_state=abc_DEF-12&digest={expected}") + ); + assert!(upload_location( + "org/pkg", + "/v2/org/pkg/blobs/uploads/abc?_state=abc%3D%3D", + &expected + ) + .is_ok()); + for location in [ + "https://public.example/upload", + "//public.example/upload", + "/v2/other/pkg/blobs/uploads/abc", + "/v2/org/pkg/blobs/uploads/../admin", + "/v2/org/pkg/blobs/uploads/abc?digest=other", + "/v2/org/pkg/blobs/uploads/abc?%64igest=other", + "/v2/org/pkg/blobs/uploads/abc?x=bad\n", + "/v2/org/pkg/blobs/uploads/abc#fragment", + ] { + assert!( + upload_location("org/pkg", location, &expected).is_err(), + "{location}" + ); + } + } + + #[test] + fn validates_blob_closure_before_network() { + let blob = Blob { + bytes: b"{}".to_vec(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + }; + let manifest = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, "mediaType": MANIFEST_MEDIA_TYPE, + "config": blob.descriptor(), "layers": [] + })) + .unwrap(); + let mut image = Image { + manifest, + blobs: vec![blob], + }; + assert!(image.validate().is_ok()); + image.blobs[0].bytes.push(b' '); + assert!(image.validate().is_err()); + } + + #[test] + fn repository_validation_and_streamed_hash() { + for repo in ["org/pkg", "org/nested/pkg_v2", "pkg.name"] { + assert!(validate_repository(repo).is_ok()); + } + for repo in [ + "", + "../pkg", + "org/../pkg", + "/org/pkg", + "org/Pkg", + "pkg:tag", + "x?token=secret", + "x@y", + "org//pkg", + ] { + assert!(validate_repository(repo).is_err()); + } + assert_eq!(sha256_reader(&b"content"[..]).unwrap(), digest(b"content")); + } +} diff --git a/src/package_dev/routing.rs b/src/package_dev/routing.rs new file mode 100644 index 0000000..40e0f54 --- /dev/null +++ b/src/package_dev/routing.rs @@ -0,0 +1,284 @@ +//! Conservative ImageConfig routing preflight, before any package pin change. +//! Rules with post-rewrite credentials/runtime/verification need independent +//! validation; this first transport contract refuses them rather than guessing. +use super::registry::digest; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::error::Error; + +type Result = std::result::Result>; + +pub struct DevelopmentRewrite { + pub source: String, + pub destination: String, + pub verified_digest: String, + pub session: String, +} + +impl DevelopmentRewrite { + pub fn image_config(&self) -> Result { + let source_digest = self.source.rsplit_once('@').map(|(_, d)| d); + let destination_digest = self.destination.rsplit_once('@').map(|(_, d)| d); + if self.verified_digest.len() != 71 + || !self.verified_digest.starts_with("sha256:") + || !self.verified_digest[7..] + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + || source_digest != Some(self.verified_digest.as_str()) + || destination_digest != Some(self.verified_digest.as_str()) + || uuid::Uuid::parse_str(&self.session).is_err() + { + return Err( + "development routes require full, matching verified digests and a session UUID" + .into(), + ); + } + for reference in [&self.source, &self.destination] { + let repository = reference.rsplit_once('@').unwrap().0; + // The transport currently generates digest-only image references. + super::registry::validate_image_name(repository)?; + } + let hash = digest(self.source.as_bytes()); + Ok(json!({ + "apiVersion": "pkg.crossplane.io/v1beta1", "kind": "ImageConfig", + "metadata": { + "name": format!("hops-dev-{}", &hash[7..27]), + "labels": {"hops.ops.com.ai/development-session": self.session} + }, + "spec": { + "matchImages": [{"type": "Prefix", "prefix": self.source}], + "rewriteImage": {"prefix": self.destination} + } + })) + } +} + +struct Rule<'a> { + name: &'a str, + prefixes: Vec<&'a str>, + spec: &'a Value, +} + +fn rules(configs: &[Value]) -> Result>> { + let mut names = HashSet::new(); + configs + .iter() + .map(|config| { + let name = config["metadata"]["name"] + .as_str() + .ok_or("ImageConfig name missing")?; + if !names.insert(name) { + return Err("duplicate ImageConfig identity".into()); + } + if config["kind"] != "ImageConfig" + || config["apiVersion"] != "pkg.crossplane.io/v1beta1" + { + return Err("unsupported ImageConfig API".into()); + } + let matches = config["spec"]["matchImages"] + .as_array() + .ok_or("ImageConfig matches missing")?; + let mut prefixes = Vec::new(); + for matcher in matches { + if !matcher["type"].is_null() && matcher["type"] != "Prefix" { + return Err("unsupported ImageConfig match type".into()); + } + let prefix = matcher["prefix"] + .as_str() + .filter(|p| !p.is_empty()) + .ok_or("empty ImageConfig prefix")?; + prefixes.push(prefix); + } + if prefixes.is_empty() { + return Err("ImageConfig has no match prefixes".into()); + } + Ok(Rule { + name, + prefixes, + spec: &config["spec"], + }) + }) + .collect() +} + +fn selected<'a>(rules: &'a [Rule<'a>], reference: &str) -> Result, usize)>> { + let mut best: Option<(&Rule<'_>, usize)> = None; + let mut tied = false; + for rule in rules { + let Some(length) = rule + .prefixes + .iter() + .filter(|p| reference.starts_with(**p)) + .map(|p| p.len()) + .max() + else { + continue; + }; + match best { + None => { + best = Some((rule, length)); + tied = false; + } + Some((_, old)) if length > old => { + best = Some((rule, length)); + tied = false; + } + Some((_, old)) if length == old => tied = true, + _ => {} + } + } + if tied { + return Err( + "ambiguous equal-longest ImageConfig prefixes; Crossplane selection would be arbitrary" + .into(), + ); + } + Ok(best) +} + +fn resolution(rules: &[Rule<'_>], reference: &str) -> Result<(Option, String)> { + let Some((rule, length)) = selected(rules, reference)? else { + return Ok((None, reference.into())); + }; + let resolved = match rule.spec["rewriteImage"]["prefix"].as_str() { + Some(prefix) => format!("{prefix}{}", &reference[length..]), + None => reference.into(), + }; + Ok((Some(rule.name.into()), resolved)) +} + +/// Returns only the generated exact rules after checking the combined routing +/// table. Does not apply manifests or assert runtime TLS/health. +pub fn preflight( + live: &[Value], + development: &[DevelopmentRewrite], + protected_refs: &[String], +) -> Result> { + let before = rules(live)?; + let mut combined = live.to_vec(); + let mut proposed = Vec::new(); + let mut sources = HashMap::new(); + for rewrite in development { + if sources + .insert(&rewrite.source, &rewrite.destination) + .is_some() + { + return Err("duplicate development source identity".into()); + } + let config = rewrite.image_config()?; + if let Some(existing) = combined + .iter() + .find(|old| old["metadata"]["name"] == config["metadata"]["name"]) + { + if existing["spec"] != config["spec"] + || existing["metadata"]["labels"]["hops.ops.com.ai/development-session"] + != rewrite.session + { + return Err( + "development ImageConfig belongs to another session or has changed".into(), + ); + } + } else { + combined.push(config.clone()); + } + proposed.push(config); + } + let after = rules(&combined)?; + for (rewrite, config) in development.iter().zip(&proposed) { + let (selected_name, resolved) = resolution(&after, &rewrite.source)?; + if selected_name.as_deref() != config["metadata"]["name"].as_str() + || resolved != rewrite.destination + { + return Err( + "development reference does not resolve through its exact verified rewrite".into(), + ); + } + // Rewriting is followed by independent auth/runtime/verification lookup. + // Refuse any additional policy until its exact live identity and trust + // have been separately verified by the activation workflow. + if after.iter().any(|rule| { + rule.prefixes + .iter() + .any(|p| rewrite.destination.starts_with(p)) + }) { + return Err("post-rewrite ImageConfig policy requires explicit authentication, trust, and runtime validation".into()); + } + } + for reference in protected_refs { + if resolution(&before, reference)? != resolution(&after, reference)? { + return Err( + "development ImageConfig would change a released or unrelated reference".into(), + ); + } + } + Ok(proposed) +} + +#[cfg(test)] +mod tests { + use super::*; + fn cache() -> Value { + json!({"apiVersion":"pkg.crossplane.io/v1beta1","kind":"ImageConfig","metadata":{"name":"ghcr-cache"}, + "spec":{"matchImages":[{"prefix":"ghcr.io"}],"rewriteImage":{"prefix":"rc-internal.example.com/ghcr"}}}) + } + fn development() -> DevelopmentRewrite { + let hash = digest(b"immutable-manifest"); + DevelopmentRewrite { + source: format!("ghcr.io/hops-ops/example@{hash}"), + destination: format!( + "registry.crossplane-dev.svc.cluster.local:5000/hops-ops/example@{hash}" + ), + verified_digest: hash, + session: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into(), + } + } + + #[test] + fn exact_digest_rule_wins_while_releases_keep_broad_cache() { + let dev = development(); + let protected = vec![ + "ghcr.io/hops-ops/example:v1.0.0".into(), + "ghcr.io/another-org/other:v2".into(), + ]; + let live = vec![cache()]; + let before = live.clone(); + let proposed = preflight(&live, &[dev], &protected).unwrap(); + assert_eq!(proposed.len(), 1); + assert_eq!(live, before); + assert_eq!( + proposed[0]["spec"]["matchImages"][0]["prefix"], + development().source + ); + let mut combined = live; + combined.extend(proposed.clone()); + assert_eq!( + preflight(&combined, &[development()], &protected).unwrap(), + proposed + ); + } + + #[test] + fn ties_other_sessions_and_post_rewrite_rules_fail_closed() { + let mut exact = development().image_config().unwrap(); + exact["metadata"]["name"] = "unexpected-exact".into(); + assert!(preflight(&[cache(), exact], &[development()], &[]).is_err()); + let mut other = development().image_config().unwrap(); + other["metadata"]["labels"]["hops.ops.com.ai/development-session"] = "other".into(); + assert!(preflight(&[other], &[development()], &[]).is_err()); + let mut post = cache(); + post["spec"]["matchImages"][0]["prefix"] = "registry.crossplane-dev".into(); + post["spec"]["rewriteImage"] = Value::Null; + post["spec"]["registry"] = json!({"authentication":{"pullSecretRef":{"name":"unknown"}}}); + assert!(preflight(&[post], &[development()], &[]).is_err()); + } + + #[test] + fn wrong_digest_duplicate_identity_and_protected_capture_are_rejected() { + let mut dev = development(); + dev.destination = format!("registry.example.com/org/pkg@{}", digest(b"different")); + assert!(preflight(&[], &[dev], &[]).is_err()); + assert!(preflight(&[], &[development(), development()], &[]).is_err()); + assert!(preflight(&[], &[development()], &[development().source]).is_err()); + assert!(preflight(&[cache(), cache()], &[development()], &[]).is_err()); + } +} diff --git a/src/package_dev/target.rs b/src/package_dev/target.rs new file mode 100644 index 0000000..bade424 --- /dev/null +++ b/src/package_dev/target.rs @@ -0,0 +1,578 @@ +//! Non-secret target bindings. Loading and validation are read-only. +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::error::Error; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +type Result = std::result::Result>; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteTarget { + pub api_version: String, + pub kind: String, + pub metadata: Identity, + pub spec: TargetSpec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TargetSpec { + /// kube-system namespace UID, not a workstation's kube-context name. + pub cluster_uid: String, + #[serde(default)] + pub allow_development: bool, + pub allowed_package_prefixes: Vec, + pub registry: Registry, + pub ownership: Ownership, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Registry { + pub namespace: String, + pub service: String, + pub port: u16, + /// HTTPS authority only, no userinfo, path, query, or credentials. + pub pull_endpoint: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)] +pub enum Ownership { + /// Direct package writes are allowed only after separate live ownership + /// checks prove the selected objects explicitly opt in to this target. + Api {}, + Gitops { + repository: String, + #[serde(rename = "baseBranch")] + base_branch: String, + #[serde(rename = "writePolicy", default)] + write_policy: WritePolicy, + #[serde(rename = "packageDirectory")] + package_directory: String, + #[serde(rename = "imageConfigDirectory")] + image_config_directory: String, + argo: Argo, + }, +} + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WritePolicy { + #[default] + Worktree, + Direct, + PrMerge, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Argo { + pub namespace: String, + pub application: String, +} + +fn name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') +} + +/// Reject traversal and symlinks in every existing component, including output +/// paths that do not yet exist. The caller may bind the root differently per laptop. +pub fn bounded_path(root: &Path, relative: &str) -> Result { + let root = fs::canonicalize(root).map_err(|_| "target root is unavailable")?; + if relative.is_empty() + || relative.contains('\\') + || relative + .split('/') + .any(|c| c.is_empty() || c == "." || c == "..") + || !Path::new(relative) + .components() + .all(|c| matches!(c, Component::Normal(_))) + { + return Err("target path must be a normalized relative path".into()); + } + let mut path = root; + for component in Path::new(relative).components() { + path.push(component); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("target paths must not contain symlinks".into()) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("cannot inspect target path".into()), + } + } + Ok(path) +} + +impl RemoteTarget { + pub fn load(root: &Path, selected: &str) -> Result { + if !name(selected) { + return Err("invalid remote target name".into()); + } + let path = bounded_path(root, &format!(".hops/remotes/{selected}.yaml"))?; + let metadata = fs::metadata(&path).map_err(|_| "remote target file is missing")?; + if !metadata.is_file() || metadata.len() > 64 * 1024 { + return Err("remote target must be a regular file at most 64 KiB".into()); + } + let bytes = fs::read(path).map_err(|_| "cannot read remote target")?; + // serde errors can echo unknown fields/values, so never return them. + let target: Self = serde_yaml::from_slice(&bytes).map_err(|_| { + "invalid remote target schema; credentials and unknown fields are forbidden" + })?; + target.validate(selected)?; + if let Ownership::Gitops { + repository, + base_branch, + package_directory, + image_config_directory, + .. + } = &target.spec.ownership + { + let packages = bounded_path(root, package_directory)?; + let imageconfigs = bounded_path(root, image_config_directory)?; + if packages.starts_with(&imageconfigs) || imageconfigs.starts_with(&packages) { + return Err("package and ImageConfig directories must not overlap".into()); + } + let actual_root = git(root, &["rev-parse", "--show-toplevel"])?; + if fs::canonicalize(actual_root.trim())? != fs::canonicalize(root)? { + return Err("--gitops must select the repository root".into()); + } + if canonical_repository(&git(root, &["remote", "get-url", "origin"])?)? + != canonical_repository(repository)? + { + return Err("target Git repository does not match checkout origin".into()); + } + if git(root, &["branch", "--show-current"])?.trim() != base_branch { + return Err("target Git base branch does not match checkout".into()); + } + git( + root, + &[ + "ls-files", + "--error-unmatch", + "--", + &format!(".hops/remotes/{selected}.yaml"), + ], + )?; + if !git( + root, + &[ + "status", + "--porcelain", + "--", + &format!(".hops/remotes/{selected}.yaml"), + ], + )? + .is_empty() + { + return Err("GitOps target declaration must be committed and unchanged".into()); + } + } + // A second declaration for the same cluster is ambiguous, even if names + // or kube contexts differ. Never read symlinked/unknown target files. + let dir = bounded_path(root, ".hops/remotes")?; + for entry in fs::read_dir(dir)? { + let entry = entry?; + let filename = entry.file_name(); + let Some(filename) = filename.to_str() else { + return Err("invalid target filename".into()); + }; + if !filename.ends_with(".yaml") || filename == format!("{selected}.yaml") { + continue; + } + let other_name = filename.trim_end_matches(".yaml"); + if !name(other_name) { + return Err("invalid target filename".into()); + } + let path = bounded_path(root, &format!(".hops/remotes/{filename}"))?; + let metadata = fs::metadata(&path)?; + if !metadata.is_file() || metadata.len() > 64 * 1024 { + return Err("remote target exceeds 64 KiB".into()); + } + let other: Self = serde_yaml::from_slice(&fs::read(path)?) + .map_err(|_| "invalid sibling remote target schema")?; + other.validate(other_name)?; + if other.spec.cluster_uid == target.spec.cluster_uid { + return Err( + "duplicate remote cluster identity; use one target per control plane".into(), + ); + } + } + Ok(target) + } + + fn validate(&self, selected: &str) -> Result<()> { + if self.api_version != "hops.remote/v1alpha1" + || self.kind != "RemoteTarget" + || self.metadata.name != selected + { + return Err("remote target API, kind, or identity mismatch".into()); + } + if !self.spec.allow_development { + return Err("target does not permit development activation".into()); + } + if uuid::Uuid::parse_str(&self.spec.cluster_uid) + .ok() + .is_none_or(|uid| uid.to_string() != self.spec.cluster_uid) + { + return Err("target requires the expected kube-system namespace UID".into()); + } + let mut unique = HashSet::new(); + if self.spec.allowed_package_prefixes.is_empty() { + return Err("target has no allowed package prefixes".into()); + } + for prefix in &self.spec.allowed_package_prefixes { + let repo = prefix + .strip_suffix('/') + .ok_or("allowed package prefixes must end at a namespace slash")?; + if repo.split('/').count() < 2 + || !repo + .split('/') + .next() + .is_some_and(|host| host.contains('.')) + { + return Err( + "allowed package prefixes require a registry and organization namespace".into(), + ); + } + if !unique.insert(prefix) { + return Err("duplicate allowed package prefix".into()); + } + super::registry::validate_image_name(repo)?; + } + self.spec.registry.validate()?; + if let Ownership::Gitops { + repository, + base_branch, + package_directory, + image_config_directory, + argo, + .. + } = &self.spec.ownership + { + canonical_repository(repository)?; + if base_branch.is_empty() + || base_branch.starts_with('-') + || base_branch.contains("..") + || base_branch.ends_with('.') + || base_branch.ends_with(".lock") + || base_branch + .split('/') + .any(|part| part.is_empty() || part.starts_with('.')) + || !base_branch + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"/._-".contains(&c)) + || package_directory.split('/').any(|part| part == ".git") + || image_config_directory.split('/').any(|part| part == ".git") + || !name(&argo.namespace) + || !name(&argo.application) + { + return Err("invalid GitOps ownership binding".into()); + } + } + Ok(()) + } + + pub fn verify_cluster_uid(&self, observed: &str) -> Result<()> { + if observed != self.spec.cluster_uid { + return Err("selected kube context points to a different cluster identity".into()); + } + Ok(()) + } +} + +impl Registry { + fn validate(&self) -> Result<()> { + let authority = self + .pull_endpoint + .strip_prefix("https://") + .ok_or("registry pull endpoint must use HTTPS")?; + let (host, port) = authority + .split_once(':') + .map(|(host, port)| (host, Some(port))) + .unwrap_or((authority, None)); + if !name(&self.namespace) + || !name(&self.service) + || self.port == 0 + || host.is_empty() + || !host.split('.').all(name) + || port.is_some_and(|port| port.parse::().ok().filter(|p| *p != 0).is_none()) + { + return Err("invalid registry binding; expected internal HTTPS authority without credentials or paths".into()); + } + Ok(()) + } + + pub fn verify_service(&self, service: &serde_json::Value) -> Result<()> { + let metadata = &service["metadata"]; + if metadata["name"] != self.service + || metadata["namespace"] != self.namespace + || metadata["labels"]["hops.ops.com.ai/registry-mode"] != "push" + || metadata["labels"]["hops.ops.com.ai/registry-access"] != "write" + || service["spec"]["type"] != "ClusterIP" + || service["spec"]["externalIPs"] + .as_array() + .is_some_and(|ips| !ips.is_empty()) + || service["spec"]["selector"] + .as_object() + .is_none_or(|s| s.is_empty()) + || !service["spec"]["ports"] + .as_array() + .is_some_and(|ports| ports.iter().any(|p| p["port"] == self.port)) + { + return Err("registry Service binding is not a private push-enabled upload Service; caches are not write targets".into()); + } + Ok(()) + } +} + +fn git(root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .output() + .map_err(|_| "cannot inspect Git target binding")?; + if !output.status.success() { + return Err("cannot verify Git target binding".into()); + } + String::from_utf8(output.stdout).map_err(|_| "invalid Git target binding".into()) +} + +fn canonical_repository(value: &str) -> Result { + let value = value.trim(); + let (host, path) = if let Some(rest) = value.strip_prefix("https://") { + rest.split_once('/') + .ok_or("invalid Git repository identity")? + } else if let Some(rest) = value.strip_prefix("git@") { + rest.split_once(':') + .ok_or("invalid Git repository identity")? + } else { + return Err("Git repository must use credential-free HTTPS or git@host SSH".into()); + }; + if !host.split('.').all(name) { + return Err("invalid Git repository host".into()); + } + let path = path.strip_suffix(".git").unwrap_or(path); + super::registry::validate_repository(path)?; + Ok(format!("{host}/{path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + const TARGET: &str = r#" +apiVersion: hops.remote/v1alpha1 +kind: RemoteTarget +metadata: + name: development +spec: + clusterUid: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + allowDevelopment: true + allowedPackagePrefixes: ["ghcr.io/hops-ops/"] + registry: + namespace: crossplane-dev + service: registry-upload + port: 5001 + pullEndpoint: https://registry.crossplane-dev.svc.cluster.local:5000 + ownership: + mode: api +"#; + + fn fixture() -> PathBuf { + let root = std::env::temp_dir().join(format!("hops-target-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join(".hops/remotes")).unwrap(); + fs::write(root.join(".hops/remotes/development.yaml"), TARGET).unwrap(); + root + } + + #[test] + fn api_target_is_portable_without_git_or_local_backend() { + for _ in 0..2 { + let root = fixture(); + let target = RemoteTarget::load(&root, "development").unwrap(); + target + .verify_cluster_uid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + .unwrap(); + assert!(target.verify_cluster_uid("wrong-cluster").is_err()); + assert!(matches!(target.spec.ownership, Ownership::Api {})); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn targets_fail_closed_without_echoing_input_or_credentials() { + let root = fixture(); + for contents in [ + TARGET.replace("allowDevelopment: true", "allowDevelopment: false"), + TARGET.replace("mode: api", "mode: api\n token: synthetic-secret-canary"), + TARGET.replace( + "https://registry.", + "https://user:synthetic-secret-canary@registry.", + ), + TARGET.replace("name: development", "name: other"), + TARGET.replace("ghcr.io/hops-ops/", "ghcr.io/"), + ] { + fs::write(root.join(".hops/remotes/development.yaml"), contents).unwrap(); + let result = RemoteTarget::load(&root, "development"); + assert!(result.is_err()); + assert!(!result + .unwrap_err() + .to_string() + .contains("synthetic-secret-canary")); + } + assert!(RemoteTarget::load(&root, "../development").is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_cluster_identity_and_escaping_paths_are_rejected() { + let root = fixture(); + fs::write( + root.join(".hops/remotes/other.yaml"), + TARGET.replace("name: development", "name: other"), + ) + .unwrap(); + assert!(RemoteTarget::load(&root, "development") + .unwrap_err() + .to_string() + .contains("duplicate")); + for relative in [ + "../escape", + "/tmp/escape", + "a/../escape", + "./escape", + "a//b", + ] { + assert!(bounded_path(&root, relative).is_err()); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(std::env::temp_dir(), root.join("escape")).unwrap(); + assert!(bounded_path(&root, "escape/new-file").is_err()); + } + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn service_caches_and_public_write_bindings_are_rejected() { + let root = fixture(); + let target = RemoteTarget::load(&root, "development").unwrap(); + let mut service = serde_json::json!({ + "metadata": {"name": "registry-upload", "namespace": "crossplane-dev", "labels": { + "hops.ops.com.ai/registry-mode": "push", "hops.ops.com.ai/registry-access": "write" + }}, + "spec": {"type": "ClusterIP", "selector": {"app": "registry"}, "ports": [{"port": 5001}]} + }); + target.spec.registry.verify_service(&service).unwrap(); + service["metadata"]["labels"]["hops.ops.com.ai/registry-mode"] = "cache".into(); + assert!(target.spec.registry.verify_service(&service).is_err()); + service["metadata"]["labels"]["hops.ops.com.ai/registry-mode"] = "push".into(); + service["spec"]["type"] = "LoadBalancer".into(); + assert!(target.spec.registry.verify_service(&service).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_identity_is_transport_independent_and_never_accepts_credentials() { + assert_eq!( + canonical_repository("https://github.com/hops-ops/example.git").unwrap(), + canonical_repository("git@github.com:hops-ops/example.git").unwrap() + ); + assert!(canonical_repository("https://token@github.com/hops-ops/example").is_err()); + assert!(canonical_repository("https://github.com/../example").is_err()); + } + + #[test] + fn committed_gitops_targets_bind_repository_branch_and_safe_directories() { + for remote_url in [ + "https://github.com/hops-ops/example.git", + "git@github.com:hops-ops/example.git", + ] { + let root = fixture(); + let contents = TARGET.replace("mode: api", "mode: gitops\n repository: https://github.com/hops-ops/example.git\n baseBranch: main\n packageDirectory: .gitops/packages\n imageConfigDirectory: .gitops/imageconfigs\n argo:\n namespace: argocd\n application: packages"); + fs::write(root.join(".hops/remotes/development.yaml"), &contents).unwrap(); + git(&root, &["init", "--initial-branch=main"]).unwrap(); + git(&root, &["remote", "add", "origin", remote_url]).unwrap(); + git(&root, &["add", "--", ".hops/remotes/development.yaml"]).unwrap(); + git( + &root, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "-m", + "fixture", + ], + ) + .unwrap(); + let before = git(&root, &["status", "--porcelain"]).unwrap(); + let target = RemoteTarget::load(&root, "development").unwrap(); + assert!(matches!( + target.spec.ownership, + Ownership::Gitops { + write_policy: WritePolicy::Worktree, + .. + } + )); + assert_eq!(git(&root, &["status", "--porcelain"]).unwrap(), before); + fs::write( + root.join(".hops/remotes/development.yaml"), + format!("{contents}\n# changed\n"), + ) + .unwrap(); + assert!(RemoteTarget::load(&root, "development") + .unwrap_err() + .to_string() + .contains("committed")); + fs::write(root.join(".hops/remotes/development.yaml"), &contents).unwrap(); + git(&root, &["switch", "-c", "wrong-branch"]).unwrap(); + assert!(RemoteTarget::load(&root, "development") + .unwrap_err() + .to_string() + .contains("branch")); + git(&root, &["switch", "main"]).unwrap(); + git( + &root, + &[ + "remote", + "set-url", + "origin", + "https://github.com/other/repo.git", + ], + ) + .unwrap(); + assert!(RemoteTarget::load(&root, "development") + .unwrap_err() + .to_string() + .contains("repository")); + fs::remove_dir_all(root).unwrap(); + } + } +} diff --git a/src/package_dev/tunnel.rs b/src/package_dev/tunnel.rs new file mode 100644 index 0000000..1c0782a --- /dev/null +++ b/src/package_dev/tunnel.rs @@ -0,0 +1,230 @@ +//! Bounded, explicitly selected kubectl tunnel. Dropping it terminates kubectl. +use std::error::Error; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +pub struct PortForward { + child: Child, + port: u16, + reaped: bool, +} + +impl PortForward { + pub fn start( + context: &str, + namespace: &str, + service: &str, + remote_port: u16, + timeout: Duration, + ) -> Result> { + Self::start_with("kubectl", context, namespace, service, remote_port, timeout) + } + + fn start_with( + program: &str, + context: &str, + namespace: &str, + service: &str, + remote_port: u16, + timeout: Duration, + ) -> Result> { + if context.trim().is_empty() + || remote_port == 0 + || timeout.is_zero() + || !valid_name(namespace) + || !valid_name(service) + { + return Err( + "explicit context, namespace, Service, port, and timeout are required".into(), + ); + } + let mut command = Command::new(program); + command + .args([ + "--context", + context, + "--namespace", + namespace, + "--request-timeout=15s", + "port-forward", + "--address=127.0.0.1", + "--pod-running-timeout=15s", + &format!("service/{service}"), + &format!(":{remote_port}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // Include auth-plugin children in cleanup without affecting the user's + // terminal process group. No local backend or current-context mutation. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let child = command + .spawn() + .map_err(|_| "could not start kubectl port-forward")?; + let mut tunnel = Self { + child, + port: 0, + reaped: false, + }; + let stdout = tunnel + .child + .stdout + .take() + .ok_or("missing port-forward output")?; + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut output = BufReader::new(stdout); + let mut announced = false; + // Bounded lines: never retain arbitrary kube/auth-plugin output. + loop { + let mut bytes = Vec::new(); + let size = Read::by_ref(&mut output) + .take(1025) + .read_until(b'\n', &mut bytes); + match size { + Ok(0) | Err(_) => break, + Ok(_) if bytes.len() > 1024 => break, + Ok(_) => {} + } + if let Ok(line) = std::str::from_utf8(&bytes) { + if let Some(port) = + forwarded_port(line.trim(), remote_port).filter(|_| !announced) + { + let _ = sender.send(port); + announced = true; + // Keep draining: kubectl logs each forwarded connection + // to stdout. Closing this pipe can terminate the tunnel. + } + } + } + }); + tunnel.port = receiver.recv_timeout(timeout).map_err(|_| { + "port-forward did not become ready before deadline; retry without changing the pin" + })?; + tunnel.check()?; + Ok(tunnel) + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn check(&mut self) -> Result<(), Box> { + if self.child.try_wait()?.is_some() { + self.reaped = true; + return Err("registry tunnel exited; retry without changing the pin".into()); + } + Ok(()) + } +} + +fn valid_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn forwarded_port(line: &str, remote: u16) -> Option { + let rest = line.strip_prefix("Forwarding from 127.0.0.1:")?; + let (port, target) = rest.split_once(" -> ")?; + let port = port.parse::().ok()?; + (port != 0 && target == remote.to_string()).then_some(port) +} + +impl Drop for PortForward { + fn drop(&mut self) { + if self.reaped { + return; + } + #[cfg(unix)] + { + // Child is not reaped until wait below, so its PID cannot be reused. + unsafe { + libc::kill(-(self.child.id() as i32), libc::SIGKILL); + } + } + #[cfg(not(unix))] + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_only_the_expected_loopback_listener() { + assert_eq!( + forwarded_port("Forwarding from 127.0.0.1:32123 -> 5001", 5001), + Some(32123) + ); + for line in [ + "Forwarding from 0.0.0.0:32123 -> 5001", + "Forwarding from 127.0.0.1:0 -> 5001", + "Forwarding from 127.0.0.1:32123 -> 5000", + "secret: synthetic-token", + ] { + assert_eq!(forwarded_port(line, 5001), None); + } + } + + #[cfg(unix)] + #[test] + fn startup_is_bounded_and_drop_terminates_process_group() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!("hops-tunnel-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let script = root.join("kubectl"); + std::fs::write( + &script, + "#!/bin/sh\nprintf 'Forwarding from 127.0.0.1:32123 -> 5001\\n'\ni=0\nwhile [ $i -lt 5000 ]; do\n printf 'Handling connection for 32123\\n'\n i=$((i + 1))\ndone\nexec sleep 30\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mut tunnel = PortForward::start_with( + script.to_str().unwrap(), + "remote-context", + "development", + "registry-upload", + 5001, + Duration::from_secs(1), + ) + .unwrap(); + let pid = tunnel.child.id() as i32; + assert_eq!(tunnel.port(), 32123); + std::thread::sleep(Duration::from_millis(100)); + tunnel.check().unwrap(); + drop(tunnel); + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + std::fs::write( + &script, + "#!/bin/sh\necho synthetic-secret-canary >&2\nexec sleep 30\n", + ) + .unwrap(); + let start = std::time::Instant::now(); + let result = PortForward::start_with( + script.to_str().unwrap(), + "remote-context", + "development", + "registry-upload", + 5001, + Duration::from_millis(100), + ); + assert!(result.is_err()); + let error = result.err().unwrap().to_string(); + assert!(!error.contains("synthetic-secret-canary")); + assert!(start.elapsed() < Duration::from_secs(2)); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/templates/local/cluster/README.md b/templates/local/cluster/README.md new file mode 100644 index 0000000..5820c3b --- /dev/null +++ b/templates/local/cluster/README.md @@ -0,0 +1,18 @@ +# hops local Cluster template + +This tree is embedded in hops-cli and materialized by `hops local up` to +`$HOME/.gitops/local/cluster`. + +It is the machine Cluster desired state: Crossplane packages (helm/k8s/zitadel +providers and ProviderConfigs) and platform stacks (AuthStack, gateway, Istio, +PSQL, secrets/Vault). AuthStack installs Zitadel as Service `zitadel` in +namespace `auth` (`zitadel.auth.svc.cluster.local`). + +Project extras overlay from `/.gitops/local/cluster/` (last write wins by +relative path). Do not put shared app workloads or product identity here — +those belong on a cluster-scoped Environment (for Harmony: MinIO/Mailpit/Redis +and Harmony Zitadel personas/project/SMTP as `harmony-system`). + +Edit the files in this CLI template, not the materialized copy under `$HOME`. +The materialized directory is marked `.hops-managed` and is rewritten on each +`up`. diff --git a/templates/local/cluster/SECRETS.md b/templates/local/cluster/SECRETS.md new file mode 100644 index 0000000..557cc65 --- /dev/null +++ b/templates/local/cluster/SECRETS.md @@ -0,0 +1,169 @@ +# Residual local secrets + +These inputs are shared by every locally registered Environment using the +Cluster. + +Tracked GitOps files contain names and keys only. They do not contain +masterkeys, PATs, OIDC credentials, Stripe credentials, or application +secrets. + +## Cluster secret backend + +`.gitops/local/cluster/secrets/stack.yaml` installs External Secrets Operator +and a durable, in-cluster Vault. The `vault` `ClusterSecretStore` is shared by +all worktree namespaces. Vault persists on its own PVC; deleting that PVC is a +full local secret reset. + +Plaintext local inputs live only under the root `secrets/` directory, which is +gitignored. No SOPS step is required because no encrypted secret is committed. +`make local-gitops` generates or reuses stable shared application values, +resolves the same per-developer Stripe sandbox used by `make dev`, +verifies/provisions its catalog, and writes: + +```text +secrets/vault/harmony/stripe/.env +secrets/vault/harmony/local/application/.env +``` + +The application path contains the shared local service/session tokens and any +optional PostHog settings. The Stripe path contains `STRIPE_API_KEY`, `STRIPE_WEBHOOK_SECRET`, +`STRIPE_PORTAL_CONFIG_ID`, and `PUBLIC_STRIPE_PUBLISHABLE_KEY`. It then syncs +the ignored directory after SecretStack is Ready. To repeat only those phases: + +```bash +make dev-gitops-vault-secrets +make dev-gitops-vault-sync +``` + +The sync is idempotent. The Hops Cluster controller repeats the configured +ignored-directory sync before every Environment reconcile and watches that +directory for changes. Harmony reads Vault's locally persisted writer token +from the explicitly selected Kubernetes context and passes it through the +`VAULT_TOKEN` environment variable without printing it. Hops owns the +kubectl port-forward lifecycle and KV v2 synchronization through +`hops secrets sync vault`. + +## Phase 1: cluster bootstrap + +Create the AuthStack masterkey and MinIO credentials before starting the +Cluster controller: + +```bash +make dev-gitops-cluster-secrets +``` + +The script is idempotent and preserves generated infrastructure values. It +creates: + +| Namespace | Secret | Keys | +|---|---|---| +| `auth` | `zitadel-masterkey` | `masterkey` | +| `harmony-system` | `harmony-minio` | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` | +| `default` | `harmony-local-human-passwords` | `approved-admin`, `waitlisted`, `iac-approved`, `device-login`, `fixture-owner`, `fixture-viewer`, `bob`, `alice`, `carol` | +| `default` | `harmony-local-smtp` | `password` | + +All local personas use `Password1234!`, matching the existing Terraform and +Compose developer contract. Override the shared value with +`LOCAL_AUTH_PERSONA_PASSWORD`. This is a well-known local-only credential; do +not use it outside a disposable local environment. + +The SMTP password is a local-only generated credential. The declarative +`identity/smtp.yaml` resource uses it to configure Zitadel to deliver mail to +`mailpit.harmony-system.svc.cluster.local:1025`; Mailpit accepts any local +credentials and exposes its inbox at +`http://mailpit.harmony-system.svc.cluster.local:8025`. + +`auth` is the Cluster Zitadel namespace. Provider bootstrap uses +`zitadel.auth.svc.cluster.local`; browser OIDC uses the AuthStack Gateway +issuer `https://auth.gitkb.localhost`. + +After AuthStack is Ready, configure the Zitadel provider from its generated +admin PAT: + +```bash +hops local zitadel --context kind-hops --source-context kind-hops \ + --source-namespace auth \ + --domain zitadel.auth.svc.cluster.local --port 8080 --insecure + +``` + +The `hops local gitops cluster` controller keeps reconciling tracked manifest +changes after package CRDs and providers +become available. No second watcher or manual file touch is required. + +After the Project reports an external ID, supply its two provider residuals: + +```bash +make dev-gitops-identity +``` + +This explicit residual step exists because provider-upjet-zitadel `v0.1.1` +requires a generated `orgId` on Role managed resources but exposes no +Crossplane reference for it. The script therefore creates `approved` and +`admin` through the management API and supplies the observed Project org ID to +the raw smoke MachineUser, whose API has no `orgIdRef`. It accepts existing +roles as success and never writes the org ID or PAT to Git. AuthStack +`HumanUser` and `Grant` resources resolve their IDs declaratively. By default +the command port-forwards through `HOPS_LOCAL_CONTEXT`, avoiding same-named +services on other local clusters. Override `ZITADEL_INTERNAL_URL` and, when +needed, `ZITADEL_HOST_HEADER` for a custom endpoint. + +The canonical users are `admin@gitkb.com`, `waitlisted@gitkb.com`, +`member@acme.com`, `device@gitkb.com`, `owner@acme.com`, `viewer@acme.com`, +`bob@acme.com`, `alice@acme.com`, and `carol@acme.com`. Their Grants reconcile +after the roles exist. The `harmony-local-smoke` MachineUser supplies client +credentials to the local smoke launcher. Read a generated local password only +when needed by selecting its persona key: + +```bash +kubectl --context kind-harmony -n default get secret harmony-local-human-passwords \ + -o 'go-template={{ index .data "approved-admin" | base64decode }}{{ "\n" }}' +``` + +## Generated outputs + +Cluster-owned `PushSecret` resources publish generated credentials to Vault: + +| Source | Vault path | +|---|---| +| login-client PAT | `harmony/local/identity/login-client` | +| IAM admin PAT | `harmony/local/identity/iam-admin` | +| smoke MachineUser client | `harmony/local/identity/smoke-tests` | + +The Environment's `.gitops/local/environment-secrets` chart materializes these +as `harmony-zitadel-login`, `harmony-zitadel-admin`, and +`harmony-zitadel-smoke`. The chart normalizes PAT whitespace when constructing +the environment-facing Secrets. The gateway chart similarly publishes its +generated OIDC client to +`harmony/local/environments//gateway-oidc` and consumes it through +an `ExternalSecret` named `harmony-gateway-oidc`. + +## Phase 2: Environment-owned application Secret + +After the shared Zitadel Project is Ready, publish its generated IDs and the +shared MinIO credentials into `harmony/local/application`, and register the +Environment gateway's residual trusted domain: + +```bash +make dev-gitops-environment-secrets +``` + +The command does not create a Kubernetes application Secret. The Environment's +own `ExternalSecret/harmony-local` materializes `/harmony-local` +from the shared `harmony/local/application` Vault path. Every Environment owns +its own ExternalSecret and Secret resources while using the same local values. + +The billing chart materializes `/harmony-stripe` from the +Cluster-shared Vault path `harmony/stripe`. The OIDC managed resource writes +its connection Secret, which the gateway's `PushSecret`/`ExternalSecret` pair +routes to a stable Environment-owned Secret. Generated PATs and OIDC client +secrets are not copied by this command. + +The same command registers +`harmony-gateway..svc.cluster.local` as a Zitadel trusted domain. +That remains imperative because provider-upjet-zitadel requires an +`instanceId` for `TrustedDomain`, while AuthStack does not expose that generated +ID. This is a provider boundary rather than hidden desired state. + +If `harmony-stripe` is not Ready, sync Vault before retrying. Never put Stripe +values into an Application, Helm values file, or another namespace's Secret. diff --git a/templates/local/cluster/configurations/auth-stack.yaml b/templates/local/cluster/configurations/auth-stack.yaml new file mode 100644 index 0000000..cebecb2 --- /dev/null +++ b/templates/local/cluster/configurations/auth-stack.yaml @@ -0,0 +1,8 @@ +# Reconciled once per Cluster from .gitops/local/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: hops-ops-auth-stack +spec: + # v1.9.0 adds the HumanUser API used by the cluster-owned local personas. + package: ghcr.io/hops-ops/auth-stack:v1.9.0 diff --git a/templates/local/cluster/configurations/gateway-api-stack.yaml b/templates/local/cluster/configurations/gateway-api-stack.yaml new file mode 100644 index 0000000..14fb221 --- /dev/null +++ b/templates/local/cluster/configurations/gateway-api-stack.yaml @@ -0,0 +1,7 @@ +# Reconciled once per Cluster from .gitops/local/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: hops-ops-gateway-api-stack +spec: + package: ghcr.io/hops-ops/gateway-api-stack:v0.4.1 diff --git a/templates/local/cluster/configurations/istio-stack.yaml b/templates/local/cluster/configurations/istio-stack.yaml new file mode 100644 index 0000000..b4f3eb0 --- /dev/null +++ b/templates/local/cluster/configurations/istio-stack.yaml @@ -0,0 +1,7 @@ +# Reconciled once per Cluster from .gitops/local/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: hops-ops-istio-stack +spec: + package: ghcr.io/hops-ops/istio-stack:v1.4.1 diff --git a/templates/local/cluster/configurations/psql-stack.yaml b/templates/local/cluster/configurations/psql-stack.yaml new file mode 100644 index 0000000..bd9a957 --- /dev/null +++ b/templates/local/cluster/configurations/psql-stack.yaml @@ -0,0 +1,7 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: hops-ops-psql-stack +spec: + package: ghcr.io/hops-ops/psql-stack:v0.9.1 diff --git a/templates/local/cluster/configurations/secret-stack.yaml b/templates/local/cluster/configurations/secret-stack.yaml new file mode 100644 index 0000000..bfb5d0c --- /dev/null +++ b/templates/local/cluster/configurations/secret-stack.yaml @@ -0,0 +1,7 @@ +# Reconciled once per Cluster from .gitops/local/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: hops-ops-secret-stack +spec: + package: ghcr.io/hops-ops/secret-stack:v1.0.0 diff --git a/templates/local/cluster/providerconfigs/helm.yaml b/templates/local/cluster/providerconfigs/helm.yaml new file mode 100644 index 0000000..c71cf34 --- /dev/null +++ b/templates/local/cluster/providerconfigs/helm.yaml @@ -0,0 +1,9 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: helm.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity diff --git a/templates/local/cluster/providerconfigs/kubernetes.yaml b/templates/local/cluster/providerconfigs/kubernetes.yaml new file mode 100644 index 0000000..3796cc6 --- /dev/null +++ b/templates/local/cluster/providerconfigs/kubernetes.yaml @@ -0,0 +1,17 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ClusterProviderConfig +metadata: + name: default +spec: + credentials: + source: InjectedIdentity diff --git a/templates/local/cluster/providerconfigs/zitadel.yaml b/templates/local/cluster/providerconfigs/zitadel.yaml new file mode 100644 index 0000000..90ad825 --- /dev/null +++ b/templates/local/cluster/providerconfigs/zitadel.yaml @@ -0,0 +1,25 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: Secret + secretRef: + namespace: default + name: zitadel-credentials + key: credentials +--- +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ClusterProviderConfig +metadata: + name: default +spec: + credentials: + source: Secret + secretRef: + namespace: default + name: zitadel-credentials + key: credentials diff --git a/templates/local/cluster/providers/00-namespaces.yaml b/templates/local/cluster/providers/00-namespaces.yaml new file mode 100644 index 0000000..3025c33 --- /dev/null +++ b/templates/local/cluster/providers/00-namespaces.yaml @@ -0,0 +1,21 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: v1 +kind: Namespace +metadata: + name: auth + labels: + app.kubernetes.io/part-of: hops-local +--- +apiVersion: v1 +kind: Namespace +metadata: + name: harmony-system + labels: + app.kubernetes.io/part-of: harmony-local +--- +apiVersion: v1 +kind: Namespace +metadata: + name: istio-system + labels: + app.kubernetes.io/part-of: harmony-local diff --git a/templates/local/cluster/providers/helm-drc.yaml b/templates/local/cluster/providers/helm-drc.yaml new file mode 100644 index 0000000..c74c721 --- /dev/null +++ b/templates/local/cluster/providers/helm-drc.yaml @@ -0,0 +1,22 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-helm +spec: + serviceAccountTemplate: + metadata: + name: local-dev-helm +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-helm-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-helm + namespace: crossplane-system diff --git a/templates/local/cluster/providers/helm.yaml b/templates/local/cluster/providers/helm.yaml new file mode 100644 index 0000000..53652f8 --- /dev/null +++ b/templates/local/cluster/providers/helm.yaml @@ -0,0 +1,9 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-helm +spec: + package: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.3.0 + runtimeConfigRef: + name: local-dev-helm diff --git a/templates/local/cluster/providers/kubernetes-drc.yaml b/templates/local/cluster/providers/kubernetes-drc.yaml new file mode 100644 index 0000000..baaee1d --- /dev/null +++ b/templates/local/cluster/providers/kubernetes-drc.yaml @@ -0,0 +1,22 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-kubernetes +spec: + serviceAccountTemplate: + metadata: + name: local-dev-kubernetes +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-kubernetes-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-kubernetes + namespace: crossplane-system diff --git a/templates/local/cluster/providers/kubernetes.yaml b/templates/local/cluster/providers/kubernetes.yaml new file mode 100644 index 0000000..8c5ce0e --- /dev/null +++ b/templates/local/cluster/providers/kubernetes.yaml @@ -0,0 +1,9 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-kubernetes +spec: + package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.2.1 + runtimeConfigRef: + name: local-dev-kubernetes diff --git a/templates/local/cluster/providers/zitadel.yaml b/templates/local/cluster/providers/zitadel.yaml new file mode 100644 index 0000000..b816393 --- /dev/null +++ b/templates/local/cluster/providers/zitadel.yaml @@ -0,0 +1,7 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-upjet-zitadel +spec: + package: xpkg.crossplane.io/crossplane-contrib/provider-upjet-zitadel:v0.1.1 diff --git a/templates/local/cluster/secrets/stack.yaml b/templates/local/cluster/secrets/stack.yaml new file mode 100644 index 0000000..49c742c --- /dev/null +++ b/templates/local/cluster/secrets/stack.yaml @@ -0,0 +1,151 @@ +# Platform SecretStack for the Harmony local Cluster. +# +# The Cluster tree installs the released secret-stack Configuration before this +# XR becomes available. The watcher retries as package CRDs converge. +# +# backend=vault + vault.install for local (no AWS PodIdentity required). +# +# Persistence: file storage on a PVC (not server.dev / inmem). postStart +# init+unseal once, stores root token + unseal key on the volume: +# /vault/data/.hops-init +# K8s auth for ESO is re-applied every start. Seed KV with: +# export VAULT_TOKEN=$(kubectl -n vault exec vault-0 -- \ +# awk '/^Initial Root Token:/{print $NF}' /vault/data/.hops-init) +# make dev-gitops-vault-sync +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: harmony + backend: vault + namespace: external-secrets + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + labels: + team: platform + hops.ops.com.ai/stack: harmony-local + secretStore: + enabled: true + scope: Cluster + name: vault + vault: + install: true + namespace: vault + path: secret + version: v2 + auth: + method: kubernetes + mountPath: kubernetes + role: external-secrets + # Merged into official Vault Helm chart (SecretStack composition defaults + # use server.dev + no PVC; we override for durable local dogfood). + values: + server: + # Local Vault is single-replica and initialized from its persistent + # volume. Rolling updates make GitOps lifecycle/policy changes take + # effect without an imperative pod deletion (the chart defaults to + # OnDelete). + updateStrategyType: RollingUpdate + # Leave dev mode — inmem cannot use dataStorage. + dev: + enabled: false + standalone: + enabled: true + config: | + ui = true + disable_mlock = true + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + storage "file" { + path = "/vault/data" + } + dataStorage: + enabled: true + size: 1Gi + # omit storageClass → cluster default (kind: local-path) + postStart: + - /bin/sh + - -c + - | + # NOTE: do not use bare `set -e` around `vault status` — sealed/uninit + # returns exit 2 and would abort the hook (killing the container). + set -u + export VAULT_ADDR=http://127.0.0.1:8200 + INIT_FILE=/vault/data/.hops-init + + vault_status_rc() { + vault status >/dev/null 2>&1 + echo $? + } + + # API is up when status returns 0 (unsealed) or 2 (sealed / not init). + i=0 + while [ "$i" -lt 90 ]; do + rc=$(vault_status_rc) + if [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ]; then + break + fi + i=$((i + 1)) + sleep 1 + done + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then + echo "vault postStart: API not ready after wait (rc=$rc)" >&2 + exit 1 + fi + + if [ ! -f "$INIT_FILE" ]; then + echo "vault postStart: initializing (1 share / threshold 1)" + vault operator init -key-shares=1 -key-threshold=1 >"$INIT_FILE" + chmod 600 "$INIT_FILE" || true + fi + + UNSEAL_KEY=$(awk '/Unseal Key 1:/{print $NF}' "$INIT_FILE") + ROOT_TOKEN=$(awk '/Initial Root Token:/{print $NF}' "$INIT_FILE") + if [ -z "${UNSEAL_KEY}" ] || [ -z "${ROOT_TOKEN}" ]; then + echo "vault postStart: failed to parse $INIT_FILE" >&2 + cat "$INIT_FILE" >&2 || true + exit 1 + fi + + # Unseal when sealed (status exit 2). + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ]; then + vault operator unseal "$UNSEAL_KEY" >/dev/null + fi + export VAULT_TOKEN="$ROOT_TOKEN" + + # Non-dev installs do not auto-mount secret/ — enable KV v2 for ESO. + if ! vault secrets list -format=json 2>/dev/null | grep -q '"secret/"'; then + vault secrets enable -path=secret kv-v2 + fi + + if ! vault auth list -format=json 2>/dev/null | grep -q '"kubernetes/"'; then + vault auth enable kubernetes + fi + + vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc" \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + disable_iss_validation=true + + printf '%s\n' \ + 'path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }' \ + 'path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }' \ + | vault policy write external-secrets - + + vault write auth/kubernetes/role/external-secrets \ + bound_service_account_names=external-secrets \ + bound_service_account_namespaces=external-secrets \ + policies=external-secrets \ + ttl=24h + + echo "vault postStart: unsealed + kubernetes auth ready (init file $INIT_FILE)" diff --git a/templates/local/cluster/secrets/vault-auth-delegator.yaml b/templates/local/cluster/secrets/vault-auth-delegator.yaml new file mode 100644 index 0000000..0f61eac --- /dev/null +++ b/templates/local/cluster/secrets/vault-auth-delegator.yaml @@ -0,0 +1,17 @@ +# Allow the Cluster Vault server SA to call TokenReview (required for kubernetes auth). +# Independent of the Vault pod lifecycle — apply with cluster gitops. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-auth-delegator + labels: + hops.ops.com.ai/stack: harmony-local + hops.ops.com.ai/component: vault +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault + namespace: vault diff --git a/templates/local/cluster/stacks/auth.yaml b/templates/local/cluster/stacks/auth.yaml new file mode 100644 index 0000000..a0116c1 --- /dev/null +++ b/templates/local/cluster/stacks/auth.yaml @@ -0,0 +1,45 @@ +# Reconciled once per Cluster from .gitops/local/cluster. +# +# The zitadel chart fullname is `{release}` when the release name already +# contains "zitadel", else `{release}-zitadel`. AuthStack names the Helm +# release `{xr.name}-zitadel`, so XR name `zitadel` becomes Service +# `zitadel-zitadel`. fullnameOverride pins the API Service to `zitadel` in +# ns `auth` → zitadel.auth.svc.cluster.local. Login UI is `{fullname}-login` +# (`zitadel-login`); do not set login.fullnameOverride to the same value. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: AuthStack +metadata: + name: auth + namespace: default +spec: + managementPolicies: ["*"] + clusterName: hops + helmProviderConfigRef: + name: default + kind: ProviderConfig + kubernetesProviderConfigRef: + name: default + kind: ProviderConfig + namespace: auth + chartValues: + fullnameOverride: zitadel + domain: auth.gitkb.localhost + externalSecure: true + gateway: + enabled: true + parentRef: + name: platform + namespace: istio-ingress + sectionName: http + externalSecrets: + enabled: false + firstInstance: + org: hops + masterkey: + secretRef: + name: zitadel-masterkey + database: + embedded: + storage: + size: 2Gi + class: psql diff --git a/templates/local/cluster/stacks/gateway-api.yaml b/templates/local/cluster/stacks/gateway-api.yaml new file mode 100644 index 0000000..caf5c44 --- /dev/null +++ b/templates/local/cluster/stacks/gateway-api.yaml @@ -0,0 +1,12 @@ +# Gateway API CRDs are a Cluster-level prerequisite shared by every Environment. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: GatewayAPIStack +metadata: + name: harmony-local + namespace: default +spec: + managementPolicies: ["*"] + clusterName: harmony-local + helmProviderConfigRef: + name: default + kind: ProviderConfig diff --git a/templates/local/cluster/stacks/istio-gateway-defaults.yaml b/templates/local/cluster/stacks/istio-gateway-defaults.yaml new file mode 100644 index 0000000..6e0eea1 --- /dev/null +++ b/templates/local/cluster/stacks/istio-gateway-defaults.yaml @@ -0,0 +1,18 @@ +# Istio overlays this onto every automatically generated local Gateway Service. +# Hops publishes the same stable node-side port when it creates the Kind cluster; +# the host-side port remains cluster-specific and is discovered by the CLI. +apiVersion: v1 +kind: ConfigMap +metadata: + name: local-istio-gateway-defaults + namespace: istio-system + labels: + gateway.istio.io/defaults-for-class: istio +data: + service: | + spec: + type: NodePort + ports: + - name: http + port: 80 + nodePort: 30080 diff --git a/templates/local/cluster/stacks/istio.yaml b/templates/local/cluster/stacks/istio.yaml new file mode 100644 index 0000000..99fcc60 --- /dev/null +++ b/templates/local/cluster/stacks/istio.yaml @@ -0,0 +1,31 @@ +# One shared ingress Gateway serves every independently named local Environment. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: IstioStack +metadata: + name: harmony-local + namespace: default +spec: + managementPolicies: ["*"] + clusterName: harmony-local + helmProviderConfigRef: + name: default + kind: ProviderConfig + kubernetesProviderConfigRef: + name: default + kind: ProviderConfig + namespace: istio-system + chartVersion: "1.29.2" + ingressGateway: + enabled: true + name: platform + namespace: istio-ingress + gatewayClassName: istio + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: All + awsLoadBalancerController: + enabled: false diff --git a/templates/local/cluster/stacks/psql.yaml b/templates/local/cluster/stacks/psql.yaml new file mode 100644 index 0000000..fffb2ff --- /dev/null +++ b/templates/local/cluster/stacks/psql.yaml @@ -0,0 +1,25 @@ +# Reconciled once per Cluster from .gitops/cluster. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLStack +metadata: + name: harmony-local + namespace: default +spec: + managementPolicies: ["*"] + clusterName: harmony-local + helmProviderConfigRef: + name: default + kind: ProviderConfig + kubernetesProviderConfigRef: + name: default + kind: ProviderConfig + storageClass: + enabled: true + name: psql + provisioner: rancher.io/local-path + allowVolumeExpansion: false + parameters: {} + snapshotClass: + enabled: false + scaleToZeroPlugin: + enabled: false diff --git a/tests/distribution_protocol.rs b/tests/distribution_protocol.rs new file mode 100644 index 0000000..4651f1f --- /dev/null +++ b/tests/distribution_protocol.rs @@ -0,0 +1,242 @@ +//! Optional native Distribution protocol proof. No Kubernetes or container daemon. +//! Build the pinned Distribution source to a temporary binary, then: +//! HOPS_DISTRIBUTION_BINARY=/absolute/path/registry cargo test --test distribution_protocol -- --ignored +use hops_cli::package_dev::registry::{digest, Blob, Image, RegistryClient, MANIFEST_MEDIA_TYPE}; +use serde::Deserialize; +use serde_yaml::Value; +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +struct Process(Child); +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct Fixture(PathBuf); +impl Drop for Fixture { + fn drop(&mut self) { + if !std::thread::panicking() { + fs::remove_dir_all(&self.0).unwrap(); + } + } +} + +fn port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn start(binary: &Path, config: &Path, log: &Path) -> Process { + let stdout = fs::File::create(log).unwrap(); + let stderr = stdout.try_clone().unwrap(); + Process( + Command::new(binary) + .arg("serve") + .arg(config) + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(stderr) + .spawn() + .unwrap(), + ) +} + +fn curl(root: &Path, port: u16, method: &str, path: &str, trusted: bool) -> std::process::Output { + let mut command = Command::new("curl"); + command + .args([ + "--silent", + "--show-error", + "--max-time", + "3", + "--noproxy", + "*", + "--proto", + "=https", + "--request", + method, + "--header", + ]) + .arg(format!("Accept: {MANIFEST_MEDIA_TYPE}")); + if trusted { + command.arg("--cacert").arg(root.join("tls.crt")); + } + command + .args(["--write-out", "\n%{http_code}"]) + .arg(format!("https://localhost:{port}{path}")); + command.output().unwrap() +} + +#[test] +#[ignore = "requires HOPS_DISTRIBUTION_BINARY built from the pinned Distribution source; no cluster deployment"] +fn distribution_chart_protocol_tls_readonly_and_restart_durability() { + let binary = PathBuf::from( + std::env::var("HOPS_DISTRIBUTION_BINARY") + .expect("explicit native Distribution binary required"), + ); + assert!(binary.is_absolute() && binary.is_file()); + let root = Fixture( + std::env::temp_dir().join(format!("hops-distribution-proof-{}", uuid::Uuid::new_v4())), + ); + fs::create_dir_all(root.0.join("data")).unwrap(); + let certificate = Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost", + "-keyout", + ]) + .arg(root.0.join("tls.key")) + .arg("-out") + .arg(root.0.join("tls.crt")) + .output() + .unwrap(); + assert!( + certificate.status.success(), + "fixture certificate generation failed" + ); + let chart = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("charts/oci-registry"); + let rendered = Command::new("helm") + .args(["template", "registry"]) + .arg(&chart) + .arg("-f") + .arg(chart.join("ci/local.yaml")) + .output() + .unwrap(); + assert!(rendered.status.success()); + let documents: Vec = serde_yaml::Deserializer::from_slice(&rendered.stdout) + .map(|d| Value::deserialize(d).unwrap()) + .collect(); + let config = documents.iter().find(|d| d["kind"] == "ConfigMap").unwrap(); + let write_port = port(); + let read_port = port(); + for (mode, port) in [("write", write_port), ("read", read_port)] { + let mut value: Value = + serde_yaml::from_str(config["data"][format!("{mode}.yml")].as_str().unwrap()).unwrap(); + value["storage"]["filesystem"]["rootdirectory"] = + root.0.join("data").to_str().unwrap().into(); + value["http"]["addr"] = format!("127.0.0.1:{port}").into(); + if mode == "read" { + value["http"]["tls"]["certificate"] = root.0.join("tls.crt").to_str().unwrap().into(); + value["http"]["tls"]["key"] = root.0.join("tls.key").to_str().unwrap().into(); + } + fs::write( + root.0.join(format!("{mode}.yaml")), + serde_yaml::to_string(&value).unwrap(), + ) + .unwrap(); + } + let writer = start( + &binary, + &root.0.join("write.yaml"), + &root.0.join("write.log"), + ); + let reader = start(&binary, &root.0.join("read.yaml"), &root.0.join("read.log")); + let client = RegistryClient::loopback(write_port, Duration::from_secs(2)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if client.ready().is_ok() + && curl(&root.0, read_port, "GET", "/v2/", true) + .stdout + .ends_with(b"\n200") + { + break; + } + assert!( + Instant::now() < deadline, + "registry failed to start; fixture logs: {}", + root.0.display() + ); + std::thread::sleep(Duration::from_millis(100)); + } + let config = Blob { + bytes: b"{}".to_vec(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + }; + let manifest = serde_json::to_vec(&serde_json::json!({ + "schemaVersion":2, "mediaType":MANIFEST_MEDIA_TYPE, "config":config.descriptor(), "layers":[] + })).unwrap(); + let image = Image { + manifest, + blobs: vec![config], + }; + assert!( + client.publish("org/pkg", &image).unwrap(), + "fixture logs: {}", + root.0.display() + ); + assert!(!client.publish("org/pkg", &image).unwrap()); + let manifest_path = format!("/v2/org/pkg/manifests/{}", digest(&image.manifest)); + let read = curl(&root.0, read_port, "GET", &manifest_path, true); + assert!(read.status.success()); + assert_eq!(read.stdout, [image.manifest.as_slice(), b"\n200"].concat()); + assert!( + !curl(&root.0, read_port, "GET", &manifest_path, false) + .status + .success(), + "pull must fail without trusting the fixture CA" + ); + for (method, path) in [ + ("POST", "/v2/org/pkg/blobs/uploads/"), + ("PATCH", "/v2/org/pkg/blobs/uploads/abc"), + ("PUT", manifest_path.as_str()), + ("DELETE", manifest_path.as_str()), + ] { + let result = curl(&root.0, read_port, method, path, true); + assert!(result.status.success()); + // Distribution validates upload session state before method dispatch; + // an unknown PATCH session is 404, not 405. Neither path may mutate. + let denied = if method == "PATCH" { + b"\n404" + } else { + b"\n405" + }; + assert!( + result.stdout.ends_with(denied), + "{method}: {}", + String::from_utf8_lossy(&result.stdout) + ); + } + drop(writer); + drop(reader); + let _writer = start( + &binary, + &root.0.join("write.yaml"), + &root.0.join("write-restart.log"), + ); + let _reader = start( + &binary, + &root.0.join("read.yaml"), + &root.0.join("read-restart.log"), + ); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let result = curl(&root.0, read_port, "GET", &manifest_path, true); + if result.stdout == [image.manifest.as_slice(), b"\n200"].concat() { + break; + } + assert!( + Instant::now() < deadline, + "digest not readable after restart; logs: {}", + root.0.display() + ); + std::thread::sleep(Duration::from_millis(100)); + } +} diff --git a/tests/local_cluster_definition.rs b/tests/local_cluster_definition.rs index f501c7a..5664259 100644 --- a/tests/local_cluster_definition.rs +++ b/tests/local_cluster_definition.rs @@ -337,7 +337,10 @@ fn rejects_unknown_fields_and_escaping_paths_before_mutation() { fixture.write_definition(&escape); let output = fixture.run(); assert!(!output.status.success()); - assert!(output_text(&output).contains("must be relative")); + assert!( + output_text(&output).contains("must be $HOME") + || output_text(&output).contains("unable to canonicalize") + ); fixture.assert_no_mutation(); } diff --git a/tests/local_machine_cluster.rs b/tests/local_machine_cluster.rs new file mode 100644 index 0000000..e5f255f --- /dev/null +++ b/tests/local_machine_cluster.rs @@ -0,0 +1,450 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::{Command, Output}; + +const FAKE_TOOL: &str = r#"#!/bin/sh +tool=${0##*/} +printf '%s %s\n' "$tool" "$*" >> "$HOPS_TEST_COMMAND_LOG" + +case "$tool" in + kind) + if test "$1" = "get" && test "$2" = "clusters"; then + if test -f "$HOPS_TEST_KIND_CLUSTERS"; then cat "$HOPS_TEST_KIND_CLUSTERS"; fi + exit 0 + fi + if test "$1" = "create" && test "$2" = "cluster"; then + name=hops + while test "$#" -gt 0; do + if test "$1" = "--name"; then + shift + name=$1 + fi + shift + done + echo "$name" >> "$HOPS_TEST_KIND_CLUSTERS" + exit 0 + fi + exit 0 + ;; + docker) + if test "$1" = "info"; then echo "27.0.0"; exit 0; fi + if test "$1" = "ps"; then exit 0; fi + if test "$1" = "inspect"; then + if test -f "$HOPS_TEST_KIND_CLUSTERS"; then echo true; exit 0; fi + exit 1 + fi + exit 0 + ;; + helm|kubectl) + exit 0 + ;; +esac +"#; + +const CLUSTER_YAML: &str = r#"apiVersion: hops.local/v1alpha1 +kind: Cluster +metadata: + name: hops +spec: + clusterProvider: kind + dockerProvider: dory + mountRoot: ../.. + manifests: + path: .gitops/local/cluster +"#; + +const LEAF_YAML: &str = r#"apiVersion: hops.local/v1alpha1 +kind: Cluster +metadata: + name: harmony +spec: + clusterProvider: kind + dockerProvider: dory + mountRoot: ../.. + manifests: + path: .gitops/local/cluster +"#; + +const ENV_YAML: &str = r#"apiVersion: hops.local/v1alpha1 +kind: Environment +metadata: + name: demo +spec: + clusterRef: + name: hops + root: . + deploys: [] +"#; + +struct Fixture { + root: PathBuf, + bin: PathBuf, + command_log: PathBuf, + kind_clusters: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "hops-machine-cluster-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(root.join(".gitops/local/cluster")).unwrap(); + fs::create_dir_all(root.join("home")).unwrap(); + fs::write(root.join(".gitops/local/cluster.yaml"), CLUSTER_YAML).unwrap(); + let bin = root.join("fake-bin"); + fs::create_dir_all(&bin).unwrap(); + for tool in ["kind", "docker", "helm", "kubectl"] { + let path = bin.join(tool); + fs::write(&path, FAKE_TOOL).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + } + let root = root.canonicalize().unwrap(); + Self { + bin: root.join("fake-bin"), + command_log: root.join("commands.log"), + kind_clusters: root.join("kind-clusters"), + root, + } + } + + fn command(&self) -> Command { + let path = format!( + "{}:{}", + self.bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut command = Command::new(env!("CARGO_BIN_EXE_hops-cli")); + command + .current_dir(&self.root) + .env("PATH", path) + .env("HOME", self.root.join("home")) + .env("DOCKER_HOST", "unix:///contract-test.sock") + .env("HOPS_KIND_REGISTRY_HOST_PORT", "39011") + .env("HOPS_TEST_COMMAND_LOG", &self.command_log) + .env("HOPS_TEST_KIND_CLUSTERS", &self.kind_clusters) + .env_remove("HOPS_KIND_EXTRA_MOUNT"); + command + } + + fn output(cmd: &mut Command) -> Output { + cmd.output().expect("run hops-cli") + } + + fn stdout_stderr(output: &Output) -> (String, String) { + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + } + + fn log(&self) -> String { + fs::read_to_string(&self.command_log).unwrap_or_default() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn local_help_lists_up_init_env_envs() { + let fixture = Fixture::new(); + let output = Fixture::output(fixture.command().args(["local", "--help"])); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + for needle in ["up", "init", "env", "envs", "configure", "fwd", "gitops"] { + assert!(stdout.contains(needle), "missing {needle} in {stdout}"); + } +} + +#[test] +fn init_cluster_writes_committed_files_and_not_catalog() { + let fixture = Fixture::new(); + let dest = fixture.root.join("home/meta"); + let output = Fixture::output( + fixture + .command() + .args(["local", "init", "cluster", "--path"]) + .arg(&dest), + ); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + assert!(dest.join(".gitops/local/cluster.yaml").is_file()); + assert!(dest.join(".gitops/local/cluster").is_dir()); + let yaml = fs::read_to_string(dest.join(".gitops/local/cluster.yaml")).unwrap(); + assert!(yaml.contains("name: hops")); + assert!(!fixture.root.join("home/.hops/local/catalog").exists()); +} + +#[test] +fn init_platform_and_environment_write_scope() { + let fixture = Fixture::new(); + let dest = fixture.root.join("home/app"); + let platform = Fixture::output( + fixture + .command() + .args(["local", "init", "platform", "--path"]) + .arg(&dest), + ); + assert!( + platform.status.success(), + "{:?}", + Fixture::stdout_stderr(&platform) + ); + let yaml = fs::read_to_string(dest.join(".gitops/local/platform.yaml")).unwrap(); + assert!(yaml.contains("scope: cluster")); + assert!(dest + .join(".gitops/local/platform/minio/Chart.yaml") + .is_file()); + let env = Fixture::output( + fixture + .command() + .args(["local", "init", "environment", "--path"]) + .arg(&dest), + ); + assert!(env.status.success(), "{:?}", Fixture::stdout_stderr(&env)); + let env_yaml = fs::read_to_string(dest.join(".gitops/local/environment.yaml")).unwrap(); + assert!(env_yaml.contains("name: hops")); +} + +#[test] +fn env_discover_catalogues_disabled_and_refuses_home() { + let fixture = Fixture::new(); + fs::write( + fixture.root.join(".gitops/local/environment.yaml"), + ENV_YAML, + ) + .unwrap(); + let output = Fixture::output(fixture.command().args(["local", "env", "discover"])); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let catalog = fixture.root.join("home/.hops/local/catalog"); + let json = fs::read_dir(&catalog) + .unwrap() + .find_map(|entry| { + let path = entry.unwrap().path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("json")).then_some(path) + }) + .unwrap(); + let body = fs::read_to_string(json).unwrap(); + assert!(body.contains("\"enabled\": false")); + let home = Fixture::output( + fixture + .command() + .args(["local", "env", "discover"]) + .arg(fixture.root.join("home")), + ); + assert!(!home.status.success()); + let stderr = String::from_utf8_lossy(&home.stderr); + assert!( + stderr.contains("refusing to crawl $HOME") || stderr.contains("HOME"), + "{stderr}" + ); +} + +#[test] +fn envs_once_lists_catalog() { + let fixture = Fixture::new(); + fs::write( + fixture.root.join(".gitops/local/environment.yaml"), + ENV_YAML, + ) + .unwrap(); + assert!( + Fixture::output(fixture.command().args(["local", "env", "discover"])) + .status + .success() + ); + let output = Fixture::output(fixture.command().args(["local", "envs", "--once"])); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.trim().is_empty(), "{stdout}"); +} + +#[test] +fn up_from_leaf_yaml_does_not_create_second_cluster() { + let fixture = Fixture::new(); + let first = Fixture::output( + fixture + .command() + .args(["local", "up", "--once", "--dry-run"]), + ); + assert!( + first.status.success(), + "{:?}", + Fixture::stdout_stderr(&first) + ); + fs::write(fixture.root.join(".gitops/local/cluster.yaml"), LEAF_YAML).unwrap(); + let second = Fixture::output( + fixture + .command() + .args(["local", "up", "--once", "--dry-run"]), + ); + let (_out, err) = Fixture::stdout_stderr(&second); + assert!(second.status.success(), "{err}"); + assert!( + err.contains("harmony") && err.contains("hops"), + "expected leaf-name warning, got {err}" + ); + let creates = fixture + .log() + .lines() + .filter(|line| line.contains("kind create")) + .count(); + assert_eq!( + creates, + 0, + "dry-run must not kind create: {}", + fixture.log() + ); +} + +#[test] +fn env_discover_keeps_worktrees_distinct() { + let fixture = Fixture::new(); + let main = fixture.root.join(".gitops/local/environment.yaml"); + let wt = fixture + .root + .join(".worktrees/feature-auth/.gitops/local/environment.yaml"); + fs::create_dir_all(wt.parent().unwrap()).unwrap(); + fs::write(&main, ENV_YAML).unwrap(); + fs::write(&wt, ENV_YAML).unwrap(); + let output = Fixture::output(fixture.command().args(["local", "env", "discover"])); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let catalog = fixture.root.join("home/.hops/local/catalog"); + let files: Vec<_> = fs::read_dir(&catalog) + .unwrap() + .filter_map(|entry| { + let path = entry.unwrap().path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("json")).then_some(path) + }) + .collect(); + assert_eq!( + files.len(), + 2, + "worktree and main must not share a catalog file" + ); + let list = Fixture::output(fixture.command().args(["local", "env", "list"])); + let stdout = String::from_utf8_lossy(&list.stdout); + assert!(stdout.contains("feature-auth"), "{stdout}"); +} + +#[test] +fn up_materializes_cli_template_and_skips_shared_overlay() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.root.join(".gitops/local/cluster/shared")).unwrap(); + fs::write( + fixture.root.join(".gitops/local/cluster/extra.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: extra\n", + ) + .unwrap(); + fs::write( + fixture.root.join(".gitops/local/cluster/shared/minio.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: minio-should-not-land\n", + ) + .unwrap(); + let output = Fixture::output( + fixture + .command() + .args(["local", "up", "--once", "--dry-run"]), + ); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let profile = fixture.root.join("home/.gitops/local/cluster"); + assert!( + profile.join("providers/helm.yaml").is_file(), + "CLI template providers must land in $HOME/.gitops/local/cluster" + ); + let helm = fs::read_to_string(profile.join("providers/helm.yaml")).unwrap(); + assert!(helm.contains("provider-helm:v1.3.0"), "{helm}"); + assert!(profile.join("extra.yaml").is_file()); + assert!(!profile.join("shared/minio.yaml").exists()); + let yaml = fs::read_to_string(fixture.root.join("home/.gitops/local/cluster.yaml")).unwrap(); + assert!(yaml.contains("name: hops"), "{yaml}"); + assert!(yaml.contains("mountRoot: $HOME"), "{yaml}"); +} + +#[test] +fn env_discover_finds_cluster_scoped_extra_yaml() { + let fixture = Fixture::new(); + fs::write( + fixture.root.join(".gitops/local/environment.yaml"), + ENV_YAML, + ) + .unwrap(); + fs::write( + fixture.root.join(".gitops/local/harmony-system.yaml"), + r#"apiVersion: hops.local/v1alpha1 +kind: Environment +metadata: + name: harmony-system +spec: + scope: cluster + clusterRef: + name: hops + namespace: harmony-system + root: . + deploys: [] +"#, + ) + .unwrap(); + let output = Fixture::output(fixture.command().args(["local", "env", "discover"])); + assert!( + output.status.success(), + "{:?}", + Fixture::stdout_stderr(&output) + ); + let list = Fixture::output(fixture.command().args(["local", "env", "list"])); + let stdout = String::from_utf8_lossy(&list.stdout); + assert!(stdout.contains("harmony-system"), "{stdout}"); + assert!( + stdout.contains("demo") || stdout.contains("off"), + "{stdout}" + ); +} + +#[test] +fn cluster_name_escape_hatch_warns() { + let fixture = Fixture::new(); + let output = Fixture::output(fixture.command().args([ + "local", + "up", + "--once", + "--dry-run", + "--cluster-name", + "hops", + ])); + let (_out, err) = Fixture::stdout_stderr(&output); + assert!(output.status.success(), "{err}"); + assert!(err.contains("escape hatch"), "{err}"); +} diff --git a/tests/local_status.rs b/tests/local_status.rs index d48b66c..ba201c5 100644 --- a/tests/local_status.rs +++ b/tests/local_status.rs @@ -79,12 +79,9 @@ fn status_observes_without_persisting_or_healing_local_state() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("service access: disabled"), "{stdout}"); - assert!(stdout.contains("app-1: Running 1/1 [ok]"), "{stdout}"); - assert!( - stdout.contains("ingress: (no HTTPRoute hostnames)"), - "{stdout}" - ); + assert!(stdout.contains("1/1 ready"), "{stdout}"); + assert!(stdout.contains("(no public URLs)"), "{stdout}"); + assert!(stdout.contains("run `hops local up`"), "{stdout}"); assert_eq!(fs::read_to_string(&record).unwrap(), record_contents); assert!(!state.join("providers.json").exists()); assert!(!state.join("backend").exists()); @@ -94,9 +91,9 @@ fn status_observes_without_persisting_or_healing_local_state() { } #[test] -fn dns_enable_requires_a_binding_but_down_remains_offline() { +fn fwd_enable_requires_a_binding_but_down_remains_offline() { let root = std::env::temp_dir().join(format!( - "hops-local-dns-binding-{}-{}", + "hops-local-fwd-binding-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); @@ -114,7 +111,7 @@ fn dns_enable_requires_a_binding_but_down_remains_offline() { .unwrap(); let enable = Command::new(env!("CARGO_BIN_EXE_hops-cli")) - .args(["local", "dns", "--name", "feature"]) + .args(["local", "fwd", "--name", "feature"]) .env("HOME", &home) .output() .unwrap(); @@ -122,7 +119,7 @@ fn dns_enable_requires_a_binding_but_down_remains_offline() { assert!(String::from_utf8_lossy(&enable.stderr).contains("no durable cluster binding")); let down = Command::new(env!("CARGO_BIN_EXE_hops-cli")) - .args(["local", "dns", "--name", "feature", "--down"]) + .args(["local", "fwd", "--name", "feature", "--down"]) .env("HOME", &home) .output() .unwrap(); diff --git a/tests/oci_registry_chart.rs b/tests/oci_registry_chart.rs new file mode 100644 index 0000000..eeb23a4 --- /dev/null +++ b/tests/oci_registry_chart.rs @@ -0,0 +1,187 @@ +use serde::Deserialize; +use serde_yaml::Value; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn render(profile: &str, extra: &[&str]) -> Output { + let chart = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("charts/oci-registry"); + Command::new("helm") + .args(["template", "registry"]) + .arg(&chart) + .args(["--namespace", "crossplane-dev", "-f"]) + .arg(chart.join("ci").join(format!("{profile}.yaml"))) + .args(extra) + .output() + .expect("helm is required for chart contract tests") +} + +fn documents(profile: &str, extra: &[&str]) -> Vec { + let output = render(profile, extra); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_yaml::Deserializer::from_slice(&output.stdout) + .map(|doc| Value::deserialize(doc).unwrap()) + .filter(|doc| !doc.is_null()) + .collect() +} + +fn resource<'a>(docs: &'a [Value], kind: &str, name: &str) -> &'a Value { + docs.iter() + .find(|d| d["kind"] == kind && d["metadata"]["name"] == name) + .unwrap() +} + +// OCI-AC-001.1, 002.1, 002.2, 003.1, 006.1: rendered contracts; +// runtime push/TLS/RBAC behavior requires the separate Kubernetes fixture. +#[test] +fn local_and_remote_share_private_read_write_contract() { + for profile in ["local", "remote"] { + let docs = documents(profile, &[]); + let config = resource(&docs, "ConfigMap", "registry-config"); + for mode in ["write", "read"] { + let text = config["data"][format!("{mode}.yml")].as_str().unwrap(); + let cfg: Value = serde_yaml::from_str(text).unwrap(); + assert!(cfg["proxy"].is_null()); + assert_eq!(cfg["storage"]["delete"]["enabled"], false); + assert_eq!(cfg["storage"]["redirect"]["disable"], true); + assert_eq!( + cfg["storage"]["maintenance"]["uploadpurging"]["enabled"], + false + ); + assert_eq!( + cfg["http"]["addr"], + if mode == "write" { + "127.0.0.1:5001" + } else { + ":5000" + } + ); + if mode == "read" { + assert_eq!(cfg["storage"]["maintenance"]["readonly"]["enabled"], true); + assert_eq!(cfg["http"]["tls"]["certificate"], "/certs/tls.crt"); + } else { + assert!(cfg["storage"]["maintenance"]["readonly"].is_null()); + assert_eq!(cfg["http"]["relativeurls"], true); + } + if profile == "local" { + assert!(cfg["storage"]["s3"].is_null()); + assert_eq!( + cfg["storage"]["filesystem"]["rootdirectory"], + "/var/lib/registry" + ); + } else { + assert_eq!(cfg["storage"]["s3"]["encrypt"], true); + assert_eq!(cfg["storage"]["s3"]["secure"], true); + assert!(cfg["storage"]["s3"]["accesskey"].is_null()); + assert!(cfg["storage"]["filesystem"].is_null()); + } + } + for doc in &docs { + assert!(![ + "Ingress", + "Gateway", + "HTTPRoute", + "OCIRegistry", + "Configuration", + "Provider", + "CronJob" + ] + .contains(&doc["kind"].as_str().unwrap())); + if doc["kind"] == "Service" { + assert_eq!(doc["spec"]["type"], "ClusterIP"); + assert!(doc["spec"]["ports"][0]["nodePort"].is_null()); + } + } + let sts = resource(&docs, "StatefulSet", "registry"); + assert_eq!(sts["spec"]["replicas"], 1); + assert_eq!( + sts["spec"]["template"]["spec"]["containers"] + .as_sequence() + .unwrap() + .len(), + 2 + ); + let rules = &resource(&docs, "Role", "registry-upload")["rules"]; + assert_eq!(rules[2]["resources"][0], "pods/portforward"); + assert_eq!(rules[2]["resourceNames"][0], "registry-0"); + assert_eq!( + rules[2]["verbs"], + serde_yaml::to_value(vec!["get", "create"]).unwrap() + ); + let policy = resource(&docs, "NetworkPolicy", "registry"); + assert_eq!(policy["spec"]["ingress"][0]["ports"][0]["port"], 5000); + assert_eq!(policy["spec"]["ingress"].as_sequence().unwrap().len(), 1); + } +} + +#[test] +fn pvc_survives_helm_argo_removal_and_existing_claim_is_not_adopted() { + let docs = documents("local", &[]); + let pvc = resource(&docs, "PersistentVolumeClaim", "registry-pvc"); + assert_eq!( + pvc["metadata"]["annotations"]["helm.sh/resource-policy"], + "keep" + ); + assert_eq!( + pvc["metadata"]["annotations"]["argocd.argoproj.io/sync-options"], + "Prune=false,Delete=false" + ); + let existing = documents( + "local", + &["--set", "storage.pvc.existingClaim=retained-data"], + ); + assert!(!existing + .iter() + .any(|d| d["kind"] == "PersistentVolumeClaim")); + let sts = resource(&existing, "StatefulSet", "registry"); + assert_eq!( + sts["spec"]["template"]["spec"]["volumes"][2]["persistentVolumeClaim"]["claimName"], + "retained-data" + ); + assert!(!documents("remote", &[]) + .iter() + .any(|d| d["kind"] == "PersistentVolumeClaim")); +} + +#[test] +fn inputs_fail_closed_for_cache_mode_missing_trust_storage_and_access() { + for (profile, flag, error) in [ + ( + "local", + "proxy.remoteurl=https://registry-1.docker.io", + "proxy", + ), + ("local", "storage.type=cache", "type"), + ("local", "tls.existingSecret=", "existingSecret"), + ( + "local", + "storage.pvc.encryptionConfirmed=false", + "encryptionConfirmed", + ), + ("local", "access.pullPeers=[]", "pullPeers"), + ("remote", "storage.s3.bucket=", "storage.s3.bucket"), + ( + "remote", + "storage.s3.accesskey=synthetic-canary", + "accesskey", + ), + ("local", "name=../../escape", "name"), + ] { + let output = render(profile, &["--set", flag]); + assert!(!output.status.success(), "accepted unsafe input: {flag}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(error), "{flag}: {stderr}"); + } +} + +#[test] +fn empty_upload_subjects_grant_nobody_and_renders_are_deterministic() { + let docs = documents("local", &["--set-json", "access.uploadSubjects=[]"]); + assert!(!docs + .iter() + .any(|d| d["kind"] == "Role" || d["kind"] == "RoleBinding")); + assert_eq!(render("local", &[]).stdout, render("local", &[]).stdout); +} diff --git a/tests/remote_registry_transport.rs b/tests/remote_registry_transport.rs new file mode 100644 index 0000000..d49ab28 --- /dev/null +++ b/tests/remote_registry_transport.rs @@ -0,0 +1,253 @@ +//! Real HTTP socket fixture; no Docker daemon, kube cluster, Git, or AWS. +use hops_cli::package_dev::registry::{digest, Blob, Image, RegistryClient, MANIFEST_MEDIA_TYPE}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::thread::JoinHandle; +use std::time::Duration; + +#[derive(Clone, Copy)] +enum Fault { + None, + PartialUpload, + BadReadback, + Redirect, +} + +struct Server { + port: u16, + stop: Arc, + writes: Arc>>, + thread: Option>, +} + +impl Server { + fn new(fault: Fault) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let writes = Arc::new(Mutex::new(Vec::new())); + let writes_thread = writes.clone(); + let thread = std::thread::spawn(move || { + let mut blobs = HashMap::>::new(); + let mut manifests = HashMap::>::new(); + while !stop_thread.load(Ordering::SeqCst) { + let (mut stream, _) = match listener.accept() { + Ok(stream) => stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("{error}"), + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap().to_owned(); + let path = parts.next().unwrap().to_owned(); + let mut length = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if header == "\r\n" { + break; + } + if let Some((key, value)) = header.split_once(':') { + if key.eq_ignore_ascii_case("content-length") { + length = value.trim().parse::().unwrap(); + } + } + } + assert!(length < 1024 * 1024, "fixture request too large"); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + if method == "POST" || method == "PUT" { + writes_thread + .lock() + .unwrap() + .push(format!("{method} {}", path.split('?').next().unwrap())); + } + if path == "/v2/" { + respond(&mut stream, 200, &[], b"{}"); + } else if method == "HEAD" { + let expected = path.rsplit('/').next().unwrap(); + let present = if path.contains("/manifests/") { + manifests.contains_key(expected) + } else { + blobs.contains_key(expected) + }; + if present { + respond( + &mut stream, + 200, + &[("Docker-Content-Digest", expected)], + &[], + ); + } else { + respond(&mut stream, 404, &[], &[]); + } + } else if method == "POST" { + let location = if matches!(fault, Fault::Redirect) { + "https://public.example/steal?token=synthetic-secret-canary" + } else { + "/v2/org/pkg/blobs/uploads/abc-123?_state=YWJj%3D" + }; + respond(&mut stream, 202, &[("Location", location)], &[]); + } else if method == "PUT" && path.contains("/blobs/uploads/") { + if matches!(fault, Fault::PartialUpload) { + break; + } + let expected = path.split("digest=").nth(1).unwrap(); + assert_eq!(digest(&body), expected); + blobs.insert(expected.into(), body); + respond( + &mut stream, + 201, + &[("Docker-Content-Digest", expected)], + &[], + ); + } else if method == "PUT" && path.contains("/manifests/") { + let expected = path.rsplit('/').next().unwrap(); + assert_eq!(digest(&body), expected); + manifests.insert(expected.into(), body); + respond( + &mut stream, + 201, + &[("Docker-Content-Digest", expected)], + &[], + ); + } else if method == "GET" && path.contains("/manifests/") { + let expected = path.rsplit('/').next().unwrap(); + let bytes = manifests.get(expected).unwrap(); + let bytes = if matches!(fault, Fault::BadReadback) { + b"corrupt".as_slice() + } else { + bytes.as_slice() + }; + respond( + &mut stream, + 200, + &[("Docker-Content-Digest", expected)], + bytes, + ); + } else { + panic!("unexpected request: {method} {path}"); + } + } + }); + Self { + port, + stop, + writes, + thread: Some(thread), + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + self.thread.take().unwrap().join().unwrap(); + } +} + +fn respond(stream: &mut TcpStream, status: u16, headers: &[(&str, &str)], body: &[u8]) { + write!( + stream, + "HTTP/1.1 {status} Fixture\r\nConnection: close\r\nContent-Length: {}\r\n", + body.len() + ) + .unwrap(); + for (name, value) in headers { + write!(stream, "{name}: {value}\r\n").unwrap(); + } + stream.write_all(b"\r\n").unwrap(); + stream.write_all(body).unwrap(); +} + +fn fixture() -> Image { + let config = Blob { + bytes: br#"{"architecture":"arm64","os":"linux"}"#.to_vec(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + }; + let layer = Blob { + bytes: b"fixture-layer".to_vec(), + media_type: "application/vnd.oci.image.layer.v1.tar".into(), + }; + let manifest = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, "mediaType": MANIFEST_MEDIA_TYPE, + "config": config.descriptor(), "layers": [layer.descriptor()] + })) + .unwrap(); + Image { + manifest, + blobs: vec![config, layer], + } +} + +#[test] +fn uploads_digest_closure_reads_it_back_and_skips_unchanged_publication() { + let server = Server::new(Fault::None); + let client = RegistryClient::loopback(server.port, Duration::from_secs(2)).unwrap(); + client.ready().unwrap(); + let image = fixture(); + assert!(client.publish("org/pkg", &image).unwrap()); + assert_eq!(server.writes.lock().unwrap().len(), 5); // 2 blobs (POST+PUT), manifest PUT. + assert!(!client.publish("org/pkg", &image).unwrap()); + assert_eq!(server.writes.lock().unwrap().len(), 5); +} + +#[test] +fn partial_upload_never_publishes_manifest_and_diagnostics_do_not_echo_server_data() { + let server = Server::new(Fault::PartialUpload); + let client = RegistryClient::loopback(server.port, Duration::from_secs(2)).unwrap(); + assert!(client.publish("org/pkg", &fixture()).is_err()); + assert!(server + .writes + .lock() + .unwrap() + .iter() + .all(|request| !request.contains("/manifests/"))); + let server = Server::new(Fault::Redirect); + let client = RegistryClient::loopback(server.port, Duration::from_secs(2)).unwrap(); + let error = client + .publish("org/pkg", &fixture()) + .unwrap_err() + .to_string(); + assert!(!error.contains("synthetic-secret-canary")); + assert_eq!(server.writes.lock().unwrap().len(), 1); +} + +#[test] +fn digest_header_alone_cannot_mask_corrupt_manifest_readback() { + let server = Server::new(Fault::BadReadback); + let client = RegistryClient::loopback(server.port, Duration::from_secs(2)).unwrap(); + assert!(client + .publish("org/pkg", &fixture()) + .unwrap_err() + .to_string() + .contains("readback bytes")); +} + +#[test] +fn malformed_artifacts_and_repository_paths_fail_before_any_upload() { + let server = Server::new(Fault::None); + let client = RegistryClient::loopback(server.port, Duration::from_secs(2)).unwrap(); + let mut image = fixture(); + image.blobs.clear(); + assert!(client.publish("org/pkg", &image).is_err()); + assert!(client.publish("../escape", &fixture()).is_err()); + assert!(server.writes.lock().unwrap().is_empty()); +}