diff --git a/Makefile b/Makefile index 3f24d102..a750f604 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ SERVICES := ssl-proxy java-coordinator atheros-sensor atheros-search wg-key-rota BUILD_TARGETS := $(addprefix build-,$(SERVICES)) PUBLISH_TARGETS := $(addprefix publish-,$(SERVICES)) -.PHONY: build build-all publish publish-all buildx-ready require-registry docs-check gitops-check $(BUILD_TARGETS) $(PUBLISH_TARGETS) +.PHONY: build build-all publish publish-all buildx-ready require-registry docs-check gitops-check test lint dependency-boundaries atheros-search-test $(BUILD_TARGETS) $(PUBLISH_TARGETS) build: build-all publish: publish-all @@ -34,6 +34,27 @@ docs-check: gitops-check: python3 scripts/check-gitops.py --kustomize "$(KUSTOMIZE)" +test: + cargo test -p sync-plane + cargo test -p ssl-proxy + cargo test -p atheros-sensor + cd apps/schema-migrator && sbt test + cd services/octopus && sbt test + $(MAKE) atheros-search-test + $(MAKE) dependency-boundaries + +lint: + cargo clippy -- -D warnings + +dependency-boundaries: + @command -v rg >/dev/null + @! rg -n 'use ssl_proxy|ssl_proxy::|ssl-proxy = \{ path = "\.\./\.\."' services/atheros-sensor services/atheros-sensor/Cargo.toml + @cargo tree -p atheros-sensor --depth 1 --prefix none | awk '$$1 == "ssl-proxy" { found=1; print; } END { exit found ? 1 : 0 }' + @cargo tree -p ssl-proxy --depth 1 --prefix none | awk '$$1 == "atheros-sensor" { found=1; print; } END { exit found ? 1 : 0 }' + +atheros-search-test: + cd services/atheros-search && go test ./... + require-registry: @test -n "$(REGISTRY)" || { echo "REGISTRY is required" >&2; exit 2; } diff --git a/cyber-stack/README.md b/cyber-stack/README.md index 3d76761b..d45a3ec2 100644 --- a/cyber-stack/README.md +++ b/cyber-stack/README.md @@ -15,7 +15,10 @@ The platform team provides and operates: or scoped token that can create image-update pull requests; - registry access for `192.168.1.221:5000`; - workload Secrets and the production TiDB endpoint ConfigMap required by the - rendered manifests. + rendered manifests; +- DNS and an `ssl-proxy-identity-tls` certificate for the hostname declared by + each environment overlay: `identity.dev.ssl-proxy.internal` for dev and + `identity.prod.ssl-proxy.internal` for prod. Platform inputs must be delivered by the platform's declarative control plane. Do not create or patch them by hand. This repository owns the `ssl-proxy` diff --git a/cyber-stack/argocd/application-app-stack.yaml b/cyber-stack/argocd/application-app-stack.yaml index 48d44975..4ceba7f2 100644 --- a/cyber-stack/argocd/application-app-stack.yaml +++ b/cyber-stack/argocd/application-app-stack.yaml @@ -15,6 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/dev/app-stack + kustomize: {} destination: server: https://kubernetes.default.svc namespace: dev-ssl-proxy diff --git a/cyber-stack/argocd/application-bootstrap.yaml b/cyber-stack/argocd/application-bootstrap.yaml index 6070113a..874ed23d 100644 --- a/cyber-stack/argocd/application-bootstrap.yaml +++ b/cyber-stack/argocd/application-bootstrap.yaml @@ -15,8 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/dev/bootstrap - directory: - recurse: false + kustomize: {} destination: server: https://kubernetes.default.svc namespace: dev-ssl-proxy diff --git a/cyber-stack/argocd/application-data-plane.yaml b/cyber-stack/argocd/application-data-plane.yaml index f86c3993..3611125c 100644 --- a/cyber-stack/argocd/application-data-plane.yaml +++ b/cyber-stack/argocd/application-data-plane.yaml @@ -15,8 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/dev/data-plane - directory: - recurse: false + kustomize: {} destination: server: https://kubernetes.default.svc namespace: dev-ssl-proxy diff --git a/cyber-stack/argocd/application-prod-app-stack.yaml b/cyber-stack/argocd/application-prod-app-stack.yaml index 389391c6..cd34d421 100644 --- a/cyber-stack/argocd/application-prod-app-stack.yaml +++ b/cyber-stack/argocd/application-prod-app-stack.yaml @@ -15,8 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/prod/app-stack - directory: - recurse: false + kustomize: {} destination: server: https://kubernetes.default.svc namespace: prod-ssl-proxy diff --git a/cyber-stack/argocd/application-prod-bootstrap.yaml b/cyber-stack/argocd/application-prod-bootstrap.yaml index 66759ecd..d1439273 100644 --- a/cyber-stack/argocd/application-prod-bootstrap.yaml +++ b/cyber-stack/argocd/application-prod-bootstrap.yaml @@ -15,8 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/prod/bootstrap - directory: - recurse: false + kustomize: {} destination: server: https://kubernetes.default.svc namespace: prod-ssl-proxy diff --git a/cyber-stack/argocd/application-prod-data-plane.yaml b/cyber-stack/argocd/application-prod-data-plane.yaml index 6b281087..70d86656 100644 --- a/cyber-stack/argocd/application-prod-data-plane.yaml +++ b/cyber-stack/argocd/application-prod-data-plane.yaml @@ -15,8 +15,7 @@ spec: repoURL: https://github.com/zlovtnik/ssl-proxy.git targetRevision: main path: cyber-stack/matrix/prod/data-plane - directory: - recurse: false + kustomize: {} destination: server: https://kubernetes.default.svc namespace: prod-ssl-proxy diff --git a/cyber-stack/base/atheros-search/deployment.yaml b/cyber-stack/base/atheros-search/deployment.yaml index cd40beea..93e96e0c 100644 --- a/cyber-stack/base/atheros-search/deployment.yaml +++ b/cyber-stack/base/atheros-search/deployment.yaml @@ -81,11 +81,6 @@ spec: value: grpc - name: OTEL_SERVICE_NAME value: atheros-search - - name: ATHSEARCH_API_TOKEN_SHA256 - valueFrom: - secretKeyRef: - name: atheros-credentials - key: api-token-sha256 ports: - name: http containerPort: 8080 diff --git a/cyber-stack/base/atheros-search/kustomization.yaml b/cyber-stack/base/atheros-search/kustomization.yaml new file mode 100644 index 00000000..9c2d28b0 --- /dev/null +++ b/cyber-stack/base/atheros-search/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml diff --git a/cyber-stack/base/atheros-sensor/kustomization.yaml b/cyber-stack/base/atheros-sensor/kustomization.yaml new file mode 100644 index 00000000..48f1d3d7 --- /dev/null +++ b/cyber-stack/base/atheros-sensor/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - daemonset.yaml diff --git a/cyber-stack/base/java-coordinator/kustomization.yaml b/cyber-stack/base/java-coordinator/kustomization.yaml new file mode 100644 index 00000000..9c2d28b0 --- /dev/null +++ b/cyber-stack/base/java-coordinator/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml diff --git a/cyber-stack/base/minio/kustomization.yaml b/cyber-stack/base/minio/kustomization.yaml new file mode 100644 index 00000000..b9031ef0 --- /dev/null +++ b/cyber-stack/base/minio/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - statefulset.yaml diff --git a/cyber-stack/base/platform-config/kustomization.yaml b/cyber-stack/base/platform-config/kustomization.yaml new file mode 100644 index 00000000..ed4b7eac --- /dev/null +++ b/cyber-stack/base/platform-config/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - serviceaccount.yaml + - configmap.yaml diff --git a/cyber-stack/base/proxy/deployment.yaml b/cyber-stack/base/proxy/deployment.yaml index 74218f5b..2d7f6fd9 100644 --- a/cyber-stack/base/proxy/deployment.yaml +++ b/cyber-stack/base/proxy/deployment.yaml @@ -129,6 +129,7 @@ spec: value: "ssl-proxy" ports: - containerPort: 443 + hostPort: 443 protocol: UDP name: wireguard - containerPort: 51820 diff --git a/cyber-stack/base/proxy/kustomization.yaml b/cyber-stack/base/proxy/kustomization.yaml new file mode 100644 index 00000000..a33121c3 --- /dev/null +++ b/cyber-stack/base/proxy/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml diff --git a/cyber-stack/base/redis-runtime/kustomization.yaml b/cyber-stack/base/redis-runtime/kustomization.yaml new file mode 100644 index 00000000..9c2d28b0 --- /dev/null +++ b/cyber-stack/base/redis-runtime/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml diff --git a/cyber-stack/base/redpanda/kustomization.yaml b/cyber-stack/base/redpanda/kustomization.yaml new file mode 100644 index 00000000..b9031ef0 --- /dev/null +++ b/cyber-stack/base/redpanda/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - statefulset.yaml diff --git a/cyber-stack/base/schema-migrator/keycloak.yaml b/cyber-stack/base/schema-migrator/keycloak.yaml index 3ba992c3..ec7656fb 100644 --- a/cyber-stack/base/schema-migrator/keycloak.yaml +++ b/cyber-stack/base/schema-migrator/keycloak.yaml @@ -127,8 +127,8 @@ spec: - name: KC_DB_PASSWORD valueFrom: secretKeyRef: - name: schema-migrator-keycloak - key: database-password + name: tidb-keycloak + key: password - name: KC_BOOTSTRAP_ADMIN_USERNAME value: "keycloak-admin" - name: KC_BOOTSTRAP_ADMIN_PASSWORD diff --git a/cyber-stack/base/telemetry/kustomization.yaml b/cyber-stack/base/telemetry/kustomization.yaml new file mode 100644 index 00000000..d253815d --- /dev/null +++ b/cyber-stack/base/telemetry/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - prometheus.yaml + - prometheus-configmap.yaml + - grafana.yaml + - grafana-configmaps.yaml + - loki.yaml + - loki-configmap.yaml + - promtail.yaml + - promtail-configmap.yaml + - jaeger.yaml + - otel-collector.yaml + - otel-collector-configmap.yaml + - node-exporter.yaml + - cadvisor.yaml + - pushgateway.yaml diff --git a/cyber-stack/base/tidb/kustomization.yaml b/cyber-stack/base/tidb/kustomization.yaml new file mode 100644 index 00000000..b9031ef0 --- /dev/null +++ b/cyber-stack/base/tidb/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - statefulset.yaml diff --git a/cyber-stack/matrix/dev/app-stack/kustomization.yaml b/cyber-stack/matrix/dev/app-stack/kustomization.yaml index 02733400..521edfed 100644 --- a/cyber-stack/matrix/dev/app-stack/kustomization.yaml +++ b/cyber-stack/matrix/dev/app-stack/kustomization.yaml @@ -37,6 +37,7 @@ images: newName: 192.168.1.221:5000/schema-migrator-ui digest: sha256:dfeda482650825f803fa84ee70b2a1df32bd807aa3e26b398407a4c9be69f536 patches: + - path: ../patches/identity-hostname.yaml - path: ../patches/proxy-replicas.yaml - path: ../patches/octopus-environment.yaml configurations: diff --git a/cyber-stack/matrix/dev/bootstrap/kustomization.yaml b/cyber-stack/matrix/dev/bootstrap/kustomization.yaml index 4aae826d..9bf5f046 100644 --- a/cyber-stack/matrix/dev/bootstrap/kustomization.yaml +++ b/cyber-stack/matrix/dev/bootstrap/kustomization.yaml @@ -5,6 +5,8 @@ resources: - ../namespace.yaml - ../../../base/platform-config/serviceaccount.yaml - ../../../base/platform-config/configmap.yaml +patches: + - path: ../patches/identity-hostname.yaml configurations: - ../../../base/schema-migrator/kustomizeconfig.yaml vars: diff --git a/cyber-stack/matrix/dev/data-plane/kustomization.yaml b/cyber-stack/matrix/dev/data-plane/kustomization.yaml index 2953ee9b..8cbd70a7 100644 --- a/cyber-stack/matrix/dev/data-plane/kustomization.yaml +++ b/cyber-stack/matrix/dev/data-plane/kustomization.yaml @@ -24,7 +24,7 @@ resources: images: - name: tidb-runtime-schema newName: 192.168.1.221:5000/tidb-runtime-schema - digest: sha256:dc9405a0c6d023e57fc975c5bc667406d81ca73d20c1936b266c7b8d90b4af80 + digest: sha256:5b04ee036ef89f9fefcaea3788132a7bc3982aaa93f9c8bc3a436abc2ec6f09e patches: - path: ../patches/tidb-replicas.yaml - path: ../patches/alloy-positions.yaml diff --git a/cyber-stack/matrix/dev/patches/identity-hostname.yaml b/cyber-stack/matrix/dev/patches/identity-hostname.yaml new file mode 100644 index 00000000..80914a48 --- /dev/null +++ b/cyber-stack/matrix/dev/patches/identity-hostname.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ssl-proxy-platform-config + namespace: ssl-proxy +data: + IDENTITY_HOSTNAME: identity.dev.ssl-proxy.internal diff --git a/cyber-stack/matrix/prod/app-stack/kustomization.yaml b/cyber-stack/matrix/prod/app-stack/kustomization.yaml index 072eb4f7..b71dfe74 100644 --- a/cyber-stack/matrix/prod/app-stack/kustomization.yaml +++ b/cyber-stack/matrix/prod/app-stack/kustomization.yaml @@ -32,6 +32,7 @@ images: newName: 192.168.1.221:5000/schema-migrator-ui digest: sha256:dfeda482650825f803fa84ee70b2a1df32bd807aa3e26b398407a4c9be69f536 patches: + - path: ../patches/identity-hostname.yaml - target: kind: ConfigMap labelSelector: app.kubernetes.io/component=platform-config @@ -41,7 +42,6 @@ patches: value: "true" - path: ../patches/proxy-replicas.yaml - path: ../patches/resources.yaml - - path: ../patches/replication-factor.yaml - path: ../patches/external-tidb.yaml configurations: - ../../../base/schema-migrator/kustomizeconfig.yaml diff --git a/cyber-stack/matrix/prod/bootstrap/kustomization.yaml b/cyber-stack/matrix/prod/bootstrap/kustomization.yaml index aec65942..f34110da 100644 --- a/cyber-stack/matrix/prod/bootstrap/kustomization.yaml +++ b/cyber-stack/matrix/prod/bootstrap/kustomization.yaml @@ -6,7 +6,7 @@ resources: - ../../../base/platform-config/serviceaccount.yaml - ../../../base/platform-config/configmap.yaml patches: - - path: ../patches/topic-replication.yaml + - path: ../patches/identity-hostname.yaml configurations: - ../../../base/schema-migrator/kustomizeconfig.yaml vars: diff --git a/cyber-stack/matrix/prod/patches/identity-hostname.yaml b/cyber-stack/matrix/prod/patches/identity-hostname.yaml new file mode 100644 index 00000000..4555325f --- /dev/null +++ b/cyber-stack/matrix/prod/patches/identity-hostname.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ssl-proxy-platform-config + namespace: ssl-proxy +data: + IDENTITY_HOSTNAME: identity.prod.ssl-proxy.internal diff --git a/cyber-stack/matrix/prod/patches/replication-factor.yaml b/cyber-stack/matrix/prod/patches/replication-factor.yaml deleted file mode 100644 index c5398403..00000000 --- a/cyber-stack/matrix/prod/patches/replication-factor.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ssl-proxy-java-coordinator - namespace: ssl-proxy -spec: - template: - spec: - containers: - - name: java-coordinator - env: - - name: SYNC_REDPANDA_TOPIC_REPLICATION_FACTOR - value: "3" diff --git a/cyber-stack/matrix/prod/patches/topic-replication.yaml b/cyber-stack/matrix/prod/patches/topic-replication.yaml deleted file mode 100644 index 0158738f..00000000 --- a/cyber-stack/matrix/prod/patches/topic-replication.yaml +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: ssl-proxy-topics-manifest - namespace: ssl-proxy -data: - topics.manifest: | - # topic|partitions|replicas|retention.ms|retention.bytes - sync.scan.request|24|3|2592000000|-1 - sync.scan.request.dlq|24|3|2592000000|-1 - sync.oracle.load|24|3|2592000000|-1 - sync.oracle.load.dlq|24|3|2592000000|-1 - sync.oracle.result|24|3|2592000000|-1 - sync.oracle.result.dlq|24|3|2592000000|-1 - proxy.events|24|3|2592000000|-1 - proxy.events.dlq|24|3|2592000000|-1 - proxy.payload_audit|3|3|2592000000|-1 - proxy.payload_audit.dlq|3|3|2592000000|-1 - wireless.audit|3|3|2592000000|-1 - wireless.mac.seen|3|3|2592000000|-1 - wireless.audit.enriched|3|3|2592000000|-1 - wireless.audit.config|1|3|2592000000|-1 - wireless.config.authorized_networks|1|3|2592000000|-1 - wireless.config.sensor|1|3|2592000000|-1 - wireless.client.inventory|3|3|2592000000|-1 - audit.threat.shadow_device|3|3|2592000000|-1 - wireless.mac.registry|1|3|2592000000|-1 - wireless.backlog.save|3|3|2592000000|-1 - wireless.backlog.save.dlq|3|3|2592000000|-1 - wireless.backlog.list|3|3|2592000000|-1 - wireless.backlog.list.dlq|3|3|2592000000|-1 - wireless.backlog.synced|3|3|2592000000|-1 - wireless.backlog.synced.dlq|3|3|2592000000|-1 - wireless.backlog.prune|3|3|2592000000|-1 - wireless.backlog.prune.dlq|3|3|2592000000|-1 - wireless.backlog.list.reply|3|3|300000|-1 - wireless.backlog.prune.reply|3|3|300000|-1 - wireless.mac.lookup|3|3|2592000000|-1 - wireless.mac.lookup.dlq|3|3|2592000000|-1 - wireless.mac.lookup.reply|3|3|300000|-1 - wireless.networks.authorized|3|3|2592000000|-1 - wireless.networks.authorized.dlq|3|3|2592000000|-1 - wireless.networks.authorized.reply|3|3|300000|-1 - wireless.probe.flush|3|3|3600000|-1 - wireless.probe.flush.dlq|3|3|2592000000|-1 - audit.wireless.bandwidth|3|3|604800000|-1 - wireless.alert.rogue_ap|3|3|2592000000|-1 - wireless.alert.deauth_flood|3|3|2592000000|-1 - wireless.alert.signal_anomaly|3|3|2592000000|-1 - wireless.alert.pmf_attack|3|3|2592000000|-1 - wireless.alert.attack_sequence|3|3|2592000000|-1 - wifi.alert.handshake|3|3|2592000000|-1 diff --git a/k8s/tidb-schema-executor/entrypoint.sh b/k8s/tidb-schema-executor/entrypoint.sh index 3eb4ce49..ac40f548 100644 --- a/k8s/tidb-schema-executor/entrypoint.sh +++ b/k8s/tidb-schema-executor/entrypoint.sh @@ -104,6 +104,24 @@ apply_grant_fixture() { "${fixture}" | mysql_run } +revoke_schema_privileges() { + database="$1" + account="$2" + privileges="$(mysql_run --batch --skip-column-names -e " + SELECT COALESCE( + GROUP_CONCAT(privilege_type ORDER BY privilege_type SEPARATOR ', '), + '' + ) + FROM information_schema.schema_privileges + WHERE table_schema = '${database}' + AND REPLACE(SUBSTRING_INDEX(grantee, '@', 1), CHAR(39), '') = '${account}'; + ")" + + if [ -n "${privileges}" ]; then + mysql_run -e "REVOKE ${privileges} ON ${database}.* FROM '${account}';" + fi +} + for domain in octopus_core atheros_search integration_console schema_migrator; do apply_domain "${domain}" done @@ -111,8 +129,9 @@ done # The TiDB bootstrap job grants database-wide privileges so it can create # accounts before tables exist. Once canonical DDL is present, replace those # bootstrap grants with the checked-in table ownership matrix. -mysql_run -e "REVOKE SELECT, INSERT, UPDATE, DELETE ON atheros_search.* FROM '${octopus_account}', '${atheros_search_account}';" -mysql_run -e "REVOKE SELECT ON octopus_core.* FROM '${atheros_search_account}';" +revoke_schema_privileges atheros_search "${octopus_account}" +revoke_schema_privileges atheros_search "${atheros_search_account}" +revoke_schema_privileges octopus_core "${atheros_search_account}" apply_grant_fixture "${schema_root}/octopus_core/grants/least_privilege.sql.tmpl" apply_grant_fixture "${schema_root}/atheros_search/grants/least_privilege.sql.tmpl" apply_grant_fixture "${schema_root}/schema_migrator/grants/least_privilege.sql.tmpl" diff --git a/ops/src/sslproxy_ops/commands/up_ready/kubernetes.py b/ops/src/sslproxy_ops/commands/up_ready/kubernetes.py index a30a93c9..4d21e4c0 100644 --- a/ops/src/sslproxy_ops/commands/up_ready/kubernetes.py +++ b/ops/src/sslproxy_ops/commands/up_ready/kubernetes.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import base64 import hashlib import json @@ -1473,29 +1472,11 @@ def stackctl_preflight(ctx: UpReadyContext) -> None: def stackctl_deploy(ctx: UpReadyContext) -> bool: - """Deploy the canonical split stack with the up-ready runtime values.""" + """Reject the retired direct-deploy path for the split stack.""" - from sslproxy_ops.stack.core import load_config, load_umbrella_values - from sslproxy_ops.stack.deploy import DeployOptions, deploy_stack + from sslproxy_ops.stack.deploy import GITOPS_DEPLOYMENT_MESSAGE - preflight_required_secrets(ctx) - root = repo_root() - config = load_config(root / "stackctl" / "stack.yaml") - options = DeployOptions( - namespace=ctx.settings.kube_namespace, - context=ctx.settings.kube_context or None, - root_dir=root, - umbrella_values=load_umbrella_values(config, root), - max_parallel=config.defaults.max_parallel, - runtime_overrides=_stack_runtime_overrides(ctx), - artifact_dir=root / config.defaults.artifact_dir, - ) - step("S03", "stackctl_deploy: bootstrap plus five split-release waves") - result = asyncio.run(deploy_stack(config, options)) - if not result.success: - failed = [item.component for item in result.component_results if not item.success] - raise UpReadyError("stackctl deployment failed: " + ", ".join(failed)) - return True + raise UpReadyError(GITOPS_DEPLOYMENT_MESSAGE) def deploy_kubernetes_release(ctx: UpReadyContext) -> bool: diff --git a/ops/src/sslproxy_ops/stack/core.py b/ops/src/sslproxy_ops/stack/core.py index 6c50580d..6f46bbd9 100644 --- a/ops/src/sslproxy_ops/stack/core.py +++ b/ops/src/sslproxy_ops/stack/core.py @@ -501,10 +501,14 @@ def generate_effective_values( if root_dir: chart_path = Path(root_dir) / component.chart values_file = chart_path / "values.yaml" + kustomization_file = chart_path / "kustomization.yaml" if not chart_path.is_dir(): raise FileNotFoundError(f"Chart directory not found: {chart_path}") - if not values_file.is_file(): - raise FileNotFoundError(f"Chart values file not found: {values_file}") + if not values_file.is_file() and not kustomization_file.is_file(): + raise FileNotFoundError( + f"Component source has neither values.yaml nor kustomization.yaml: " + f"{chart_path}" + ) merged: dict[str, Any] = {} for values_file in umbrella_values: @@ -840,13 +844,16 @@ def cmd_compare(args: argparse.Namespace) -> int: def cmd_dry_run(args: argparse.Namespace) -> int: - """Run Helm server-side dry-runs without retaining raw values.""" + """Render component Kustomizations without mutating a cluster.""" return _run_deploy(args, dry_run=True) def cmd_deploy(args: argparse.Namespace) -> int: - """Deploy components wave-by-wave with concurrent execution.""" - return _run_deploy(args, dry_run=False) + """Reject live deployment in favor of the repository GitOps surface.""" + from .deploy import GITOPS_DEPLOYMENT_MESSAGE + + print(f"ERROR: {GITOPS_DEPLOYMENT_MESSAGE}", file=sys.stderr) + return 2 def _run_deploy(args: argparse.Namespace, dry_run: bool) -> int: @@ -1174,7 +1181,11 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("preflight", parents=[common], help="Check cluster prerequisites") subparsers.add_parser("dry-run", parents=[common], help="Dry-run deployment") - deploy_parser = subparsers.add_parser("deploy", parents=[common], help="Deploy components") + deploy_parser = subparsers.add_parser( + "deploy", + parents=[common], + help="Explain the GitOps deployment requirement", + ) deploy_parser.add_argument( "--keep-artifacts", action="store_true", diff --git a/ops/src/sslproxy_ops/stack/deploy.py b/ops/src/sslproxy_ops/stack/deploy.py index b3cfb2db..c89fd0f5 100644 --- a/ops/src/sslproxy_ops/stack/deploy.py +++ b/ops/src/sslproxy_ops/stack/deploy.py @@ -22,10 +22,9 @@ StackConfig, generate_effective_values, resolve_dependencies, - staged_waves, ) from .gates import parse_timeout_seconds, wait_for_gates -from .shell import ShellError, helm, kubectl, kustomize_apply, kustomize_build +from .shell import ShellError, helm, kubectl, kustomize_build # --------------------------------------------------------------------------- # Data classes @@ -36,6 +35,11 @@ re.IGNORECASE, ) +GITOPS_DEPLOYMENT_MESSAGE = ( + "live stackctl deployment is disabled; change cyber-stack desired state, " + "merge it to main, and let Argo CD reconcile it" +) + @dataclass class DeployOptions: @@ -387,7 +391,7 @@ def _kustomize_deploy( root_dir: Path | None = None, rollback_state: list[dict[str, Any]] | None = None, ) -> subprocess.CompletedProcess[str]: - """Apply a kustomize overlay for a component.""" + """Render a component overlay without mutating a cluster.""" release = component.release chart = component.chart if not chart: @@ -406,37 +410,12 @@ def _kustomize_deploy( if not overlay_src.is_dir(): raise FileNotFoundError(f"Component overlay not found: {overlay_src}") - if dry_run: - return kustomize_apply( - str(overlay_src), - namespace=namespace, - dry_run=True, - context=context, - kubeconfig=kubeconfig, - timeout=timeout, - ) - if rollback_state is not None: - rendered = kustomize_build( - str(overlay_src), - context=context, - kubeconfig=kubeconfig, - ) - rollback_state.extend( - _capture_kustomize_rollback_state( - rendered.stdout, - namespace, - context, - kubeconfig, - ) - ) - return kustomize_apply( + if not dry_run: + raise RuntimeError(GITOPS_DEPLOYMENT_MESSAGE) + return kustomize_build( str(overlay_src), - namespace=namespace, - wait_for_completion=wait_for_completion, - release=release, context=context, kubeconfig=kubeconfig, - timeout=timeout, ) @@ -893,6 +872,8 @@ def deploy_component( rollback_state: list[dict[str, Any]] = [] try: + if not options.dry_run and component.type != "external-check": + raise RuntimeError(GITOPS_DEPLOYMENT_MESSAGE) if component.type == "manifest": _deploy_manifest(component_name, component, options, run_dir) if not options.dry_run: @@ -1089,74 +1070,23 @@ async def deploy_stack( config: StackConfig, options: DeployOptions, ) -> DeployResult: - """Deploy all components in wave order. + """Render all component Kustomizations in dependency order. - Each wave runs concurrently (asyncio.gather) - If one sibling fails: finish collecting, mark wave failed, stop - No next wave starts after a failure - - Successful sibling upgrades are retained - - No stateful data is deleted on failure + - Live deployment is rejected so Argo CD remains the sole reconciler """ + if not options.dry_run: + raise RuntimeError(GITOPS_DEPLOYMENT_MESSAGE) + run_started = datetime.now(UTC) - dry_run_baseline = ( - await asyncio.to_thread(_cluster_mutation_snapshot, options) if options.dry_run else None - ) waves = resolve_dependencies( config, target_component=options.target_component, from_wave=options.from_wave, include_descendants=options.include_descendants, ) - if hasattr(config, "model_copy") and (options.target_component or options.from_wave): - from .cluster import component_health - - if options.from_wave: - bootstrap, numbered = staged_waves(config) - earlier = set(bootstrap) - for wave in numbered[: options.from_wave - 1]: - earlier.update(wave) - health = component_health( - config, - earlier, - options.namespace, - options.context, - options.kubeconfig, - ) - degraded = sorted(name for name, healthy in health.items() if not healthy) - if degraded: - raise RuntimeError( - "--from-wave requires every earlier wave to be healthy; degraded: " - + ", ".join(degraded) - ) - - if options.target_component: - dependencies: set[str] = set() - pending = list(config.components[options.target_component].depends_on) - while pending: - name = pending.pop() - if name in dependencies: - continue - dependencies.add(name) - pending.extend(config.components[name].depends_on) - health = component_health( - config, - dependencies, - options.namespace, - options.context, - options.kubeconfig, - ) - selected = { - name - for wave in waves - for name in wave - if ( - name not in dependencies - or config.components[name].type == "helm-job" - or not health.get(name, False) - ) - } - waves = [[name for name in wave if name in selected] for wave in waves] - waves = [wave for wave in waves if wave] # Determine run directory if options.work_dir: @@ -1223,17 +1153,6 @@ async def deploy_stack( print(f"Wave {wave_num} OK") overall_success = waves_completed == len(waves) - if overall_success and dry_run_baseline is not None: - after = await asyncio.to_thread(_cluster_mutation_snapshot, options) - if after != dry_run_baseline: - overall_success = False - all_results.append( - ComponentResult( - component="dry-run-mutation-proof", - success=False, - error=("server dry-run changed Kubernetes resource UIDs"), - ) - ) return DeployResult( success=overall_success, waves_completed=waves_completed, diff --git a/scripts/check-gitops.py b/scripts/check-gitops.py index bdc723b9..a1fa2a55 100755 --- a/scripts/check-gitops.py +++ b/scripts/check-gitops.py @@ -110,11 +110,102 @@ def _check_proxy_probes(rendered: str, relative: str) -> list[str]: return errors +def _check_proxy_wireguard_route(rendered: str, relative: str) -> list[str]: + if "name: ssl-proxy-proxy" not in rendered: + return [] + route = re.search(r"containerPort:\s*443(?P.{0,160})", rendered, re.DOTALL) + body = route.group("body") if route is not None else "" + if "hostPort: 443" not in body or "protocol: UDP" not in body: + return [f"{relative}: proxy WireGuard UDP/443 has no external hostPort route"] + return [] + + +def _check_atheros_search_auth(rendered: str, relative: str) -> list[str]: + if "name: ssl-proxy-atheros-search" not in rendered: + return [] + if "ATHSEARCH_API_TOKEN_SHA256" in rendered: + return [ + f"{relative}: browser-facing Atheros Search enables bearer auth " + "without a browser or trusted-proxy credential flow" + ] + return [] + + +def _check_keycloak_database_credential(rendered: str, relative: str) -> list[str]: + if "name: ssl-proxy-schema-migrator-keycloak" not in rendered: + return [] + password = re.search( + r"name:\s+KC_DB_PASSWORD(?P.{0,300})", + rendered, + re.DOTALL, + ) + body = password.group("body") if password is not None else "" + if "name: tidb-keycloak" not in body or "key: password" not in body: + return [ + f"{relative}: Keycloak database password must come from " + "tidb-keycloak/password" + ] + return [] + + +def _check_redpanda_topic_replication(rendered: str, relative: str) -> list[str]: + broker_match = re.search( + r"kind:\s+StatefulSet.*?name:\s+ssl-proxy-redpanda.*?" + r"spec:\s+replicas:\s+(\d+)", + rendered, + re.DOTALL, + ) + if broker_match is None or "topics.manifest" not in rendered: + return [] + broker_count = int(broker_match.group(1)) + topic_replication = [ + int(value) + for value in re.findall( + r"(?m)^\s+[a-z0-9._-]+\|\d+\|(\d+)\|", + rendered, + ) + ] + producer_replication = [ + int(value) + for value in re.findall( + r"name:\s+SYNC_REDPANDA_TOPIC_REPLICATION_FACTOR\s+value:\s+\"?(\d+)\"?", + rendered, + ) + ] + requested_replication = topic_replication + producer_replication + if requested_replication and max(requested_replication) > broker_count: + return [ + f"{relative}: topic replication factor {max(requested_replication)} " + f"exceeds Redpanda broker count {broker_count}" + ] + return [] + + +def _check_environment_identity_hostnames(rendered: dict[str, str]) -> list[str]: + hostnames: dict[str, str] = {} + errors: list[str] = [] + for environment in ("dev", "prod"): + relative = f"cyber-stack/matrix/{environment}/bootstrap" + match = re.search( + r"(?m)^\s*IDENTITY_HOSTNAME:\s*([^\s]+)\s*$", + rendered.get(relative, ""), + ) + if match is None: + errors.append(f"{relative}: environment identity hostname is missing") + continue + hostname = match.group(1).strip("\"'") + hostnames[environment] = hostname + if ".example." in hostname or hostname.endswith(".example"): + errors.append(f"{relative}: example identity hostname is not deployable") + if len(hostnames) == 2 and hostnames["dev"] == hostnames["prod"]: + errors.append("dev and prod must not share an identity hostname") + return errors + + def _check_traefik_redirect(rendered: str, relative: str) -> list[str]: if "entrypoints.web.http.redirections.entrypoint.port" in rendered: return [ - f"{relative}: Traefik entrypoint.port is not a supported " - "redirection field" + f"{relative}: Traefik entrypoint.port is not a supported redirection field" ] return [] @@ -214,6 +305,7 @@ def _image_pin_counts(text: str) -> tuple[int, int]: def check_repository(root: Path, executable: str) -> list[str]: errors: list[str] = [] + rendered_kustomizations: dict[str, str] = {} for relative in CANONICAL_KUSTOMIZATIONS: path = root / relative @@ -224,19 +316,35 @@ def check_repository(root: Path, executable: str) -> list[str]: if error: errors.append(error) continue + rendered_kustomizations[relative] = rendered if re.search(r"(?m)^\s*image:\s+\S+:latest\s*$", rendered): errors.append(f"{relative}: rendered workload uses a mutable latest tag") for image in FIRST_PARTY_IMAGES: - if re.search(rf"(?m)^\s*image:\s+{re.escape(image)}(?::\S+)?\s*$", rendered): + if re.search( + rf"(?m)^\s*image:\s+{re.escape(image)}(?::\S+)?\s*$", rendered + ): errors.append( f"{relative}: rendered workload retains logical image name {image}" ) errors.extend(_check_otel_endpoint(rendered, relative)) errors.extend(_check_redpanda_memory(rendered, relative)) errors.extend(_check_proxy_probes(rendered, relative)) + errors.extend(_check_proxy_wireguard_route(rendered, relative)) + errors.extend(_check_atheros_search_auth(rendered, relative)) + errors.extend(_check_keycloak_database_credential(rendered, relative)) + errors.extend(_check_redpanda_topic_replication(rendered, relative)) errors.extend(_check_traefik_redirect(rendered, relative)) errors.extend(_check_tidb_waves(rendered, relative)) + errors.extend(_check_environment_identity_hostnames(rendered_kustomizations)) + prod_rendered = "\n".join( + rendered_kustomizations.get(f"cyber-stack/matrix/prod/{component}", "") + for component in ("bootstrap", "data-plane", "app-stack") + ) + errors.extend( + _check_redpanda_topic_replication(prod_rendered, "cyber-stack/matrix/prod") + ) + for filename, source_path in APPLICATIONS.items(): relative = Path("cyber-stack/argocd") / filename text = _read_required(root, relative, errors, "Application manifest") @@ -253,10 +361,12 @@ def check_repository(root: Path, executable: str) -> list[str]: for value in required: if value not in text: errors.append(f"{relative}: missing {value!r}") + if "kustomize:" not in text: + errors.append(f"{relative}: source must select the Kustomize renderer") + if "directory:" in text: + errors.append(f"{relative}: plain-directory renderer is not allowed") if "CreateNamespace=true" in text: - errors.append( - f"{relative}: namespace creation must come from Git" - ) + errors.append(f"{relative}: namespace creation must come from Git") updater_relative = Path("cyber-stack/argocd/image-updater-dev.yaml") updater = _read_required(root, updater_relative, errors, "Image Updater manifest") @@ -278,7 +388,12 @@ def check_repository(root: Path, executable: str) -> list[str]: for environment in ("dev", "prod"): for component in ("data-plane", "app-stack"): - relative = Path("cyber-stack/matrix") / environment / component / "kustomization.yaml" + relative = ( + Path("cyber-stack/matrix") + / environment + / component + / "kustomization.yaml" + ) text = _read_required(root, relative, errors, "component kustomization") if text is None: continue @@ -301,7 +416,29 @@ def check_repository(root: Path, executable: str) -> list[str]: makefile = (root / "Makefile").read_text(encoding="utf-8") for forbidden in ("argocd-update", "release-all", "kubectl patch application"): if forbidden in makefile: - errors.append(f"Makefile: live-cluster promotion surface remains: {forbidden}") + errors.append( + f"Makefile: live-cluster promotion surface remains: {forbidden}" + ) + for target in ("test", "lint", "dependency-boundaries", "atheros-search-test"): + if re.search(rf"(?m)^{re.escape(target)}:\s*$", makefile) is None: + errors.append( + f"Makefile: documented verification target is missing: {target}" + ) + + stack_config = (root / "stackctl/stack.yaml").read_text(encoding="utf-8") + for configured in re.findall(r"(?m)^\s+chart:\s+(.+?)\s*$", stack_config): + overlay = (root / configured.removeprefix("./")).resolve() + if not (overlay / "kustomization.yaml").is_file(): + errors.append( + "stackctl/stack.yaml: component overlay is not a Kustomization: " + f"{configured}" + ) + + deploy_source = (root / "ops/src/sslproxy_ops/stack/deploy.py").read_text( + encoding="utf-8" + ) + if "kustomize_apply" in deploy_source: + errors.append("stackctl: direct Kustomize cluster apply path remains") return errors diff --git a/scripts/tests/test_check_gitops.py b/scripts/tests/test_check_gitops.py index 4a9a48f7..f788e7f8 100644 --- a/scripts/tests/test_check_gitops.py +++ b/scripts/tests/test_check_gitops.py @@ -161,6 +161,92 @@ def test_skips_non_proxy_rendered(self) -> None: self.assertEqual([], check_gitops._check_proxy_probes(rendered, "test")) +class GitOpsRegressionCheckTest(unittest.TestCase): + def test_proxy_wireguard_requires_host_route(self) -> None: + rendered = ( + "name: ssl-proxy-proxy\nports:\n - containerPort: 443\n protocol: UDP\n" + ) + self.assertEqual( + 1, len(check_gitops._check_proxy_wireguard_route(rendered, "test")) + ) + + def test_proxy_wireguard_accepts_host_port(self) -> None: + rendered = ( + "name: ssl-proxy-proxy\n" + "ports:\n" + " - containerPort: 443\n" + " hostPort: 443\n" + " protocol: UDP\n" + ) + self.assertEqual( + [], check_gitops._check_proxy_wireguard_route(rendered, "test") + ) + + def test_atheros_search_rejects_unusable_bearer_auth(self) -> None: + rendered = ( + "name: ssl-proxy-atheros-search\n" + "env:\n" + " - name: ATHSEARCH_API_TOKEN_SHA256\n" + ) + self.assertEqual( + 1, len(check_gitops._check_atheros_search_auth(rendered, "test")) + ) + + def test_keycloak_requires_provisioned_database_secret(self) -> None: + rendered = ( + "name: ssl-proxy-schema-migrator-keycloak\n" + "- name: KC_DB_PASSWORD\n" + " valueFrom:\n" + " secretKeyRef:\n" + " key: password\n" + " name: tidb-keycloak\n" + ) + self.assertEqual( + [], check_gitops._check_keycloak_database_credential(rendered, "test") + ) + + def test_topic_replication_cannot_exceed_brokers(self) -> None: + rendered = ( + "kind: StatefulSet\n" + "metadata:\n" + " name: ssl-proxy-redpanda\n" + "spec:\n" + " replicas: 1\n" + "topics.manifest: |\n" + " sync.scan.request|24|3|1000|-1\n" + ) + self.assertEqual( + 1, + len(check_gitops._check_redpanda_topic_replication(rendered, "test")), + ) + + def test_identity_hostnames_are_environment_specific(self) -> None: + rendered = { + "cyber-stack/matrix/dev/bootstrap": ( + "data:\n IDENTITY_HOSTNAME: identity.dev.ssl-proxy.internal\n" + ), + "cyber-stack/matrix/prod/bootstrap": ( + "data:\n IDENTITY_HOSTNAME: identity.prod.ssl-proxy.internal\n" + ), + } + self.assertEqual( + [], check_gitops._check_environment_identity_hostnames(rendered) + ) + + def test_identity_hostnames_reject_example_and_shared_values(self) -> None: + rendered = { + "cyber-stack/matrix/dev/bootstrap": ( + "data:\n IDENTITY_HOSTNAME: identity.example.internal\n" + ), + "cyber-stack/matrix/prod/bootstrap": ( + "data:\n IDENTITY_HOSTNAME: identity.example.internal\n" + ), + } + self.assertEqual( + 3, len(check_gitops._check_environment_identity_hostnames(rendered)) + ) + + class TraefikRedirectCheckTest(unittest.TestCase): def test_rejects_unsupported_port_field(self) -> None: rendered = "entrypoints.web.http.redirections.entrypoint.port=:443" @@ -179,11 +265,11 @@ def test_accepts_valid_redirect(self) -> None: class TiDBWaveCheckTest(unittest.TestCase): def test_rejects_init_wave_not_less_than_schema(self) -> None: rendered = ( - 'name: ssl-proxy-tidb-init\n annotations:\n' + "name: ssl-proxy-tidb-init\n annotations:\n" ' argocd.argoproj.io/sync-wave: "1"\n' - 'name: ssl-proxy-tidb-schema-executor\n annotations:\n' + "name: ssl-proxy-tidb-schema-executor\n annotations:\n" ' argocd.argoproj.io/sync-wave: "1"\n' - 'name: ssl-proxy-tidb-init-grants\n annotations:\n' + "name: ssl-proxy-tidb-init-grants\n annotations:\n" ' argocd.argoproj.io/sync-wave: "2"' ) errors = check_gitops._check_tidb_waves(rendered, "test") @@ -191,11 +277,11 @@ def test_rejects_init_wave_not_less_than_schema(self) -> None: def test_rejects_schema_wave_not_less_than_grants(self) -> None: rendered = ( - 'name: ssl-proxy-tidb-init\n annotations:\n' + "name: ssl-proxy-tidb-init\n annotations:\n" ' argocd.argoproj.io/sync-wave: "0"\n' - 'name: ssl-proxy-tidb-schema-executor\n annotations:\n' + "name: ssl-proxy-tidb-schema-executor\n annotations:\n" ' argocd.argoproj.io/sync-wave: "2"\n' - 'name: ssl-proxy-tidb-init-grants\n annotations:\n' + "name: ssl-proxy-tidb-init-grants\n annotations:\n" ' argocd.argoproj.io/sync-wave: "2"' ) errors = check_gitops._check_tidb_waves(rendered, "test") @@ -203,11 +289,11 @@ def test_rejects_schema_wave_not_less_than_grants(self) -> None: def test_accepts_correct_wave_ordering(self) -> None: rendered = ( - 'name: ssl-proxy-tidb-init\n annotations:\n' + "name: ssl-proxy-tidb-init\n annotations:\n" ' argocd.argoproj.io/sync-wave: "0"\n' - 'name: ssl-proxy-tidb-schema-executor\n annotations:\n' + "name: ssl-proxy-tidb-schema-executor\n annotations:\n" ' argocd.argoproj.io/sync-wave: "1"\n' - 'name: ssl-proxy-tidb-init-grants\n annotations:\n' + "name: ssl-proxy-tidb-init-grants\n annotations:\n" ' argocd.argoproj.io/sync-wave: "2"' ) self.assertEqual([], check_gitops._check_tidb_waves(rendered, "test")) diff --git a/stackctl/stack.yaml b/stackctl/stack.yaml index 8654e241..d38f75fa 100644 --- a/stackctl/stack.yaml +++ b/stackctl/stack.yaml @@ -17,7 +17,7 @@ components: type: helm stage: bootstrap release: ssl-proxy-platform-config - chart: ./helm/ssl-proxy/charts/platform-config + chart: ./cyber-stack/base/platform-config values_key: platformConfig include_global: true gates: @@ -27,7 +27,7 @@ components: type: helm stage: infrastructure release: ssl-proxy-tidb - chart: ./helm/ssl-proxy/charts/tidb + chart: ./cyber-stack/base/tidb values_key: tidb include_global: true set: @@ -41,7 +41,7 @@ components: type: helm stage: infrastructure release: ssl-proxy-redpanda - chart: ./helm/ssl-proxy/charts/redpanda + chart: ./cyber-stack/base/redpanda values_key: redpanda include_global: true set: @@ -55,7 +55,7 @@ components: type: helm stage: infrastructure release: ssl-proxy-minio - chart: ./helm/ssl-proxy/charts/minio + chart: ./cyber-stack/base/minio values_key: minio include_global: true set: @@ -69,7 +69,7 @@ components: type: helm stage: infrastructure release: ssl-proxy-redis-runtime - chart: ./helm/ssl-proxy/charts/redis-runtime + chart: ./cyber-stack/base/redis-runtime values_key: redisRuntime include_global: true depends_on: [platform-config] @@ -80,7 +80,7 @@ components: type: helm stage: infrastructure release: ssl-proxy-telemetry - chart: ./helm/ssl-proxy/charts/telemetry + chart: ./cyber-stack/base/telemetry values_key: telemetry include_global: true depends_on: [platform-config] @@ -104,7 +104,7 @@ components: type: helm-job stage: schema-executor release: ssl-proxy-tidb-schema-executor - chart: ./helm/ssl-proxy/charts/tidb-schema-executor + chart: ./cyber-stack/base/tidb-schema-executor values_key: tidbSchemaExecutor include_global: true depends_on: @@ -124,7 +124,7 @@ components: type: helm stage: schema-migrator release: ssl-proxy-schema-migrator - chart: ./helm/ssl-proxy/charts/schema-migrator + chart: ./cyber-stack/base/schema-migrator values_key: schemaMigrator include_global: true depends_on: @@ -141,7 +141,7 @@ components: type: helm stage: applications release: ssl-proxy-coordinator - chart: ./helm/ssl-proxy/charts/java-coordinator + chart: ./cyber-stack/base/java-coordinator values_key: javaCoordinator include_global: true depends_on: @@ -160,7 +160,7 @@ components: type: helm stage: applications release: ssl-proxy-atheros-search - chart: ./helm/ssl-proxy/charts/atheros-search + chart: ./cyber-stack/base/atheros-search values_key: atherosSearch include_global: true depends_on: @@ -180,7 +180,7 @@ components: type: helm stage: applications release: ssl-proxy-atheros-sensor - chart: ./helm/ssl-proxy/charts/atheros-sensor + chart: ./cyber-stack/base/atheros-sensor values_key: atherosSensor include_global: true gates: @@ -190,7 +190,7 @@ components: type: helm stage: proxy release: ssl-proxy-proxy - chart: ./helm/ssl-proxy/charts/proxy + chart: ./cyber-stack/base/proxy values_key: proxy include_global: true depends_on: diff --git a/stackctl/tests/test_deploy_regressions.py b/stackctl/tests/test_deploy_regressions.py index e1e78945..38df04e1 100644 --- a/stackctl/tests/test_deploy_regressions.py +++ b/stackctl/tests/test_deploy_regressions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import subprocess from pathlib import Path from types import SimpleNamespace @@ -10,10 +11,39 @@ import yaml -from deploy import DeployOptions, _cluster_mutation_snapshot, _kustomize_deploy +import pytest +from deploy import ( + DeployOptions, + _cluster_mutation_snapshot, + _kustomize_deploy, + deploy_stack, +) +from stackctl import generate_effective_values, load_config, load_umbrella_values -def test_kustomize_deploy_omits_effective_values_configmap_and_passes_timeout( + +def test_repository_stack_components_reference_kustomizations(): + root = Path(__file__).resolve().parents[2] + config_path = root / "stackctl/stack.yaml" + stack = yaml.safe_load(config_path.read_text()) + config = load_config(config_path) + umbrella_values = load_umbrella_values(config, root) + + for name, component in stack["components"].items(): + overlay = root / component["chart"] + assert (overlay / "kustomization.yaml").is_file(), overlay + assert isinstance( + generate_effective_values( + config, + name, + umbrella_values, + root_dir=root, + ), + dict, + ) + + +def test_kustomize_dry_run_renders_without_cluster_apply( tmp_path: Path, ): overlay = tmp_path / "overlay" @@ -26,22 +56,44 @@ def test_kustomize_deploy_omits_effective_values_configmap_and_passes_timeout( component = SimpleNamespace(release="test-release", chart=str(overlay)) captured: dict[str, Any] = {} - def fake_apply(path: str, **kwargs: Any) -> subprocess.CompletedProcess[str]: + def fake_build(path: str, **kwargs: Any) -> subprocess.CompletedProcess[str]: captured["kustomization"] = yaml.safe_load( (Path(path) / "kustomization.yaml").read_text() ) captured["kwargs"] = kwargs return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") - with patch("deploy.kustomize_apply", side_effect=fake_apply): - _kustomize_deploy(component, "default", None, None, "15m") + with patch("deploy.kustomize_build", side_effect=fake_build): + _kustomize_deploy(component, "default", None, None, "15m", dry_run=True) assert captured["kustomization"] == { "apiVersion": "kustomize.config.k8s.io/v1beta1", "kind": "Kustomization", "resources": [], } - assert captured["kwargs"]["timeout"] == "15m" + assert captured["kwargs"] == {"context": None, "kubeconfig": None} + + +def test_kustomize_live_deploy_is_rejected(tmp_path: Path): + overlay = tmp_path / "overlay" + overlay.mkdir() + (overlay / "kustomization.yaml").write_text( + "apiVersion: kustomize.config.k8s.io/v1beta1\n" + "kind: Kustomization\n" + "resources: []\n" + ) + component = SimpleNamespace(release="test-release", chart=str(overlay)) + + with pytest.raises(RuntimeError, match="Argo CD"): + _kustomize_deploy(component, "default", None, None, "15m") + + +def test_stack_live_deploy_is_rejected_before_work_starts(): + config = SimpleNamespace(components={}) + options = DeployOptions(namespace="default") + + with pytest.raises(RuntimeError, match="Argo CD"): + asyncio.run(deploy_stack(config, options)) def test_cluster_mutation_snapshot_excludes_ephemeral_resources():