From 2caea4f9ecca7debd1a4fc22a57855884b577f27 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Mon, 10 Aug 2026 16:38:11 +0200 Subject: [PATCH 1/3] add worker role and prometheus metrics - deploy role: worker as Deployment or StatefulSet without ClusterIP - emit ServiceMonitor or PodMonitor from profile metrics settings - bump manifest to v1-alpha.4 and platform to v1-alpha.3 --- README.md | 229 ++++++++++++--- examples/nginx/deployah.platform.yaml | 2 +- examples/nginx/deployah.yaml | 2 +- internal/action/deploy_test.go | 2 +- internal/cmd/deploy/deploy.go | 58 +--- internal/cmd/deploy/deploy_test.go | 2 +- internal/cmd/deploy/guards.go | 18 ++ internal/cmd/deploy/guards_test.go | 64 +++++ internal/cmd/initialize/components.go | 101 ++++++- internal/cmd/initialize/init_test.go | 2 +- .../cmd/initialize/noninteractive_test.go | 4 +- internal/cmd/plan/plan.go | 8 + internal/cmd/plan/plan_test.go | 8 +- .../testdata/basic-web-service/deployah.yaml | 2 +- .../e2e/testdata/stateful-basic/deployah.yaml | 2 +- .../stateful-scale/deployah-replicas-2.yaml | 2 +- .../e2e/testdata/stateful-scale/deployah.yaml | 2 +- .../e2e/testdata/worker-basic/deployah.yaml | 12 + .../e2e/testdata/worker-basic/expect.yaml | 14 + internal/extras/load_test.go | 2 +- internal/helm/cache_test.go | 4 +- .../chart/charts/deployah/templates/app.yaml | 2 +- .../charts/deployah/templates/podmonitor.yaml | 2 +- .../deployah/templates/servicemonitor.yaml | 2 +- .../helm/chart/charts/deployah/values.yaml | 4 + internal/helm/generate.go | 266 ++++++++++++++++-- internal/helm/generate_test.go | 205 ++++++++++++-- internal/helm/render.go | 11 + internal/k8s/requirements.go | 82 ++++++ internal/session/session_test.go | 8 +- internal/spec/constants.go | 25 +- internal/spec/defaults.go | 39 ++- internal/spec/defaults_test.go | 52 ++-- internal/spec/example_test.go | 6 +- internal/spec/field_validation.go | 2 +- internal/spec/field_validation_test.go | 2 +- internal/spec/loader_test.go | 6 +- internal/spec/platform.go | 33 ++- internal/spec/platform_loader.go | 6 +- internal/spec/platform_test.go | 68 ++--- internal/spec/profile.go | 65 ++++- internal/spec/profile_test.go | 52 ++++ internal/spec/resolve.go | 38 ++- internal/spec/resolved_spec.go | 1 + .../{v1-alpha.2 => v1-alpha.3}/platform.json | 97 ++++++- internal/spec/schema/schema.go | 8 +- internal/spec/schema/schema_test.go | 4 +- .../environments.json | 6 +- .../{v1-alpha.3 => v1-alpha.4}/manifest.json | 108 ++++++- internal/spec/types.go | 106 ++++++- internal/spec/validate.go | 124 ++++++-- internal/spec/validate_test.go | 163 ++++++++++- scenarios/autoscaling-hpa/deployah.yaml | 4 +- ...oyment-autoscaling-hpa-production-api.yaml | 1 + scenarios/basic-web-service/deployah.yaml | 4 +- .../deployment-basic-web-service-dev.yaml | 1 + .../command-args-resources/deployah.yaml | 4 +- ...d-args-resources-production-processor.yaml | 1 + scenarios/env-substitution/deployah.yaml | 4 +- .../deployment-env-substitution-dev-api.yaml | 1 + scenarios/error-apex-subdomain/deployah.yaml | 4 +- scenarios/error-empty-resources/deployah.yaml | 4 +- .../error-health-durations/deployah.yaml | 4 +- .../error-health-on-worker/deployah.yaml | 4 +- .../error-health-on-worker/error-config.yaml | 2 +- scenarios/error-health-path/deployah.yaml | 4 +- scenarios/error-metric-type/deployah.yaml | 4 +- scenarios/error-multiple-issues/deployah.yaml | 4 +- .../deployah.platform.yaml | 4 +- scenarios/error-profile-ceiling/deployah.yaml | 4 +- .../deployah.platform.yaml | 4 +- scenarios/error-profile-domain/deployah.yaml | 4 +- .../deployah.platform.yaml | 4 +- scenarios/error-profile-unknown/deployah.yaml | 4 +- .../error-replicas-autoscaling/deployah.yaml | 4 +- .../deployah.yaml | 4 +- scenarios/error-worker-expose/deployah.yaml | 12 + .../error-worker-expose/error-config.yaml | 2 + .../error-worker-http-health/deployah.yaml | 13 + .../error-config.yaml | 2 + .../deployah.yaml | 11 + .../error-config.yaml | 2 + scenarios/error-worker-port/deployah.yaml | 11 + scenarios/error-worker-port/error-config.yaml | 2 + .../deployah.platform.yaml | 4 +- .../expose-apex-certmanager/deployah.yaml | 4 +- ...xpose-apex-certmanager-production-api.yaml | 1 + .../expose-secretname/deployah.platform.yaml | 4 +- scenarios/expose-secretname/deployah.yaml | 4 +- ...ment-expose-secretname-production-api.yaml | 1 + .../expose-selfsigned/deployah.platform.yaml | 4 +- scenarios/expose-selfsigned/deployah.yaml | 4 +- ...ment-expose-selfsigned-production-web.yaml | 1 + scenarios/extras-env-and-crds/deployah.yaml | 4 +- ...eployment-extras-env-and-crds-dev-web.yaml | 1 + ...ployment-extras-env-and-crds-prod-web.yaml | 1 + scenarios/extras-manifest/deployah.yaml | 4 +- .../deployment-extras-manifest-dev-web.yaml | 1 + scenarios/health-check-http/deployah.yaml | 4 +- .../deployment-health-check-http-dev-api.yaml | 1 + scenarios/health-disabled/deployah.yaml | 4 +- .../deployment-health-disabled-dev-api.yaml | 1 + scenarios/invalid-manifest/deployah.yaml | 4 +- scenarios/multi-component/deployah.yaml | 4 +- ...oyment-multi-component-production-api.yaml | 1 + ...oyment-multi-component-production-web.yaml | 1 + scenarios/multi-env/deployah.yaml | 4 +- ...eployment-multi-env-dev-debug-sidecar.yaml | 1 + .../deployment-multi-env-dev-web.yaml | 1 + .../deployment-multi-env-production-web.yaml | 1 + .../plan-after-failed-upgrade/deployah.yaml | 4 +- .../plan-after-failed-upgrade/previous.yaml | 1 + scenarios/plan-command-change/before.yaml | 4 +- scenarios/plan-command-change/deployah.yaml | 4 +- .../plan-extras-fresh-install/deployah.yaml | 4 +- scenarios/plan-fresh-install/deployah.yaml | 4 +- scenarios/plan-hpa-change/before.yaml | 4 +- scenarios/plan-hpa-change/deployah.yaml | 4 +- scenarios/plan-image-bump/before.yaml | 4 +- scenarios/plan-image-bump/deployah.yaml | 4 +- scenarios/plan-ingress-added/before.yaml | 4 +- .../plan-ingress-added/deployah.platform.yaml | 4 +- scenarios/plan-ingress-added/deployah.yaml | 4 +- scenarios/plan-mixed-changes/before.yaml | 4 +- scenarios/plan-mixed-changes/deployah.yaml | 4 +- scenarios/plan-no-changes/before.yaml | 4 +- scenarios/plan-no-changes/deployah.yaml | 4 +- scenarios/plan-resource-added/before.yaml | 4 +- scenarios/plan-resource-added/deployah.yaml | 4 +- scenarios/plan-resource-removed/before.yaml | 4 +- scenarios/plan-resource-removed/deployah.yaml | 4 +- .../profile-basic/deployah.platform.yaml | 4 +- scenarios/profile-basic/deployah.yaml | 4 +- ...ployment-profile-basic-production-web.yaml | 1 + .../profile-merge/deployah.platform.yaml | 4 +- scenarios/profile-merge/deployah.yaml | 4 +- ...ployment-profile-merge-production-api.yaml | 1 + .../deployah.platform.yaml | 10 + .../service-metrics-dedicated/deployah.yaml | 15 + ...vice-metrics-dedicated-production-api.yaml | 85 ++++++ ...vice-metrics-dedicated-production-api.yaml | 31 ++ ...vice-metrics-dedicated-production-api.yaml | 28 ++ .../service-metrics/deployah.platform.yaml | 10 + scenarios/service-metrics/deployah.yaml | 13 + ...oyment-service-metrics-production-api.yaml | 82 ++++++ ...ervice-service-metrics-production-api.yaml | 27 ++ ...onitor-service-metrics-production-api.yaml | 28 ++ scenarios/stateful-basic/deployah.yaml | 4 +- .../statefulset-stateful-basic-dev-db.yaml | 1 + scenarios/stateful-hpa/deployah.yaml | 4 +- .../statefulset-stateful-hpa-dev-cache.yaml | 1 + scenarios/stateful-identity/deployah.yaml | 4 +- ...tatefulset-stateful-identity-dev-peer.yaml | 1 + .../deployah.platform.yaml | 4 +- .../stateful-profile-retention/deployah.yaml | 4 +- ...teful-profile-retention-production-db.yaml | 1 + scenarios/stateless-persistence/deployah.yaml | 4 +- ...loyment-stateless-persistence-dev-web.yaml | 1 + scenarios/worker-exec-health/deployah.yaml | 16 ++ .../deployment-worker-exec-health-dev.yaml | 68 +++++ scenarios/worker-hpa/deployah.yaml | 18 ++ .../deployment-worker-hpa-production.yaml | 59 ++++ ...alpodautoscaler-worker-hpa-production.yaml | 36 +++ .../worker-metrics/deployah.platform.yaml | 11 + scenarios/worker-metrics/deployah.yaml | 15 + .../deployment-worker-metrics-production.yaml | 64 +++++ .../podmonitor-worker-metrics-production.yaml | 30 ++ scenarios/worker-stateful/deployah.yaml | 17 ++ .../service-worker-stateful-dev-headless.yaml | 28 ++ .../statefulset-worker-stateful-dev.yaml | 96 +++++++ scenarios/worker-stateless/deployah.yaml | 13 + .../deployment-worker-stateless-dev.yaml | 64 +++++ 172 files changed, 2885 insertions(+), 442 deletions(-) create mode 100644 internal/e2e/testdata/worker-basic/deployah.yaml create mode 100644 internal/e2e/testdata/worker-basic/expect.yaml create mode 100644 internal/k8s/requirements.go rename internal/spec/schema/platform/{v1-alpha.2 => v1-alpha.3}/platform.json (79%) rename internal/spec/schema/{v1-alpha.3 => v1-alpha.4}/environments.json (94%) rename internal/spec/schema/{v1-alpha.3 => v1-alpha.4}/manifest.json (80%) create mode 100644 scenarios/error-worker-expose/deployah.yaml create mode 100644 scenarios/error-worker-expose/error-config.yaml create mode 100644 scenarios/error-worker-http-health/deployah.yaml create mode 100644 scenarios/error-worker-http-health/error-config.yaml create mode 100644 scenarios/error-worker-metrics-no-port/deployah.yaml create mode 100644 scenarios/error-worker-metrics-no-port/error-config.yaml create mode 100644 scenarios/error-worker-port/deployah.yaml create mode 100644 scenarios/error-worker-port/error-config.yaml create mode 100644 scenarios/service-metrics-dedicated/deployah.platform.yaml create mode 100644 scenarios/service-metrics-dedicated/deployah.yaml create mode 100644 scenarios/service-metrics-dedicated/expected/deployment-service-metrics-dedicated-production-api.yaml create mode 100644 scenarios/service-metrics-dedicated/expected/service-service-metrics-dedicated-production-api.yaml create mode 100644 scenarios/service-metrics-dedicated/expected/servicemonitor-service-metrics-dedicated-production-api.yaml create mode 100644 scenarios/service-metrics/deployah.platform.yaml create mode 100644 scenarios/service-metrics/deployah.yaml create mode 100644 scenarios/service-metrics/expected/deployment-service-metrics-production-api.yaml create mode 100644 scenarios/service-metrics/expected/service-service-metrics-production-api.yaml create mode 100644 scenarios/service-metrics/expected/servicemonitor-service-metrics-production-api.yaml create mode 100644 scenarios/worker-exec-health/deployah.yaml create mode 100644 scenarios/worker-exec-health/expected/deployment-worker-exec-health-dev.yaml create mode 100644 scenarios/worker-hpa/deployah.yaml create mode 100644 scenarios/worker-hpa/expected/deployment-worker-hpa-production.yaml create mode 100644 scenarios/worker-hpa/expected/horizontalpodautoscaler-worker-hpa-production.yaml create mode 100644 scenarios/worker-metrics/deployah.platform.yaml create mode 100644 scenarios/worker-metrics/deployah.yaml create mode 100644 scenarios/worker-metrics/expected/deployment-worker-metrics-production.yaml create mode 100644 scenarios/worker-metrics/expected/podmonitor-worker-metrics-production.yaml create mode 100644 scenarios/worker-stateful/deployah.yaml create mode 100644 scenarios/worker-stateful/expected/service-worker-stateful-dev-headless.yaml create mode 100644 scenarios/worker-stateful/expected/statefulset-worker-stateful-dev.yaml create mode 100644 scenarios/worker-stateless/deployah.yaml create mode 100644 scenarios/worker-stateless/expected/deployment-worker-stateless-dev.yaml diff --git a/README.md b/README.md index 5060186..d9f3a73 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ but for the deploy step: S2I builds your image, and Deployah runs your release. - [Platform file](#platform-file) - [Profiles](#profiles) - [Health checks](#health-checks) +- [Worker components](#worker-components) +- [Metrics](#metrics) - [Custom manifests and CRDs](#custom-manifests-and-crds) - [Commands](#commands) - [Environments and variables](#environments-and-variables) @@ -109,7 +111,7 @@ Save this as `deployah.yaml` in an empty folder. It runs the public `nginx` image, so you do not need to build anything. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: my-first-app components: web: @@ -215,10 +217,23 @@ A few words you will see often. (StatefulSet with stable identity; optional per-pod volumes). See [Stateful workloads](#stateful-workloads) and [Storage classes](#storage-classes). -- **What deploys today.** Deployah deploys `service` components as - `stateless` (Deployment) or `stateful` (StatefulSet). The `worker` and - `job` roles are in the schema but are not deployable yet, so a deploy that - uses them stops with a "not supported yet" error. +- **Workload matrix.** `role` and `kind` combine independently. `job` is in + the schema but not deployable yet. + + | Capability | service+stateless | service+stateful | worker+stateless | worker+stateful | + |---------------------|:-----------------:|:----------------:|:----------------:|:---------------:| + | Deployment | Y | - | Y | - | + | StatefulSet | - | Y | - | Y | + | ClusterIP Service | Y | Y | - | - | + | Headless Service | - | Y | - | Y | + | Ingress | Y | Y | - | - | + | HPA | Y | Y | Y | Y | + | Persistence | Y | Y | Y | Y | + | Health (TCP/HTTP) | Y | Y | - | - | + | Health (exec) | Y | Y | Y | Y | + | ServiceMonitor | Y | Y | - | - | + | PodMonitor | - | - | Y | Y | + - **Environment.** A target such as `dev`, `staging`, or `prod`. Each environment can use a different cluster, different files, and different variables. The platform file registers which environments exist; an entry @@ -227,12 +242,12 @@ A few words you will see often. Kubernetes units. Use `resourcePreset: small` instead of writing exact values. This is not the same as a [profile](#profiles). - **Profile.** A named deployment policy owned by the platform team (node - placement, security context, domain and resource ceilings, and more). - Components select one or more with `profiles: [...]`. See [Profiles](#profiles). -- **Health checks.** Deployah checks that your app is ready for traffic and - restarts it if it gets stuck. This happens automatically for every service - component. You can improve the checks by giving Deployah an HTTP endpoint to - call. See [Health checks](#health-checks). + placement, security context, domain and resource ceilings, monitor labels, + and more). Components select one or more with `profiles: [...]`. See + [Profiles](#profiles). +- **Health checks.** Services get automatic TCP/HTTP ready and alive checks. + Workers use process-exit by default, or optional `health.alive.exec`. See + [Health checks](#health-checks) and [Worker components](#worker-components). - **Bring your own image.** Deployah does not build images. You give it an image that already exists in a registry your cluster can pull from. Build your image in CI (or locally), then let Deployah deploy it. @@ -261,7 +276,7 @@ Here is a full example that shows the common fields. You do not need all of them; most have defaults. ```yaml -apiVersion: v1-alpha.3 # required: the schema version +apiVersion: v1-alpha.4 # required: the schema version project: shop # required: your project name components: # required: one or more components @@ -276,6 +291,8 @@ components: # required: one or more components env: # planned: not applied to the container yet LOG_LEVEL: info resourcePreset: small # nano|micro|small|medium|large|xlarge|2xlarge + shutdownTimeout: 30s # how long Kubernetes waits for graceful stop + metrics: true # scrape /metrics on the app port (needs profile metrics.monitorLabels) expose: # optional: `expose: true` uses all defaults subdomain: api # optional: defaults to the component name # domain: internal # optional: defaults to the platform's default domain @@ -287,6 +304,19 @@ components: # required: one or more components metrics: - type: cpu # cpu | memory target: 70 # target usage percentage + worker: + role: worker # no port, expose, or Ingress + image: ghcr.io/acme/shop-worker:${TAG} + environments: [staging, prod] + command: ["/bin/worker"] + resourcePreset: small + shutdownTimeout: 60s # workers default to 60s + metrics: # workers need an explicit scrape port + port: 9090 + path: /metrics + health: + alive: + exec: ["/bin/grpc_health_probe", "-addr=:50051"] environments: # define your environments (a map, not a list) staging: @@ -309,7 +339,7 @@ Top level: | Field | Required | Notes | |---|---|---| -| `apiVersion` | Yes | The schema version. Must be `v1-alpha.3`. | +| `apiVersion` | Yes | The schema version. Must be `v1-alpha.4`. | | `project` | Yes | Lowercase name (DNS-1123). Prefixes your Kubernetes resources. | | `components` | Yes | A map of component name to component settings. | | `environments` | Yes in practice | A map of environment name to environment settings. Keys support prefix-based wildcard matching, e.g. a `review` key matches `--environment review/pr-123`. | @@ -319,27 +349,28 @@ Component: | Field | Default | Notes | |---|---|---| | `image` | none | The container image to run. You provide this. | -| `role` | `service` | `service`, `worker`, or `job`. | +| `role` | `service` | `service` or `worker` (`job` is accepted by the schema but not deployable yet). | | `kind` | `stateless` | `stateless` or `stateful`. | -| `port` | `8080` | The port your app listens on (1 to 65535). | +| `port` | `8080` (services) | App listen port (1 to 65535). Not allowed on workers. | | `command` / `args` | none | Override the image ENTRYPOINT and CMD. | | `env` | none | Environment variables (uppercase keys). | | `resourcePreset` | none | `nano`, `micro`, `small`, `medium`, `large`, `xlarge`, `2xlarge`. | | `resources` | none | `cpu`, `memory`, `ephemeralStorage` (Kubernetes units). | -| `expose` | none | `true` for all defaults, or an object with `domain` (defaults to the platform's default domain), `subdomain` (defaults to the component name), and `apex`. See [Platform file](#platform-file). | +| `expose` | none | Services only. `true` for all defaults, or an object with `domain`, `subdomain`, and `apex`. See [Platform file](#platform-file). | | `replicas` | `1` (chart) | Desired pod count. Cannot combine with `autoscaling.enabled`. | | `persistence` | none | Optional for `kind: stateful` (`size`, `mountPath`, optional logical `storageClass`). Omit for identity-only. Allowed on stateless (shared PVC, Recreate). See [Stateful workloads](#stateful-workloads). | | `autoscaling` | off | `enabled`, `minReplicas`, `maxReplicas`, `metrics`. | +| `shutdownTimeout` | `30s` (service), `60s` (worker) | Graceful stop window; maps to `terminationGracePeriodSeconds`. | +| `metrics` | off | `true`, `false`, or `{enabled?, port, path, interval?, scrapeTimeout?}`. See [Metrics](#metrics). | | `health` | auto | Ready and alive checks. See [Health checks](#health-checks). | | `environments` | none | Which environments deploy this component. | | `profiles` | none | List of platform profile names. Merged left to right. See [Profiles](#profiles). | > [!IMPORTANT] -> Not deployed yet: the schema accepts `role: worker` and `role: job`, and -> the `env`, `envFile`, and `configFile` fields, but Deployah does not apply -> them at deploy time yet. Today, deploy a `service` as `stateless` or -> `stateful` using `image`, `port`, `resources` or `resourcePreset`, -> optional `persistence`, `expose`, `autoscaling`, and `profiles`. +> Not deployed yet: the schema accepts `role: job`, and the `env`, `envFile`, +> and `configFile` fields, but Deployah does not apply them at deploy time +> yet. Changing `role` between `service` and `worker` on an existing release +> is rejected; delete the release and redeploy. Environment: @@ -421,7 +452,7 @@ Every example below is complete and valid. Copy one and change the values. **Smallest spec.** One service, one environment. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: hello components: web: @@ -434,7 +465,7 @@ environments: **Two components.** A web app and an API in one project. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -453,7 +484,7 @@ environments: from the platform file, not from here. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -475,7 +506,7 @@ comes from the platform file. Set `subdomain` only when you want a different label, and `apex: true` for the bare domain. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -488,7 +519,7 @@ components: **Set exact resources.** Use `resources` instead of a preset. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -505,7 +536,7 @@ environments: **Autoscale on CPU.** Scale between 2 and 6 replicas at 70% CPU. ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -549,7 +580,7 @@ not require that floor. `size` and `mountPath` are required: ```yaml -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: # Identity only: stable DNS / ordinals, no PVC @@ -685,7 +716,7 @@ requires it. This file is not processed with `${...}` substitution: it holds real values, not templates. ```yaml -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 profiles: default: nodeSelector: @@ -809,7 +840,7 @@ components: ```yaml # deployah.platform.yaml (platform team) -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 profiles: default: nodeSelector: @@ -864,6 +895,7 @@ environments: | `storageClass` | string | Logical key from the target environment's `storageClasses` map. | | `allowedDomains` | list of string | Logical domain keys the component may expose on. Omitted (or null) means no constraint. An empty list (`[]`) is deny-all: no domain is allowed. | | `maxResources` | object | Ceiling on component resource **requests** (`cpu`, `memory`). Exceeding it is an error. | +| `metrics` | object | Platform Prometheus policy. See [Metrics](#metrics). Fields: `monitorLabels` (required when a component enables metrics), `monitorNamespace`, `interval`, `scrapeTimeout`, `jobLabel`, `honorLabels`, `annotations`, `relabelings`, `metricRelabelings`. | #### Merge rules @@ -872,9 +904,10 @@ right** (after prepending `default` when that profile exists): | Kind | Fields | Rule | |---|---|---| -| Maps | `nodeSelector`, `podLabels`, `podAnnotations`, security contexts | Deep merge; last wins on key conflict | -| Arrays | `tolerations` | Concatenate; identical entries are deduplicated | -| Scalars | `storageClass` | Last non-empty wins | +| Maps | `nodeSelector`, `podLabels`, `podAnnotations`, `metrics.monitorLabels`, `metrics.annotations`, security contexts | Deep merge; last wins on key conflict | +| Arrays | `tolerations`, `metrics.relabelings`, `metrics.metricRelabelings` | Concatenate; identical `tolerations` entries are deduplicated | +| Scalars | `storageClass`, `metrics.monitorNamespace`, `metrics.interval`, `metrics.scrapeTimeout`, `metrics.jobLabel` | Last non-empty wins | +| Bools | `metrics.honorLabels` | Last non-nil wins | | Domains | `allowedDomains` | Intersection of profiles that set a list; omitted means no constraint; empty list is deny-all | | Ceilings | `maxResources` | Minimum (strictest) wins per resource | @@ -1006,6 +1039,126 @@ components: alive: false ``` +**Exec alive probe.** Use a command instead of HTTP when your process has +no listen port, or when an in-container check is a better signal. `path` and +`exec` are mutually exclusive. Services may use either; workers may use +`exec` only (see [Worker components](#worker-components)). + +```yaml +components: + api: + image: my-app:1.0.0 + port: 8080 + health: + alive: + exec: ["/bin/grpc_health_probe", "-addr=:8080"] + interval: 10s + restartAfter: 60s +``` + +## Worker components + +A `role: worker` component is a long-running process that does not serve +inbound traffic. Deployah still runs it as a Deployment (`kind: stateless`) +or StatefulSet (`kind: stateful`), with HPA and persistence available the +same way as for services. + +Workers must not set: + +- `port` (use `metrics.port` when you need a scrape endpoint) +- `expose` (no Ingress) +- `health.ready` (there is no traffic gate) +- `health.alive.path` (HTTP probes need an app port) + +By default, Kubernetes restarts a worker when the process exits. Optional +`health.alive.exec` adds a command-based liveness probe: + +```yaml +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + environments: [production] + resourcePreset: small + shutdownTimeout: 60s + health: + alive: + exec: ["/bin/grpc_health_probe", "-addr=:50051"] +``` + +Stateful workers get a headless Service with a synthetic `identity` port +(`9`) so peer DNS and stable network identity work without an app listen +port. Stateless workers with no metrics get no Service. + +`shutdownTimeout` defaults to `60s` for workers (vs `30s` for services) and +maps to `terminationGracePeriodSeconds`. Give workers enough time to finish +in-flight work before the kubelet sends `SIGKILL`. + +Changing a component's `role` between `service` and `worker` on an existing +release is not supported. Delete the release and redeploy. + +## Metrics + +Deployah can emit Prometheus Operator scrape configs when a component +enables `metrics`. Services produce a `ServiceMonitor`; workers produce a +`PodMonitor`. Your cluster must have the Prometheus Operator CRDs +(`monitoring.coreos.com/v1`). `deployah plan` and `deployah deploy` check +for that API group when metrics are enabled. + +Shape: + +| Form | Meaning | +|---|---| +| `metrics: true` | Enable scraping. Services scrape the app port at `/metrics`. Workers must set `port` instead. | +| `metrics: false` | Explicitly off. | +| `metrics: { ... }` | Object form with `enabled?`, `port`, `path`, `interval?`, `scrapeTimeout?`. | + +Defaults when enabled: `path` is `/metrics`. For services, omitted `port` +uses the component port (ServiceMonitor endpoint port name `http`). A +dedicated metrics port adds a container and Service port named `metrics`. +Workers always require `metrics.port` and scrape via PodMonitor port name +`metrics`. + +Platform profiles own discovery labels and scrape policy under +`metrics:`. When component metrics are enabled, the merged profile must set +`metrics.monitorLabels` (for example `release: prometheus` for the +kube-prometheus-stack selector). Other profile metrics fields: +`monitorNamespace`, `interval`, `scrapeTimeout`, `jobLabel`, `honorLabels`, +`annotations`, `relabelings`, `metricRelabelings`. + +```yaml +# deployah.yaml +components: + api: + image: ghcr.io/acme/api:1.0.0 + port: 8080 + environments: [production] + profiles: [observability] + metrics: true + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + environments: [production] + profiles: [observability] + metrics: + port: 9090 + path: /metrics +``` + +```yaml +# deployah.platform.yaml +apiVersion: platform/v1-alpha.3 +profiles: + observability: + metrics: + monitorLabels: + release: prometheus + interval: 30s +environments: + production: + context: prod +``` + ## Custom manifests and CRDs Deployah can ship raw Kubernetes YAML next to the generated Helm chart. Use @@ -1646,11 +1799,11 @@ deployah --help Deployah validates your spec and platform file with JSON Schema. -- **Manifest schema version:** v1-alpha.3 -- **Manifest schema:** `internal/spec/schema/v1-alpha.3/manifest.json` -- **Manifest environments schema:** `internal/spec/schema/v1-alpha.3/environments.json` -- **Platform schema version:** platform/v1-alpha.2 -- **Platform schema:** `internal/spec/schema/platform/v1-alpha.2/platform.json` +- **Manifest schema version:** v1-alpha.4 +- **Manifest schema:** `internal/spec/schema/v1-alpha.4/manifest.json` +- **Manifest environments schema:** `internal/spec/schema/v1-alpha.4/environments.json` +- **Platform schema version:** platform/v1-alpha.3 +- **Platform schema:** `internal/spec/schema/platform/v1-alpha.3/platform.json` For the latest schema and examples, see the [schema directory](internal/spec/schema/) in the repository. diff --git a/examples/nginx/deployah.platform.yaml b/examples/nginx/deployah.platform.yaml index ddd1443..da6bca2 100644 --- a/examples/nginx/deployah.platform.yaml +++ b/examples/nginx/deployah.platform.yaml @@ -1,4 +1,4 @@ -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 environments: local: context: kind-deployah diff --git a/examples/nginx/deployah.yaml b/examples/nginx/deployah.yaml index 802d628..9cc591f 100644 --- a/examples/nginx/deployah.yaml +++ b/examples/nginx/deployah.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: nginx components: web: diff --git a/internal/action/deploy_test.go b/internal/action/deploy_test.go index 36c73e0..6e6d60e 100644 --- a/internal/action/deploy_test.go +++ b/internal/action/deploy_test.go @@ -34,7 +34,7 @@ func (m *mockSpecLoader) Spec(_ context.Context, _ string) (*spec.Spec, error) { } var testManifest = &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "my-app", } diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index 4536c3c..be5adac 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -237,7 +237,7 @@ func runDeploy(c *nabat.Context) error { // Skip when Helm is idle and only CRDs will apply (CRDs are the APIs). // Also skip group/versions that .deployah/crds/ will install in this deploy. if !helmIdle && k8sErr == nil { - reqs := filterCoveredAPIs(requiredAPIs(manifest, opts.Environment, resolvedSpec), extras.GroupVersionsFromCRDs(bundle.CRDs)) + reqs := filterCoveredAPIs(k8s.RequiredAPIs(manifest, opts.Environment, resolvedSpec), extras.GroupVersionsFromCRDs(bundle.CRDs)) if len(reqs) > 0 { if capErr := k8s.CheckAPIRequirements(k8sClient, reqs); capErr != nil { return capErr @@ -614,62 +614,6 @@ func filterCoveredAPIs(reqs []k8s.APIRequirement, covered map[string]struct{}) [ return out } -// requiredAPIs derives the Kubernetes API group/version requirements for -// components deployed in the target environment, including cert-manager -// (TLS mode) via the resolved spec. -func requiredAPIs(manifest *spec.Spec, environment string, resolved *spec.ResolvedSpec) []k8s.APIRequirement { - type entry struct { - groupVersions []string - components []string - } - - entries := make(map[string]*entry) // keyed by canonical group/version string - - add := func(groupVersions []string, componentName string) { - key := strings.Join(groupVersions, "|") - e := entries[key] - if e == nil { - e = &entry{groupVersions: groupVersions} - entries[key] = e - } - e.components = append(e.components, fmt.Sprintf("%q", componentName)) - } - - for name, component := range manifest.Components { - // Same matcher as spec.Resolve and chart generation, so wildcard - // deploys agree on the active component set. - if len(component.Environments) > 0 { - if _, ok := spec.MatchEnvKey(environment, component.Environments); !ok { - continue - } - } - if component.Autoscaling != nil && component.Autoscaling.Enabled { - add([]string{"autoscaling/v2", "autoscaling/v2beta2"}, name) - } - if component.Expose != nil { - add([]string{"networking.k8s.io/v1"}, name) - } - if resolved != nil { - if rc, ok := resolved.Components[name]; ok && rc.TLSMode == spec.TLSModeCertManager { - add([]string{"cert-manager.io/v1"}, name) - } - } - } - - reqs := make([]k8s.APIRequirement, 0, len(entries)) - for _, e := range entries { - noun := "component" - if len(e.components) > 1 { - noun = "components" - } - reqs = append(reqs, k8s.APIRequirement{ - GroupVersions: e.groupVersions, - Reason: fmt.Sprintf("required by %s %s", noun, strings.Join(e.components, ", ")), - }) - } - return reqs -} - // warnContextMismatch emits a warning when the --context flag overrides the // platform-file context for the target environment. Silenced by setting // DEPLOYAH_ALLOW_CONTEXT_MISMATCH=1. diff --git a/internal/cmd/deploy/deploy_test.go b/internal/cmd/deploy/deploy_test.go index feb5767..3135620 100644 --- a/internal/cmd/deploy/deploy_test.go +++ b/internal/cmd/deploy/deploy_test.go @@ -247,7 +247,7 @@ func TestRequiredAPIs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - reqs := requiredAPIs(tt.manifest, tt.environment, tt.resolved) + reqs := k8s.RequiredAPIs(tt.manifest, tt.environment, tt.resolved) if tt.wantEmpty { assert.Empty(t, reqs) return diff --git a/internal/cmd/deploy/guards.go b/internal/cmd/deploy/guards.go index 12f8b20..994f761 100644 --- a/internal/cmd/deploy/guards.go +++ b/internal/cmd/deploy/guards.go @@ -77,6 +77,24 @@ func checkWorkloadGuards( )) } + wantRole := string(component.Role) + if wantRole == "" { + wantRole = string(spec.ComponentRoleService) + } + // Missing or empty previous role means service (the only role before + // workers existed). Always compare so service -> worker upgrades are + // rejected even when the prior release omitted role from resolved values. + prevRole, hasRole := prev["role"].(string) + if !hasRole || prevRole == "" { + prevRole = string(spec.ComponentRoleService) + } + if prevRole != wantRole { + errors = append(errors, fmt.Sprintf( + " %s: role change %s -> %s is not supported; delete the release and redeploy", + name, prevRole, wantRole, + )) + } + prevSize, hasPrevSize := prev["persistenceSize"].(string) prevHadPersistence := hasPrevSize && prevSize != "" nowHasPersistence := component.Persistence != nil diff --git a/internal/cmd/deploy/guards_test.go b/internal/cmd/deploy/guards_test.go index a0bbfe8..4bb96f0 100644 --- a/internal/cmd/deploy/guards_test.go +++ b/internal/cmd/deploy/guards_test.go @@ -314,3 +314,67 @@ func TestCheckWorkloadGuards_SizeParseError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "parse previous persistence.size") } + +func TestCheckWorkloadGuards_RoleChangeRejected(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("api", map[string]any{ + "workloadKind": "Deployment", + "role": "service", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "api": {Role: spec.ComponentRoleWorker, Image: "worker:1"}, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "role change") + assert.Contains(t, err.Error(), "service -> worker") +} + +func TestCheckWorkloadGuards_SameRoleAllowed(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("api", map[string]any{ + "workloadKind": "Deployment", + "role": "worker", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "api": {Role: spec.ComponentRoleWorker, Image: "worker:1"}, + }, + } + assert.NoError(t, checkWorkloadGuards(manifest, "production", prev)) +} + +func TestCheckWorkloadGuards_MissingPrevRoleTreatedAsService(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("api", map[string]any{ + "workloadKind": "Deployment", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "api": {Role: spec.ComponentRoleWorker, Image: "worker:1"}, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "role change") + assert.Contains(t, err.Error(), "service -> worker") +} + +func TestCheckWorkloadGuards_MissingPrevRoleSameAsServiceAllowed(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("api", map[string]any{ + "workloadKind": "Deployment", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "api": {Role: spec.ComponentRoleService, Image: "api:1", Port: 8080}, + }, + } + assert.NoError(t, checkWorkloadGuards(manifest, "production", prev)) +} diff --git a/internal/cmd/initialize/components.go b/internal/cmd/initialize/components.go index 17105f3..8832937 100644 --- a/internal/cmd/initialize/components.go +++ b/internal/cmd/initialize/components.go @@ -212,15 +212,15 @@ func collectComponentDetails(c *nabat.Context, componentName string, availableEn return component, nil } -// needsHealthCheckQuestion reports whether the health-check question -// applies: a health probe needs a named port to attach to. +// needsHealthCheckQuestion reports whether the HTTP health-check question +// applies: a TCP/HTTP probe needs a named port to attach to. func needsHealthCheckQuestion(component spec.Component) bool { return component.ListensOnPort() } // collectComponentEssentials asks the questions every component needs // regardless of role: role, image, resources, and (for service components -// only) port and expose. +// only) port and expose. Workers also get command and optional metrics port. func collectComponentEssentials(c *nabat.Context, component *spec.Component, componentName string) error { if err := collectComponentRole(c, component, componentName); err != nil { return fmt.Errorf("failed to collect component role: %w", err) @@ -236,6 +236,18 @@ func collectComponentEssentials(c *nabat.Context, component *spec.Component, com } } + if component.Role.IsWorker() { + if err := collectComponentCommand(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component command: %w", err) + } + if err := collectComponentArgs(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component args: %w", err) + } + if err := collectComponentMetricsPort(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component metrics port: %w", err) + } + } + if err := collectComponentResources(c, component, componentName); err != nil { return fmt.Errorf("failed to collect component resources: %w", err) } @@ -291,12 +303,15 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo } } - if err = collectComponentCommand(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component command: %w", err) - } + // Workers already answered command/args in essentials. + if !component.Role.IsWorker() { + if err = collectComponentCommand(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component command: %w", err) + } - if err = collectComponentArgs(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component args: %w", err) + if err = collectComponentArgs(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component args: %w", err) + } } if err = collectComponentConfigFiles(c, component, componentName); err != nil { @@ -320,6 +335,11 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo return fmt.Errorf("failed to collect component health check: %w", err) } } + if component.Role.IsWorker() { + if err = collectComponentExecHealth(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component exec health check: %w", err) + } + } if err = collectComponentEnvironments(c, component, componentName, availableEnvironments); err != nil { return fmt.Errorf("failed to collect component environments: %w", err) @@ -800,6 +820,71 @@ func collectComponentHealth(c *nabat.Context, component *spec.Component, compone return nil } +// collectComponentMetricsPort optionally asks for a Prometheus metrics port +// on workers (required when metrics are enabled). +func collectComponentMetricsPort(c *nabat.Context, component *spec.Component, componentName string) error { + enable, err := c.Confirm( + fmt.Sprintf("Expose Prometheus metrics for %s?", componentName), + nabat.WithAffirmative("Yes"), + nabat.WithNegative("No"), + ) + if err != nil { + return fmt.Errorf("failed to collect metrics preference: %w", err) + } + if !enable { + return nil + } + portStr, err := c.Input( + fmt.Sprintf("Metrics port for %s", componentName), + nabat.WithHint("9090"), + nabat.WithDefault("9090"), + nabat.WithValidate(spec.ValidatePort), + ) + if err != nil { + return fmt.Errorf("failed to collect metrics port: %w", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid metrics port: %w", err) + } + component.Metrics = &spec.ComponentMetrics{Port: port} + return nil +} + +// collectComponentExecHealth optionally asks for an exec liveness command on +// workers (process-exit is the default when omitted). +func collectComponentExecHealth(c *nabat.Context, component *spec.Component, componentName string) error { + enable, err := c.Confirm( + fmt.Sprintf("Add an exec alive check for %s? (default is process-exit only)", componentName), + nabat.WithAffirmative("Yes"), + nabat.WithNegative("No"), + ) + if err != nil { + return fmt.Errorf("failed to collect exec health preference: %w", err) + } + if !enable { + return nil + } + cmdStr, err := c.Input( + fmt.Sprintf("Alive exec command for %s (space-separated)", componentName), + nabat.WithHint("pgrep -f worker"), + nabat.WithValidate(func(s string) error { + return validate.ValidateNonEmpty(s, "exec command") + }), + ) + if err != nil { + return fmt.Errorf("failed to collect exec health command: %w", err) + } + parts := strings.Fields(cmdStr) + if len(parts) == 0 { + return fmt.Errorf("exec command must not be empty") + } + component.Health = &spec.Health{ + Alive: &spec.HealthAlive{Exec: parts}, + } + return nil +} + func collectComponentEnvironmentVariables(c *nabat.Context, component *spec.Component, componentName string) error { addComponentEnvVars, err := c.Confirm( fmt.Sprintf("Add environment variables for %s? Would you like to add component-specific environment variables?", componentName), diff --git a/internal/cmd/initialize/init_test.go b/internal/cmd/initialize/init_test.go index 8075472..ed3bc3e 100644 --- a/internal/cmd/initialize/init_test.go +++ b/internal/cmd/initialize/init_test.go @@ -19,7 +19,7 @@ func TestCheckOverwrite(t *testing.T) { t.Parallel() existing := filepath.Join(t.TempDir(), "deployah.yaml") - require.NoError(t, os.WriteFile(existing, []byte("apiVersion: v1-alpha.3\n"), 0o600)) + require.NoError(t, os.WriteFile(existing, []byte("apiVersion: v1-alpha.4\n"), 0o600)) missing := filepath.Join(t.TempDir(), "missing.yaml") tests := []struct { diff --git a/internal/cmd/initialize/noninteractive_test.go b/internal/cmd/initialize/noninteractive_test.go index 814520e..149df40 100644 --- a/internal/cmd/initialize/noninteractive_test.go +++ b/internal/cmd/initialize/noninteractive_test.go @@ -56,7 +56,7 @@ func TestInit_DefaultsProducesValidSpec(t *testing.T) { func TestInit_DefaultsWithoutForceAgainstExistingFileFails(t *testing.T) { dir := t.TempDir() outputPath := filepath.Join(dir, "deployah.yaml") - require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.3\n"), 0o600)) + require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.4\n"), 0o600)) io, _, _, _ := nabattest.NewIO() app := newInitApp(io) @@ -70,7 +70,7 @@ func TestInit_DefaultsWithoutForceAgainstExistingFileFails(t *testing.T) { func TestInit_DefaultsWithForceAgainstExistingFileSucceeds(t *testing.T) { dir := t.TempDir() outputPath := filepath.Join(dir, "deployah.yaml") - require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.3\n"), 0o600)) + require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.4\n"), 0o600)) io, _, _, _ := nabattest.NewIO() app := newInitApp(io) diff --git a/internal/cmd/plan/plan.go b/internal/cmd/plan/plan.go index 993ded2..be20129 100644 --- a/internal/cmd/plan/plan.go +++ b/internal/cmd/plan/plan.go @@ -251,6 +251,14 @@ func runOnline(c *nabat.Context, sess *session.Session, platform *spec.PlatformC if err != nil { return fmt.Errorf("load extras: %w", err) } + if k8sErr == nil { + reqs := k8s.RequiredAPIs(manifest, opts.Environment, resolvedSpec) + if len(reqs) > 0 { + if capErr := k8s.CheckAPIRequirements(k8sClient, reqs); capErr != nil { + return capErr + } + } + } postRenderer := bundle.PostRendererFor() p, result, cleanup, err := planengine.BuildPlan(c, helmClient, manifest, opts.Environment, cluster.Context(), resolvedSpec, postRenderer) diff --git a/internal/cmd/plan/plan_test.go b/internal/cmd/plan/plan_test.go index 9ff6772..5f14adf 100644 --- a/internal/cmd/plan/plan_test.go +++ b/internal/cmd/plan/plan_test.go @@ -186,7 +186,7 @@ data: ` func testManifest() *spec.Spec { - return &spec.Spec{Project: "web", APIVersion: "v1-alpha.3"} + return &spec.Spec{Project: "web", APIVersion: "v1-alpha.4"} } func testOptions() *Options { @@ -381,7 +381,7 @@ func TestRunOffline_PrintsPendingCRDs(t *testing.T) { t.Parallel() dir := t.TempDir() specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") writePlanExtras(t, dir, ".deployah/crds/widget.yaml", ` apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -422,7 +422,7 @@ func TestRunOffline_LoadExtrasError(t *testing.T) { t.Parallel() dir := t.TempDir() specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") writePlanExtras(t, dir, ".deployah/manifests/bad.yaml", "not: [valid") stub := &stubHelmClient{offlineResult: renderResult(deploymentV1)} sess := session.New( @@ -445,7 +445,7 @@ func TestRunOnline_PrintsPendingCRDs(t *testing.T) { t.Parallel() dir := t.TempDir() specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") writePlanExtras(t, dir, ".deployah/crds/widget.yaml", ` apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/internal/e2e/testdata/basic-web-service/deployah.yaml b/internal/e2e/testdata/basic-web-service/deployah.yaml index 2e21395..9186089 100644 --- a/internal/e2e/testdata/basic-web-service/deployah.yaml +++ b/internal/e2e/testdata/basic-web-service/deployah.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: basic-web-service components: web: diff --git a/internal/e2e/testdata/stateful-basic/deployah.yaml b/internal/e2e/testdata/stateful-basic/deployah.yaml index 7f8a833..fe8caaf 100644 --- a/internal/e2e/testdata/stateful-basic/deployah.yaml +++ b/internal/e2e/testdata/stateful-basic/deployah.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: stateful-basic components: cache: diff --git a/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml b/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml index 2c2f97d..74c658f 100644 --- a/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml +++ b/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: stateful-scale components: cache: diff --git a/internal/e2e/testdata/stateful-scale/deployah.yaml b/internal/e2e/testdata/stateful-scale/deployah.yaml index e542e23..c3dfa28 100644 --- a/internal/e2e/testdata/stateful-scale/deployah.yaml +++ b/internal/e2e/testdata/stateful-scale/deployah.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: stateful-scale components: cache: diff --git a/internal/e2e/testdata/worker-basic/deployah.yaml b/internal/e2e/testdata/worker-basic/deployah.yaml new file mode 100644 index 0000000..7b4f080 --- /dev/null +++ b/internal/e2e/testdata/worker-basic/deployah.yaml @@ -0,0 +1,12 @@ +apiVersion: v1-alpha.4 +project: worker-basic +components: + worker: + role: worker + image: busybox:1.36 + command: ["sleep"] + args: ["infinity"] + environments: [dev] + resourcePreset: small +environments: + dev: {} diff --git a/internal/e2e/testdata/worker-basic/expect.yaml b/internal/e2e/testdata/worker-basic/expect.yaml new file mode 100644 index 0000000..4e350ae --- /dev/null +++ b/internal/e2e/testdata/worker-basic/expect.yaml @@ -0,0 +1,14 @@ +env: dev +namespace: default +deployments: + - name: worker-basic-dev + replicas: 1 + image: docker.io/library/busybox:1.36 + labels: + deployah.dev/project: worker-basic + deployah.dev/environment: dev + deployah.dev/component: worker +pods: + labelSelector: "deployah.dev/project=worker-basic,deployah.dev/environment=dev" + minCount: 1 + phase: Running diff --git a/internal/extras/load_test.go b/internal/extras/load_test.go index 30dca62..a85ab67 100644 --- a/internal/extras/load_test.go +++ b/internal/extras/load_test.go @@ -727,7 +727,7 @@ func TestLoadFromSpec_Offline(t *testing.T) { t.Parallel() dir := t.TempDir() specPath := filepath.Join(dir, "deployah.yaml") - writeFile(t, specPath, "apiVersion: deployah.dev/v1-alpha.3\nproject: demo\n") + writeFile(t, specPath, "apiVersion: deployah.dev/v1-alpha.4\nproject: demo\n") writeFile(t, filepath.Join(dir, ".deployah", "manifests", "cm.yaml"), ` apiVersion: v1 kind: ConfigMap diff --git a/internal/helm/cache_test.go b/internal/helm/cache_test.go index 9767882..deb7317 100644 --- a/internal/helm/cache_test.go +++ b/internal/helm/cache_test.go @@ -38,11 +38,11 @@ func TestPrepareChart_CacheSurvivesCallerCleanup(t *testing.T) { t.Parallel() cache := NewChartCache(time.Hour) manifest := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "cache-test", Components: map[string]spec.Component{"web": serviceComponent()}, } - require.NoError(t, spec.FillSpecWithDefaults(manifest, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(manifest, "v1-alpha.4")) returnedPath, err := PrepareChart(t.Context(), manifest, "production", nil, cache) require.NoError(t, err) diff --git a/internal/helm/chart/charts/deployah/templates/app.yaml b/internal/helm/chart/charts/deployah/templates/app.yaml index d3b3e01..af97334 100644 --- a/internal/helm/chart/charts/deployah/templates/app.yaml +++ b/internal/helm/chart/charts/deployah/templates/app.yaml @@ -23,7 +23,7 @@ {{ include "deployah.tls.secrets" . }} -{{- if and .Values.ports .Values.service.ports }} +{{- if and .Values.ports .Values.service.ports (ne .Values.service.enabled false) }} {{ include "deployah.service" . }} {{- end }} diff --git a/internal/helm/chart/charts/deployah/templates/podmonitor.yaml b/internal/helm/chart/charts/deployah/templates/podmonitor.yaml index 30b6b80..ab9a901 100644 --- a/internal/helm/chart/charts/deployah/templates/podmonitor.yaml +++ b/internal/helm/chart/charts/deployah/templates/podmonitor.yaml @@ -1,5 +1,5 @@ {{- define "deployah.podmonitor" -}} -{{- if .Values.podMonitor.enabled }} +{{- if and .Values.podMonitor.enabled (.Capabilities.APIVersions.Has "monitoring.coreos.com/v1") }} --- apiVersion: monitoring.coreos.com/v1 kind: PodMonitor diff --git a/internal/helm/chart/charts/deployah/templates/servicemonitor.yaml b/internal/helm/chart/charts/deployah/templates/servicemonitor.yaml index bd05978..5b85d1c 100644 --- a/internal/helm/chart/charts/deployah/templates/servicemonitor.yaml +++ b/internal/helm/chart/charts/deployah/templates/servicemonitor.yaml @@ -1,5 +1,5 @@ {{- define "deployah.servicemonitor" -}} -{{- if .Values.serviceMonitor.enabled }} +{{- if and .Values.serviceMonitor.enabled (.Capabilities.APIVersions.Has "monitoring.coreos.com/v1") }} --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor diff --git a/internal/helm/chart/charts/deployah/values.yaml b/internal/helm/chart/charts/deployah/values.yaml index 0cca589..4f648a3 100644 --- a/internal/helm/chart/charts/deployah/values.yaml +++ b/internal/helm/chart/charts/deployah/values.yaml @@ -685,6 +685,10 @@ exports: ## APP service parameters ## service: + ## @param service.enabled Create a ClusterIP Service. Set false for workers (headless still renders for StatefulSets). + ## + enabled: true + ## @param service.type APP service type ## type: ClusterIP diff --git a/internal/helm/generate.go b/internal/helm/generate.go index a19ebdd..02e8fe4 100644 --- a/internal/helm/generate.go +++ b/internal/helm/generate.go @@ -321,8 +321,7 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe }, } - if !component.Role.IsService() { - // TODO: Add support for component roles such as "worker", and "job" + if component.Role == spec.ComponentRoleJob { return nil, fmt.Errorf("role %s is not supported yet", component.Role) } // TODO: Implement handling for component envFile @@ -483,19 +482,11 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe componentValues["args"] = component.Args } - // Worker and job roles do not expose ports. - if component.ListensOnPort() { - componentValues["ports"] = []map[string]any{ - { - "name": "http", - "containerPort": component.Port, - // TODO: Add support for protocol - "protocol": "TCP", - }, - } + if err := applyPortsAndService(componentValues, component); err != nil { + return nil, fmt.Errorf("component %s: %w", componentName, err) } - if component.Role.IsService() { + if component.Role.IsService() || hasExecAlive(component) { probes, probeErr := buildProbeValues(component) if probeErr != nil { return nil, fmt.Errorf("component %s: probes: %w", componentName, probeErr) @@ -503,12 +494,27 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe maps.Copy(componentValues, probes) } + if err := applyShutdownTimeout(componentValues, component); err != nil { + return nil, fmt.Errorf("component %s: shutdownTimeout: %w", componentName, err) + } + + var mergedProfile *spec.PlatformProfile + if resolved != nil { + if rc, ok := resolved.Components[componentName]; ok { + mergedProfile = rc.MergedProfile + } + } + if err := applyMetricsValues(componentValues, component, mergedProfile); err != nil { + return nil, fmt.Errorf("component %s: metrics: %w", componentName, err) + } + entry, hasEntry := resolvedComponents[componentName].(map[string]any) if !hasEntry { entry = map[string]any{} resolvedComponents[componentName] = entry } entry["workloadKind"] = workloadKind + entry["role"] = string(component.Role) if component.Persistence != nil { entry["persistenceMountPath"] = component.Persistence.MountPath entry["persistenceSize"] = component.Persistence.Size @@ -555,14 +561,225 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe return values, nil } +// applyPortsAndService sets container ports and ClusterIP/headless service +// ports. Workers never get a ClusterIP Service; stateful workers get a +// synthetic identity port for headless DNS. Chart defaults include an http +// port, so workers without ports must clear ports to nil (falsy in Helm). +// TODO: Add support for port protocol +func applyPortsAndService(componentValues map[string]any, component spec.Component) error { + if component.Role.IsWorker() { + svc := map[string]any{"enabled": false} + componentValues["service"] = svc + ports := make([]map[string]any, 0, 2) + servicePorts := make([]map[string]any, 0, 1) + if component.Kind == spec.ComponentKindStateful { + ports = append(ports, map[string]any{ + "name": spec.IdentityPortName, + "containerPort": spec.IdentityPortNumber, + "protocol": "TCP", + }) + servicePorts = append(servicePorts, map[string]any{ + "name": spec.IdentityPortName, + "protocol": "TCP", + "port": spec.IdentityPortNumber, + "targetPort": spec.IdentityPortName, + }) + } + if component.Metrics.IsEnabled() && component.Metrics.Port > 0 { + ports = append(ports, map[string]any{ + "name": spec.MetricsPortName, + "containerPort": component.Metrics.Port, + "protocol": "TCP", + }) + } + if len(ports) == 0 { + componentValues["ports"] = nil + } else { + componentValues["ports"] = ports + } + if len(servicePorts) > 0 { + // Keep enabled:false while attaching headless identity ports. + svc["ports"] = servicePorts + } + return nil + } + + // Service role. + if !component.ListensOnPort() { + return nil + } + ports := []map[string]any{ + { + "name": "http", + "containerPort": component.Port, + "protocol": "TCP", + }, + } + servicePorts := []map[string]any{ + { + "name": "http", + "protocol": "TCP", + "port": 80, + "targetPort": "http", + }, + } + if component.Metrics.IsEnabled() { + metricsPort := component.Metrics.Port + if metricsPort == 0 { + metricsPort = component.Port + } + if metricsPort != component.Port { + ports = append(ports, map[string]any{ + "name": spec.MetricsPortName, + "containerPort": metricsPort, + "protocol": "TCP", + }) + servicePorts = append(servicePorts, map[string]any{ + "name": spec.MetricsPortName, + "protocol": "TCP", + "port": metricsPort, + "targetPort": spec.MetricsPortName, + }) + } + } + componentValues["ports"] = ports + componentValues["service"] = map[string]any{ + "enabled": true, + "ports": servicePorts, + } + return nil +} + +// applyShutdownTimeout maps shutdownTimeout to terminationGracePeriodSeconds. +// FillSpecWithDefaults guarantees shutdownTimeout is non-empty by the time +// chart generation runs. +func applyShutdownTimeout(componentValues map[string]any, component spec.Component) error { + sec, err := spec.ParseDuration(component.ShutdownTimeout) + if err != nil { + return err + } + componentValues["terminationGracePeriodSeconds"] = sec + return nil +} + +// applyMetricsValues enables ServiceMonitor (service) or PodMonitor (worker) +// and copies platform profile monitor settings onto the monitor values. +func applyMetricsValues( + componentValues map[string]any, + component spec.Component, + profile *spec.PlatformProfile, +) error { + if !component.Metrics.IsEnabled() { + return nil + } + // FillSpecWithDefaults guarantees Path is non-empty when metrics are enabled. + path := component.Metrics.Path + monitorPort := "http" + if component.Role.IsWorker() { + monitorPort = spec.MetricsPortName + } else if component.Metrics.Port > 0 && component.Metrics.Port != component.Port { + monitorPort = spec.MetricsPortName + } + + monitor := map[string]any{ + "enabled": true, + "port": monitorPort, + "path": path, + } + if profile != nil { + if err := copyMonitorProfile(monitor, profile); err != nil { + return err + } + } + // App interval/timeout override profile defaults when set. + if component.Metrics.Interval != "" { + monitor["interval"] = component.Metrics.Interval + } + if component.Metrics.ScrapeTimeout != "" { + monitor["scrapeTimeout"] = component.Metrics.ScrapeTimeout + } + + if component.Role.IsWorker() { + componentValues["podMonitor"] = monitor + } else { + componentValues["serviceMonitor"] = monitor + } + return nil +} + +// copyMonitorProfile writes platform profile metrics fields into a monitor +// values map. App-level interval/scrapeTimeout are applied by the caller +// after this returns. +func copyMonitorProfile(monitor map[string]any, profile *spec.PlatformProfile) error { + m := profile.Metrics + if m == nil { + return nil + } + if len(m.MonitorLabels) > 0 { + monitor["labels"] = maps.Clone(m.MonitorLabels) + } + if m.MonitorNamespace != "" { + monitor["namespace"] = m.MonitorNamespace + } + if m.Interval != "" { + monitor["interval"] = m.Interval + } + if m.ScrapeTimeout != "" { + monitor["scrapeTimeout"] = m.ScrapeTimeout + } + if m.JobLabel != "" { + monitor["jobLabel"] = m.JobLabel + } + if m.HonorLabels != nil { + monitor["honorLabels"] = *m.HonorLabels + } + if len(m.Annotations) > 0 { + monitor["annotations"] = maps.Clone(m.Annotations) + } + if len(m.Relabelings) > 0 { + vals, err := toValuesSlice(m.Relabelings) + if err != nil { + return fmt.Errorf("relabelings: %w", err) + } + monitor["relabelings"] = vals + } + if len(m.MetricRelabelings) > 0 { + vals, err := toValuesSlice(m.MetricRelabelings) + if err != nil { + return fmt.Errorf("metricRelabelings: %w", err) + } + monitor["metricRelabelings"] = vals + } + return nil +} + +func hasExecAlive(component spec.Component) bool { + return component.Health != nil && + component.Health.Alive != nil && + !component.Health.Alive.Disabled && + len(component.Health.Alive.Exec) > 0 +} + // buildProbeValues builds startup/readiness/liveness probe values from the -// component's health config. +// component's health config. Workers only emit liveness when alive.exec is set. func buildProbeValues(component spec.Component) (map[string]any, error) { h := component.Health readyDisabled := h != nil && h.Ready != nil && h.Ready.Disabled aliveDisabled := h != nil && h.Alive != nil && h.Alive.Disabled + // Workers: only optional exec liveness; no startup/readiness. + if component.Role.IsWorker() { + if aliveDisabled || !hasExecAlive(component) { + return map[string]any{}, nil + } + liveness, err := buildLivenessProbe(h.Alive.Exec, "", h.Alive.Interval, h.Alive.RestartAfter) + if err != nil { + return nil, fmt.Errorf("livenessProbe: %w", err) + } + return map[string]any{"livenessProbe": liveness}, nil + } + // Nothing to emit when both sides are explicitly off. if readyDisabled && aliveDisabled { return map[string]any{}, nil @@ -576,8 +793,10 @@ func buildProbeValues(component spec.Component) (map[string]any, error) { readyPath = h.Ready.Path } var alivePath string + var aliveExec []string if h != nil && h.Alive != nil && !h.Alive.Disabled { alivePath = h.Alive.Path + aliveExec = h.Alive.Exec } // Startup probe is active whenever at least one side is not disabled. It @@ -605,7 +824,7 @@ func buildProbeValues(component spec.Component) (map[string]any, error) { interval = h.Alive.Interval restartAfter = h.Alive.RestartAfter } - liveness, livenessErr := buildLivenessProbe(alivePath, interval, restartAfter) + liveness, livenessErr := buildLivenessProbe(aliveExec, alivePath, interval, restartAfter) if livenessErr != nil { return nil, fmt.Errorf("livenessProbe: %w", livenessErr) } @@ -619,7 +838,7 @@ func buildProbeValues(component spec.Component) (map[string]any, error) { // When path is non-empty the probe uses HTTP; otherwise it uses TCP. func buildStartupProbe(path string) (map[string]any, error) { return probeValues(corev1.Probe{ - ProbeHandler: probeHandler(path), + ProbeHandler: probeHandler(nil, path), PeriodSeconds: int32(spec.DefaultStartupProbePeriod), FailureThreshold: int32(spec.DefaultStartupProbeFailureThreshold), TimeoutSeconds: int32(spec.DefaultStartupProbeTimeout), @@ -630,7 +849,7 @@ func buildStartupProbe(path string) (map[string]any, error) { // When path is non-empty the probe uses HTTP; otherwise it uses TCP. func buildReadinessProbe(path string) (map[string]any, error) { return probeValues(corev1.Probe{ - ProbeHandler: probeHandler(path), + ProbeHandler: probeHandler(nil, path), PeriodSeconds: int32(spec.DefaultReadinessProbePeriod), FailureThreshold: int32(spec.DefaultReadinessProbeFailureThreshold), TimeoutSeconds: int32(spec.DefaultReadinessProbeTimeout), @@ -638,10 +857,10 @@ func buildReadinessProbe(path string) (map[string]any, error) { } // buildLivenessProbe constructs the liveness probe map for the Helm values. -// When path is non-empty the probe uses HTTP; otherwise it uses TCP. +// Exec takes precedence over path. When both are empty the probe uses TCP. // interval and restartAfter are duration strings; each defaults when empty. // failureThreshold = ceil(restartAfterSec / intervalSec). -func buildLivenessProbe(path, interval, restartAfter string) (map[string]any, error) { +func buildLivenessProbe(exec []string, path, interval, restartAfter string) (map[string]any, error) { if interval == "" { interval = spec.DefaultLivenessInterval } @@ -664,7 +883,7 @@ func buildLivenessProbe(path, interval, restartAfter string) (map[string]any, er failureThreshold := min(max(int(math.Ceil(float64(restartSec)/float64(intervalSec))), 1), math.MaxInt32) return probeValues(corev1.Probe{ - ProbeHandler: probeHandler(path), + ProbeHandler: probeHandler(exec, path), PeriodSeconds: int32(intervalSec), // failureThreshold is clamped to math.MaxInt32 above. FailureThreshold: int32(failureThreshold), //nolint:gosec @@ -672,7 +891,12 @@ func buildLivenessProbe(path, interval, restartAfter string) (map[string]any, er }) } -func probeHandler(path string) corev1.ProbeHandler { +func probeHandler(exec []string, path string) corev1.ProbeHandler { + if len(exec) > 0 { + return corev1.ProbeHandler{ + Exec: &corev1.ExecAction{Command: slices.Clone(exec)}, + } + } port := intstr.FromString("http") if path != "" { return corev1.ProbeHandler{ diff --git a/internal/helm/generate_test.go b/internal/helm/generate_test.go index e39f7b4..29a24dc 100644 --- a/internal/helm/generate_test.go +++ b/internal/helm/generate_test.go @@ -244,7 +244,7 @@ func TestBuildLivenessProbe_IntervalOnlyDefaultsRestartAfter(t *testing.T) { t.Parallel() // interval provided, restartAfter omitted -> defaults to 60s -> 60/30=2 - p, err := buildLivenessProbe("", "30s", "") + p, err := buildLivenessProbe(nil, "", "30s", "") require.NoError(t, err) assert.Equal(t, 2, p["failureThreshold"]) assert.Equal(t, 30, p["periodSeconds"]) @@ -256,7 +256,7 @@ func TestBuildLivenessProbe_RestartAfterOnlyDefaultsInterval(t *testing.T) { t.Parallel() // restartAfter provided, interval omitted -> interval defaults to 10s -> 120/10=12 - p, err := buildLivenessProbe("", "", "2m") + p, err := buildLivenessProbe(nil, "", "", "2m") require.NoError(t, err) assert.Equal(t, 12, p["failureThreshold"]) assert.Equal(t, 10, p["periodSeconds"]) @@ -287,11 +287,11 @@ func TestMapSpecToChartValues_EnvironmentFilterPrefixMatch(t *testing.T) { comp := serviceComponent() comp.Environments = tt.filter m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Components: map[string]spec.Component{"web": comp}, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) vals, err := MapSpecToChartValues(m, tt.environment, nil) require.NoError(t, err) @@ -312,7 +312,7 @@ func TestMapSpecToChartValues_SelfSignedTLS(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "local": {}, @@ -329,7 +329,7 @@ func TestMapSpecToChartValues_SelfSignedTLS(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -375,7 +375,7 @@ func TestMapSpecToChartValues_SelfSignedTLS_Unmaterialized(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "local": {}, @@ -392,7 +392,7 @@ func TestMapSpecToChartValues_SelfSignedTLS_Unmaterialized(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -418,7 +418,7 @@ func TestMapSpecToChartValues_SecretNameTLS(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -435,7 +435,7 @@ func TestMapSpecToChartValues_SecretNameTLS(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -466,7 +466,7 @@ func TestMapSpecToChartValues_CertManagerTLS(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -483,7 +483,7 @@ func TestMapSpecToChartValues_CertManagerTLS(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -514,7 +514,7 @@ func TestMapSpecToChartValues_Autoscaling(t *testing.T) { t.Parallel() m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -536,7 +536,7 @@ func TestMapSpecToChartValues_Autoscaling(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) vals, err := MapSpecToChartValues(m, "production", nil) require.NoError(t, err) @@ -556,7 +556,7 @@ func TestMapSpecToChartValues_Profiles(t *testing.T) { t.Parallel() m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -569,7 +569,7 @@ func TestMapSpecToChartValues_Profiles(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -643,7 +643,7 @@ func TestMapSpecToChartValues_StatefulComponent(t *testing.T) { replicas := 2 m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -662,7 +662,7 @@ func TestMapSpecToChartValues_StatefulComponent(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -713,7 +713,7 @@ func TestMapSpecToChartValues_StatefulIdentityOnly(t *testing.T) { replicas := 2 m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -728,7 +728,7 @@ func TestMapSpecToChartValues_StatefulIdentityOnly(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) vals, err := MapSpecToChartValues(m, "production", nil) require.NoError(t, err) @@ -746,7 +746,7 @@ func TestMapSpecToChartValues_StatelessWithPersistence(t *testing.T) { t.Parallel() m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -764,7 +764,7 @@ func TestMapSpecToChartValues_StatelessWithPersistence(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) vals, err := MapSpecToChartValues(m, "production", nil) require.NoError(t, err) @@ -784,7 +784,7 @@ func TestMapSpecToChartValues_Replicas(t *testing.T) { replicas := 3 m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -798,7 +798,7 @@ func TestMapSpecToChartValues_Replicas(t *testing.T) { }, }, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.4")) vals, err := MapSpecToChartValues(m, "production", nil) require.NoError(t, err) @@ -1051,3 +1051,160 @@ func TestNormalizeJSONNumbers(t *testing.T) { }) } } + +func TestMapSpecToChartValues_WorkerStateless(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "worker": { + Role: spec.ComponentRoleWorker, + Image: "ghcr.io/acme/worker:1.0.0", + Command: []string{"sleep", "infinity"}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + values, err := MapSpecToChartValues(manifest, "dev", nil) + require.NoError(t, err) + worker := mustNestedMap(t, values, "worker") + assert.Equal(t, "Deployment", worker["workloadKind"]) + assert.Nil(t, worker["ports"]) + svc := mustNestedMap(t, worker, "service") + assert.Equal(t, false, svc["enabled"]) + assert.Equal(t, 60, worker["terminationGracePeriodSeconds"]) + assert.Nil(t, worker["startupProbe"]) + assert.Nil(t, worker["readinessProbe"]) + resolved := mustNestedMap(t, mustNestedMap(t, mustNestedMap(t, values, "deployah"), "resolved"), "components") + assert.Equal(t, "worker", mustNestedMap(t, resolved, "worker")["role"]) +} + +func TestMapSpecToChartValues_WorkerStatefulIdentityPort(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "worker": { + Role: spec.ComponentRoleWorker, + Kind: spec.ComponentKindStateful, + Image: "ghcr.io/acme/worker:1.0.0", + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + values, err := MapSpecToChartValues(manifest, "dev", nil) + require.NoError(t, err) + worker := mustNestedMap(t, values, "worker") + assert.Equal(t, "StatefulSet", worker["workloadKind"]) + ports, ok := worker["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, ports, 1) + assert.Equal(t, spec.IdentityPortName, ports[0]["name"]) + assert.Equal(t, spec.IdentityPortNumber, ports[0]["containerPort"]) + svc := mustNestedMap(t, worker, "service") + assert.Equal(t, false, svc["enabled"]) + svcPorts, ok := svc["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, svcPorts, 1) + assert.Equal(t, spec.IdentityPortName, svcPorts[0]["name"]) +} + +func TestMapSpecToChartValues_WorkerMetrics(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "worker": { + Role: spec.ComponentRoleWorker, + Image: "ghcr.io/acme/worker:1.0.0", + Metrics: &spec.ComponentMetrics{Port: 9090}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + profile := &spec.PlatformProfile{Metrics: &spec.ProfileMetrics{ + MonitorLabels: map[string]string{"release": "kube-prometheus-stack"}, + }} + resolved := &spec.ResolvedSpec{Components: map[string]spec.ResolvedComponent{ + "worker": {MergedProfile: profile}, + }} + values, err := MapSpecToChartValues(manifest, "dev", resolved) + require.NoError(t, err) + worker := mustNestedMap(t, values, "worker") + ports, ok := worker["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, ports, 1) + assert.Equal(t, spec.MetricsPortName, ports[0]["name"]) + assert.Equal(t, 9090, ports[0]["containerPort"]) + pm := mustNestedMap(t, worker, "podMonitor") + assert.Equal(t, true, pm["enabled"]) + assert.Equal(t, spec.MetricsPortName, pm["port"]) + assert.Equal(t, "/metrics", pm["path"]) + assert.Equal(t, map[string]string{"release": "kube-prometheus-stack"}, pm["labels"]) +} + +func TestMapSpecToChartValues_ServiceMetricsDedicatedPort(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "ghcr.io/acme/api:1.0.0", + Port: 8080, + Metrics: &spec.ComponentMetrics{Port: 9090, Path: "/metrics"}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + profile := &spec.PlatformProfile{Metrics: &spec.ProfileMetrics{ + MonitorLabels: map[string]string{"release": "prom"}, + }} + resolved := &spec.ResolvedSpec{Components: map[string]spec.ResolvedComponent{ + "api": {MergedProfile: profile}, + }} + values, err := MapSpecToChartValues(manifest, "dev", resolved) + require.NoError(t, err) + api := mustNestedMap(t, values, "api") + ports, ok := api["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, ports, 2) + sm := mustNestedMap(t, api, "serviceMonitor") + assert.Equal(t, true, sm["enabled"]) + assert.Equal(t, spec.MetricsPortName, sm["port"]) + svc := mustNestedMap(t, api, "service") + svcPorts, ok := svc["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, svcPorts, 2) +} + +func TestMapSpecToChartValues_WorkerExecHealth(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "worker": { + Role: spec.ComponentRoleWorker, + Image: "ghcr.io/acme/worker:1.0.0", + Health: &spec.Health{ + Alive: &spec.HealthAlive{Exec: []string{"pgrep", "-f", "worker"}}, + }, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + values, err := MapSpecToChartValues(manifest, "dev", nil) + require.NoError(t, err) + worker := mustNestedMap(t, values, "worker") + assert.Nil(t, worker["startupProbe"]) + assert.Nil(t, worker["readinessProbe"]) + liveness := mustNestedMap(t, worker, "livenessProbe") + assert.Equal(t, true, liveness["enabled"]) + exec := mustNestedMap(t, liveness, "exec") + assert.Equal(t, []any{"pgrep", "-f", "worker"}, exec["command"]) +} diff --git a/internal/helm/render.go b/internal/helm/render.go index 21b1092..c2bfa61 100644 --- a/internal/helm/render.go +++ b/internal/helm/render.go @@ -27,9 +27,16 @@ import ( "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" + chartcommon "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) +// offlineMonitorAPIVersion is appended to Helm Install.APIVersions on +// DryRunClient installs so ServiceMonitor/PodMonitor templates can render +// when there is no discovery client. Online plan/deploy still gate on +// k8s.CheckAPIRequirements; real installs use cluster Capabilities. +const offlineMonitorAPIVersion = "monitoring.coreos.com/v1" + // RenderManifests renders the chart via Helm's DryRunClient strategy, so // hooks/templates see the same values, capabilities, and revision as a real // apply. It mirrors InstallApp's install-vs-upgrade decision so the result @@ -194,6 +201,10 @@ func (c *Client) renderInstall(ctx context.Context, releaseName string, ch *char install.DisableOpenAPIValidation = true install.Labels = labels install.PostRenderer = postRenderer + // DryRunClient resets Capabilities to DefaultCapabilities (built-in + // APIs only). Append the Prometheus Operator GV so chart templates + // gated on .Capabilities.APIVersions.Has still render offline. + install.APIVersions = chartcommon.VersionSet{offlineMonitorAPIVersion} rel, runErr := install.RunWithContext(ctx, ch, values) if runErr != nil { diff --git a/internal/k8s/requirements.go b/internal/k8s/requirements.go new file mode 100644 index 0000000..09e9333 --- /dev/null +++ b/internal/k8s/requirements.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "fmt" + "strings" + + "deployah.dev/deployah/internal/spec" +) + +// RequiredAPIs derives the Kubernetes API group/version requirements for +// components deployed in the target environment, including cert-manager +// (TLS mode) via the resolved spec and prometheus-operator when metrics are +// enabled. +func RequiredAPIs(manifest *spec.Spec, environment string, resolved *spec.ResolvedSpec) []APIRequirement { + type entry struct { + groupVersions []string + components []string + } + + entries := make(map[string]*entry) // keyed by canonical group/version string + + add := func(groupVersions []string, componentName string) { + key := strings.Join(groupVersions, "|") + e := entries[key] + if e == nil { + e = &entry{groupVersions: groupVersions} + entries[key] = e + } + e.components = append(e.components, fmt.Sprintf("%q", componentName)) + } + + for name, component := range manifest.Components { + // Same matcher as spec.Resolve and chart generation, so wildcard + // deploys agree on the active component set. + if len(component.Environments) > 0 { + if _, ok := spec.MatchEnvKey(environment, component.Environments); !ok { + continue + } + } + if component.Autoscaling != nil && component.Autoscaling.Enabled { + add([]string{"autoscaling/v2", "autoscaling/v2beta2"}, name) + } + if component.Expose != nil { + add([]string{"networking.k8s.io/v1"}, name) + } + if component.Metrics.IsEnabled() { + add([]string{"monitoring.coreos.com/v1"}, name) + } + if resolved != nil { + if rc, ok := resolved.Components[name]; ok && rc.TLSMode == spec.TLSModeCertManager { + add([]string{"cert-manager.io/v1"}, name) + } + } + } + + reqs := make([]APIRequirement, 0, len(entries)) + for _, e := range entries { + noun := "component" + if len(e.components) > 1 { + noun = "components" + } + reqs = append(reqs, APIRequirement{ + GroupVersions: e.groupVersions, + Reason: fmt.Sprintf("required by %s %s", noun, strings.Join(e.components, ", ")), + }) + } + return reqs +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index e8c4e68..85dc31e 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -61,7 +61,7 @@ users: // minimalSpecYAML is a self-contained spec fixture with a valid apiVersion // and a single component, reused by Spec/ParseManifest tests. -const minimalSpecYAML = `apiVersion: v1-alpha.3 +const minimalSpecYAML = `apiVersion: v1-alpha.4 project: demo components: web: @@ -355,7 +355,7 @@ func TestTarget(t *testing.T) { platformDir := t.TempDir() platformPath := platformDir + "/deployah.platform.yaml" - platformYAML := `apiVersion: platform/v1-alpha.2 + platformYAML := `apiVersion: platform/v1-alpha.3 environments: production: context: prod-eks @@ -375,7 +375,7 @@ environments: platformDir := t.TempDir() platformPath := platformDir + "/deployah.platform.yaml" - platformYAML := `apiVersion: platform/v1-alpha.2 + platformYAML := `apiVersion: platform/v1-alpha.3 environments: production: context: prod-eks @@ -534,7 +534,7 @@ func TestKubeContextAccessor(t *testing.T) { // stale cached value. func TestClose(t *testing.T) { platformPath := filepath.Join(t.TempDir(), "deployah.platform.yaml") - platformYAML := `apiVersion: platform/v1-alpha.2 + platformYAML := `apiVersion: platform/v1-alpha.3 environments: production: domains: diff --git a/internal/spec/constants.go b/internal/spec/constants.go index 379d2dd..8f7bc4f 100644 --- a/internal/spec/constants.go +++ b/internal/spec/constants.go @@ -19,7 +19,7 @@ const ( // CurrentManifestVersion is the manifest apiVersion written by the init // command and expected by the current resolver. Bump this when a new // schema version is added alongside a new schema directory. - CurrentManifestVersion = "v1-alpha.3" + CurrentManifestVersion = "v1-alpha.4" // DefaultSpecPath is the default path for the Deployah spec file DefaultSpecPath = "deployah.yaml" @@ -143,6 +143,29 @@ const ( // DefaultLivenessRestartAfter is the default value for // health.alive.restartAfter when the field is omitted. DefaultLivenessRestartAfter = "60s" + + // DefaultServiceShutdownTimeout is the default shutdownTimeout for + // service components (terminationGracePeriodSeconds). + DefaultServiceShutdownTimeout = "30s" + + // DefaultWorkerShutdownTimeout is the default shutdownTimeout for + // worker components (terminationGracePeriodSeconds). + DefaultWorkerShutdownTimeout = "60s" + + // DefaultMetricsPath is the default HTTP path for Prometheus metrics. + DefaultMetricsPath = "/metrics" + + // IdentityPortName is the synthetic container/service port name used for + // headless DNS on stateful workers that have no app port. + IdentityPortName = "identity" + + // IdentityPortNumber is the discard protocol port used as a tracking + // port for headless Services when a worker has no application port. + IdentityPortNumber = 9 + + // MetricsPortName is the named container/service port used when metrics + // scraping is enabled on a dedicated port or on a worker. + MetricsPortName = "metrics" ) // Resource Management diff --git a/internal/spec/defaults.go b/internal/spec/defaults.go index 6b73312..056e629 100644 --- a/internal/spec/defaults.go +++ b/internal/spec/defaults.go @@ -66,14 +66,14 @@ const componentsPrefixLength = len(ComponentsPrefix) // and pattern extraction operations. // // Cache keys follow the format: "{version}-{schemaType}" -// Example: "v1-alpha.3-spec", "v1-alpha.3-environments" +// Example: "v1-alpha.4-spec", "v1-alpha.4-environments" var ( // compiledSchemaCache stores compiled JSON schemas with their raw data - // Key format: "v1-alpha.3-spec" -> schemaInfo{compiled, rawData} + // Key format: "v1-alpha.4-spec" -> schemaInfo{compiled, rawData} compiledSchemaCache = make(map[string]*schemaInfo) // patternCache stores extracted component name patterns from schemas - // Key format: "v1-alpha.3" -> "^[a-zA-Z0-9_-]+$" + // Key format: "v1-alpha.4" -> "^[a-zA-Z0-9_-]+$" patternCache = make(map[string]string) // schemaMutex protects concurrent access to the caches @@ -270,7 +270,7 @@ func (w *defaultsWalker) walk(schemaData any, path string, defaults DefaultValue } // Handle map-typed additionalProperties combined with a propertyNames - // pattern (the v1-alpha.3 layout for components/environments); the + // pattern (the v1-alpha.4 layout for components/environments); the // pattern plays the same role as a patternProperties key. if addProps, exists := schemaMap["additionalProperties"].(map[string]any); exists { pattern := ".*" @@ -398,6 +398,7 @@ func FillSpecWithDefaults(spec *Spec, version string) error { if err = applyDefaultsRecursively(&component, specDefaults, "components."+componentName, version); err != nil { return fmt.Errorf("failed to apply defaults to component %s: %w", componentName, err) } + applyRoleDependentDefaults(&component) spec.Components[componentName] = component } @@ -839,6 +840,36 @@ func isZeroValue(val any) bool { return v.IsZero() } +// applyRoleDependentDefaults fills fields whose defaults depend on role +// (and therefore cannot come from the JSON schema alone): +// - shutdownTimeout: 30s service, 60s worker +// - port: 8080 for services only (workers must not get a default port) +// - metrics.path: /metrics when metrics enabled and path empty +// - metrics.port: component port for services when metrics enabled and port 0 +func applyRoleDependentDefaults(c *Component) { + if c.Role == "" { + c.Role = ComponentRoleService + } + if c.ShutdownTimeout == "" { + if c.Role.IsWorker() { + c.ShutdownTimeout = DefaultWorkerShutdownTimeout + } else { + c.ShutdownTimeout = DefaultServiceShutdownTimeout + } + } + if c.Role.IsService() && c.Port == 0 { + c.Port = 8080 + } + if c.Metrics.IsEnabled() { + if c.Metrics.Path == "" { + c.Metrics.Path = DefaultMetricsPath + } + if c.Role.IsService() && c.Metrics.Port == 0 { + c.Metrics.Port = c.Port + } + } +} + // CreateSpecWithDefaults creates a minimal [Spec] for projectName and fills // it with the defaults declared by version's schema. func CreateSpecWithDefaults(projectName, version string) (*Spec, error) { diff --git a/internal/spec/defaults_test.go b/internal/spec/defaults_test.go index 3f216ff..d7dba08 100644 --- a/internal/spec/defaults_test.go +++ b/internal/spec/defaults_test.go @@ -137,13 +137,13 @@ func TestGetDefaultValues(t *testing.T) { }{ { name: "valid manifest schema", - version: "v1-alpha.3", + version: "v1-alpha.4", schemaType: schema.SchemaTypeManifest, expectErr: false, }, { name: "valid environments schema", - version: "v1-alpha.3", + version: "v1-alpha.4", schemaType: schema.SchemaTypeEnvironments, expectErr: false, }, @@ -155,7 +155,7 @@ func TestGetDefaultValues(t *testing.T) { }, { name: "unsupported schema type", - version: "v1-alpha.3", + version: "v1-alpha.4", schemaType: "unsupported", expectErr: true, }, @@ -173,7 +173,7 @@ func TestGetDefaultValues(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, defaults) // The environments schema declares no defaults in - // v1-alpha.3; only the manifest schema must be non-empty. + // v1-alpha.4; only the manifest schema must be non-empty. if tt.schemaType == schema.SchemaTypeManifest { assert.NotEmpty(t, defaults) } @@ -195,7 +195,7 @@ func TestFillSpecWithDefaults(t *testing.T) { { name: "valid manifest with components", manifest: &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: map[string]Component{ "web": { @@ -203,36 +203,36 @@ func TestFillSpecWithDefaults(t *testing.T) { }, }, }, - version: "v1-alpha.3", + version: "v1-alpha.4", expectErr: false, }, { name: "manifest with nil components", manifest: &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: nil, }, - version: "v1-alpha.3", + version: "v1-alpha.4", expectErr: false, }, { name: "manifest with environments", manifest: &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: map[string]Component{}, Environments: map[string]Environment{ "production": {}, }, }, - version: "v1-alpha.3", + version: "v1-alpha.4", expectErr: false, }, { name: "invalid version", manifest: &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: map[string]Component{}, }, @@ -384,7 +384,7 @@ func TestApplyDefaultsRecursively(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - require.NoError(t, applyDefaultsRecursively(tt.obj, tt.defaults, tt.path, "v1-alpha.3")) + require.NoError(t, applyDefaultsRecursively(tt.obj, tt.defaults, tt.path, "v1-alpha.4")) assert.Equal(t, tt.expected, tt.obj) }) } @@ -426,7 +426,7 @@ func TestApplyDefaultsToMap(t *testing.T) { t.Parallel() // This test mainly ensures the function doesn't panic - require.NoError(t, applyDefaultsToMap(tt.mapVal, tt.defaults, tt.path, "v1-alpha.3")) + require.NoError(t, applyDefaultsToMap(tt.mapVal, tt.defaults, tt.path, "v1-alpha.4")) // No specific assertions as this is mainly testing for panics }) } @@ -465,7 +465,7 @@ func TestApplyDefaultsToSlice(t *testing.T) { t.Parallel() // This test mainly ensures the function doesn't panic - require.NoError(t, applyDefaultsToSlice(tt.sliceVal, tt.defaults, tt.path, "v1-alpha.3")) + require.NoError(t, applyDefaultsToSlice(tt.sliceVal, tt.defaults, tt.path, "v1-alpha.4")) // No specific assertions as this is mainly testing for panics }) } @@ -659,7 +659,7 @@ func TestCreateSpecWithDefaults(t *testing.T) { { name: "valid manifest creation", projectName: "test-project", - version: "v1-alpha.3", + version: "v1-alpha.4", expectErr: false, }, { @@ -787,7 +787,7 @@ func TestIntegration(t *testing.T) { t.Run("create manifest with defaults and verify component defaults", func(t *testing.T) { t.Parallel() - manifest, err := CreateSpecWithDefaults("test-project", "v1-alpha.3") + manifest, err := CreateSpecWithDefaults("test-project", "v1-alpha.4") assert.NoError(t, err) assert.NotNil(t, manifest) @@ -796,7 +796,7 @@ func TestIntegration(t *testing.T) { Image: "nginx:latest", } - err = FillSpecWithDefaults(manifest, "v1-alpha.3") + err = FillSpecWithDefaults(manifest, "v1-alpha.4") assert.NoError(t, err) webComponent := manifest.Components["web"] @@ -810,7 +810,7 @@ func TestIntegration(t *testing.T) { t.Parallel() manifest := &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: map[string]Component{ "api": { @@ -822,7 +822,7 @@ func TestIntegration(t *testing.T) { }, } - err := FillSpecWithDefaults(manifest, "v1-alpha.3") + err := FillSpecWithDefaults(manifest, "v1-alpha.4") assert.NoError(t, err) apiComponent := manifest.Components["api"] @@ -838,7 +838,7 @@ func TestIntegration(t *testing.T) { t.Parallel() manifest := &Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "test-project", Components: map[string]Component{}, Environments: map[string]Environment{ @@ -846,10 +846,10 @@ func TestIntegration(t *testing.T) { }, } - err := FillSpecWithDefaults(manifest, "v1-alpha.3") + err := FillSpecWithDefaults(manifest, "v1-alpha.4") assert.NoError(t, err) - // v1-alpha.3 declares no envFile/configFile defaults: the loader's + // v1-alpha.4 declares no envFile/configFile defaults: the loader's // convention-based lookup replaced them. assert.Empty(t, manifest.Environments["production"].EnvFile) assert.Empty(t, manifest.Environments["production"].ConfigFile) @@ -931,7 +931,7 @@ func TestFillSpecWithDefaults_GuardClauses(t *testing.T) { version string errContains string }{ - {name: "nil spec returns error", spec: nil, version: "v1-alpha.3", errContains: "spec cannot be nil"}, + {name: "nil spec returns error", spec: nil, version: "v1-alpha.4", errContains: "spec cannot be nil"}, {name: "empty version returns error", spec: &Spec{Project: "test"}, version: "", errContains: "version cannot be empty"}, } @@ -1006,7 +1006,7 @@ func TestApplyDefaultsToMap_EdgeCases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := applyDefaultsToMap(tt.value, tt.defaults, tt.path, "v1-alpha.3") + err := applyDefaultsToMap(tt.value, tt.defaults, tt.path, "v1-alpha.4") require.NoError(t, err) if tt.check != nil { tt.check(t, tt.value) @@ -1079,7 +1079,7 @@ func TestApplyDefaultsToSlice_EdgeCases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := applyDefaultsToSlice(tt.value, tt.defaults, tt.path, "v1-alpha.3") + err := applyDefaultsToSlice(tt.value, tt.defaults, tt.path, "v1-alpha.4") require.NoError(t, err) if tt.check != nil { tt.check(t, tt.value) @@ -1295,7 +1295,7 @@ func TestProcessStructField(t *testing.T) { t.Parallel() field, fieldType, verify := tt.setup() - err := processStructField(field, fieldType, tt.defaults, tt.path, "v1-alpha.3") + err := processStructField(field, fieldType, tt.defaults, tt.path, "v1-alpha.4") require.NoError(t, err) verify(t) }) diff --git a/internal/spec/example_test.go b/internal/spec/example_test.go index 21a89a7..5f326fb 100644 --- a/internal/spec/example_test.go +++ b/internal/spec/example_test.go @@ -26,13 +26,13 @@ import ( // ExampleFillSpecWithDefaults applies schema defaults to a minimal manifest. func ExampleFillSpecWithDefaults() { m := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "demo", Components: map[string]spec.Component{ "web": {Image: "nginx:latest"}, }, } - if err := spec.FillSpecWithDefaults(m, "v1-alpha.3"); err != nil { + if err := spec.FillSpecWithDefaults(m, "v1-alpha.4"); err != nil { log.Fatal(err) } fmt.Println(m.Components["web"].Port) @@ -41,7 +41,7 @@ func ExampleFillSpecWithDefaults() { // ExampleLoad reads a manifest file from disk. func ExampleLoad() { - const yamlDoc = `apiVersion: v1-alpha.3 + const yamlDoc = `apiVersion: v1-alpha.4 project: demo environments: default: {} diff --git a/internal/spec/field_validation.go b/internal/spec/field_validation.go index 451dfd5..453c390 100644 --- a/internal/spec/field_validation.go +++ b/internal/spec/field_validation.go @@ -64,7 +64,7 @@ func initValidators() error { return fmt.Errorf("failed to extract component name pattern: %w", err) } - // Extract environment name pattern. v1-alpha.3 models "environments" as + // Extract environment name pattern. v1-alpha.4 models "environments" as // an object keyed by environment name, so the pattern lives on // propertyNames rather than on an array item's "name" field. envPattern, err := extractPattern(schemaData, []string{"properties", "environments", "propertyNames", "pattern"}) diff --git a/internal/spec/field_validation_test.go b/internal/spec/field_validation_test.go index 171a65a..2eae0ac 100644 --- a/internal/spec/field_validation_test.go +++ b/internal/spec/field_validation_test.go @@ -111,7 +111,7 @@ func TestValidateComponentName(t *testing.T) { } } -// TestValidateEnvName verifies ValidateEnvName rules against the v1-alpha.3 +// TestValidateEnvName verifies ValidateEnvName rules against the v1-alpha.4 // object-shaped "environments" schema. Top-level environment keys never // carry a "/*" wildcard suffix; that syntax is only valid in a component's // "environments" filter list, which is a plain string array with no pattern diff --git a/internal/spec/loader_test.go b/internal/spec/loader_test.go index bb7c7fe..87fa89f 100644 --- a/internal/spec/loader_test.go +++ b/internal/spec/loader_test.go @@ -144,7 +144,7 @@ func TestLoad_NoEnvironmentsSection(t *testing.T) { dir := t.TempDir() t.Chdir(dir) path := filepath.Join(dir, "deployah.yaml") - doc := `apiVersion: v1-alpha.3 + doc := `apiVersion: v1-alpha.4 project: demo components: web: @@ -394,7 +394,7 @@ func TestParseManifest_ProfilesArray(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "deployah.yaml") content := ` -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: @@ -416,7 +416,7 @@ func TestLoad_OldProfileStringRejected(t *testing.T) { dir := t.TempDir() t.Chdir(dir) content := ` -apiVersion: v1-alpha.3 +apiVersion: v1-alpha.4 project: shop components: web: diff --git a/internal/spec/platform.go b/internal/spec/platform.go index 0998d41..fe3dc77 100644 --- a/internal/spec/platform.go +++ b/internal/spec/platform.go @@ -32,7 +32,7 @@ const PlatformEnvVar = "DEPLOYAH_PLATFORM_FILE" // PlatformConfig is the top-level structure of the platform file // (deployah.platform.yaml). It is platform-owned and not subject to envsubst. type PlatformConfig struct { - // APIVersion is the platform schema version, e.g. "platform/v1-alpha.2". + // APIVersion is the platform schema version, e.g. "platform/v1-alpha.3". APIVersion string `json:"apiVersion" yaml:"apiVersion"` // Profiles maps logical profile names to deployment policy. Profiles are // org-wide (root-level), not per-environment. A profile named "default" is @@ -77,6 +77,37 @@ type PlatformProfile struct { AllowedDomains []string `json:"allowedDomains" yaml:"allowedDomains"` // MaxResources is a ceiling on component resource requests. MaxResources *ProfileMaxResources `json:"maxResources,omitempty" yaml:"maxResources,omitempty"` + // Metrics holds Prometheus Operator monitor defaults owned by the + // platform (discovery labels, scrape timing, relabelings). Nested under + // metrics so profile root stays free of scrape-specific field names. + Metrics *ProfileMetrics `json:"metrics,omitempty" yaml:"metrics,omitempty"` +} + +// ProfileMetrics is the platform-owned Prometheus scrape policy for a +// profile. When a component enables metrics, MonitorLabels must be set on +// the merged profile so Prometheus Operator can discover the monitor CR. +type ProfileMetrics struct { + // MonitorLabels are required discovery labels for ServiceMonitor and + // PodMonitor resources. Typically matches the Prometheus Operator + // release selector (e.g. release: kube-prometheus-stack). + MonitorLabels map[string]string `json:"monitorLabels,omitempty" yaml:"monitorLabels,omitempty"` + // MonitorNamespace is the namespace where the monitor CR is created. + // Empty means the app namespace. + MonitorNamespace string `json:"monitorNamespace,omitempty" yaml:"monitorNamespace,omitempty"` + // Interval is the default scrape interval (e.g. "30s"). + Interval string `json:"interval,omitempty" yaml:"interval,omitempty"` + // ScrapeTimeout is the default scrape timeout (e.g. "10s"). + ScrapeTimeout string `json:"scrapeTimeout,omitempty" yaml:"scrapeTimeout,omitempty"` + // JobLabel is the ServiceMonitor/PodMonitor jobLabel field. + JobLabel string `json:"jobLabel,omitempty" yaml:"jobLabel,omitempty"` + // HonorLabels maps to honorLabels on monitor endpoints. + HonorLabels *bool `json:"honorLabels,omitempty" yaml:"honorLabels,omitempty"` + // Annotations are applied to the monitor CR. + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` + // Relabelings are Prometheus relabel configs on the scrape endpoint. + Relabelings []any `json:"relabelings,omitempty" yaml:"relabelings,omitempty"` + // MetricRelabelings are Prometheus metricRelabelings on the scrape endpoint. + MetricRelabelings []any `json:"metricRelabelings,omitempty" yaml:"metricRelabelings,omitempty"` } // PVCRetentionPolicy controls StatefulSet persistentVolumeClaimRetentionPolicy. diff --git a/internal/spec/platform_loader.go b/internal/spec/platform_loader.go index d82e736..6ee3f5f 100644 --- a/internal/spec/platform_loader.go +++ b/internal/spec/platform_loader.go @@ -32,13 +32,13 @@ import ( // SupportedPlatformVersions lists platform schema versions that are // compatible with the current manifest API. -var SupportedPlatformVersions = []string{"platform/v1-alpha.2"} +var SupportedPlatformVersions = []string{"platform/v1-alpha.3"} // CurrentPlatformVersion is the platform apiVersion written by scaffold // helpers (init, cluster up). It is always the last entry in // SupportedPlatformVersions. Bump SupportedPlatformVersions first, then this // constant follows automatically at compile time. -const CurrentPlatformVersion = "platform/v1-alpha.2" +const CurrentPlatformVersion = "platform/v1-alpha.3" // LoadPlatform reads and validates the platform configuration file at path. // The file is never subject to envsubst. LoadPlatform performs: @@ -209,7 +209,7 @@ func validatePlatformTLS(tls *PlatformTLS, envKey, domainKey string) error { } // IsSupportedPlatformVersion reports whether the given platform apiVersion -// (e.g. "platform/v1-alpha.2") is supported by the current version of +// (e.g. "platform/v1-alpha.3") is supported by the current version of // Deployah. func IsSupportedPlatformVersion(apiVersion string) bool { return slices.Contains(SupportedPlatformVersions, apiVersion) diff --git a/internal/spec/platform_test.go b/internal/spec/platform_test.go index 89feb18..8e8d803 100644 --- a/internal/spec/platform_test.go +++ b/internal/spec/platform_test.go @@ -41,7 +41,7 @@ func writeTempFile(t *testing.T, content string) string { // TestLoadPlatform_Valid verifies platform spec behavior. func TestLoadPlatform_Valid(t *testing.T) { yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 environments: production: context: prod-eks @@ -63,7 +63,7 @@ environments: p, err := spec.LoadPlatform(path) require.NoError(t, err) require.NotNil(t, p) - assert.Equal(t, "platform/v1-alpha.2", p.APIVersion) + assert.Equal(t, "platform/v1-alpha.3", p.APIVersion) assert.Len(t, p.Environments, 2) prod := p.Environments["production"] assert.Equal(t, "prod-eks", prod.Context) @@ -93,7 +93,7 @@ environments: // TestLoadPlatform_CertManagerMissingIssuer verifies platform spec behavior. func TestLoadPlatform_CertManagerMissingIssuer(t *testing.T) { yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 environments: prod: domains: @@ -111,7 +111,7 @@ environments: // TestLoadPlatform_SecretNameMissingField verifies platform spec behavior. func TestLoadPlatform_SecretNameMissingField(t *testing.T) { yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 environments: prod: domains: @@ -180,7 +180,7 @@ func TestNormalizeEnv_Wildcard(t *testing.T) { func minimalPlatform() *spec.PlatformConfig { return &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "production": { Context: "prod-eks", @@ -209,7 +209,7 @@ func minimalPlatform() *spec.PlatformConfig { func minimalSpec(subdomain *string) *spec.Spec { return &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -245,7 +245,7 @@ func TestResolve_FQDN(t *testing.T) { // the platform registry warn, while prefix-style entries stay warning-free. func TestResolve_UnknownEnvironmentNameWarnings(t *testing.T) { appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -366,7 +366,7 @@ func TestResolve_DomainGapError(t *testing.T) { appSpec := minimalSpec(new("api")) // staging has no domains defined. platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "staging": {Context: "staging-eks"}, }, @@ -382,7 +382,7 @@ func TestResolve_DomainGapError(t *testing.T) { func TestResolve_FQDNCollision(t *testing.T) { // Two components resolving to the same FQDN (apex on same domain). appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -403,7 +403,7 @@ func TestResolve_FQDNCollision(t *testing.T) { func TestResolve_WildcardStaticSubdomainWarning(t *testing.T) { // review/pr-123 matches the "review" wildcard key; static subdomain warns. platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "review": { Context: "staging-eks", @@ -417,7 +417,7 @@ func TestResolve_WildcardStaticSubdomainWarning(t *testing.T) { }, } appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "review": {}, @@ -442,7 +442,7 @@ func TestResolve_WildcardStaticSubdomainWarning(t *testing.T) { func TestResolve_WildcardDynamicSubdomainNoWarning(t *testing.T) { // Subdomain came from envsubst => no warning even for wildcard env. platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "review": { Context: "staging-eks", @@ -456,7 +456,7 @@ func TestResolve_WildcardDynamicSubdomainNoWarning(t *testing.T) { }, } appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "review": {}, @@ -477,7 +477,7 @@ func TestResolve_WildcardDynamicSubdomainNoWarning(t *testing.T) { func TestResolve_PlatformEnvNotFound(t *testing.T) { appSpec := minimalSpec(new("api")) platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{}, } env := spec.NormalizeEnv("production") @@ -506,7 +506,7 @@ func TestSentinelSubstituteRaw_LiteralPassthrough(t *testing.T) { func TestLoadPlatform_RejectsTwoDefaultDomains(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "deployah.platform.yaml") - doc := `apiVersion: platform/v1-alpha.2 + doc := `apiVersion: platform/v1-alpha.3 environments: production: context: prod @@ -583,7 +583,7 @@ func TestScaffoldPlatformFile_NoEnvironmentsWritesNothing(t *testing.T) { func TestScaffoldPlatformFile_DoesNotOverwriteExisting(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "deployah.platform.yaml") - require.NoError(t, os.WriteFile(path, []byte("apiVersion: platform/v1-alpha.2\nenvironments:\n prod:\n context: prod\n"), 0o600)) + require.NoError(t, os.WriteFile(path, []byte("apiVersion: platform/v1-alpha.3\nenvironments:\n prod:\n context: prod\n"), 0o600)) created, err := spec.ScaffoldPlatformFile(path, "127.0.0.1", []string{"local"}) require.NoError(t, err) @@ -632,7 +632,7 @@ func TestPlatformEnvContext(t *testing.T) { t.Parallel() reviewPlatform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "review": {Context: "staging-eks"}, }, @@ -693,7 +693,7 @@ func TestResolve_ErrorCode_PlatformNotFound(t *testing.T) { func TestResolve_ErrorCode_PlatformEnvNotFound(t *testing.T) { appSpec := minimalSpec(new("api")) platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "staging": {Context: "staging-eks"}, }, @@ -710,7 +710,7 @@ func TestResolve_ErrorCode_DomainGap(t *testing.T) { appSpec := minimalSpec(new("api")) // Platform has the env but not the domain referenced by the component. platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "production": {Context: "prod-eks", Domains: map[string]spec.PlatformDomain{}}, }, @@ -726,7 +726,7 @@ func TestResolve_ErrorCode_DomainGap(t *testing.T) { func TestResolve_ErrorCode_InvalidDNS(t *testing.T) { // Subdomain with invalid characters (not dynamic). appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -746,7 +746,7 @@ func TestResolve_ErrorCode_InvalidDNS(t *testing.T) { // TestResolve_ErrorCode_FQDNCollision verifies platform spec behavior. func TestResolve_ErrorCode_FQDNCollision(t *testing.T) { appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "production": {}, @@ -769,7 +769,7 @@ func TestResolve_DynamicSubdomainSkipsDNSValidation(t *testing.T) { // Subdomain contains ${PR_NUMBER} which is not a valid DNS label, but // the prescan marks it as dynamic so resolution should succeed. appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "review": {}, @@ -779,7 +779,7 @@ func TestResolve_DynamicSubdomainSkipsDNSValidation(t *testing.T) { }, } platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "review": { Context: "staging-eks", @@ -800,7 +800,7 @@ func TestResolve_DynamicSubdomainSkipsDNSValidation(t *testing.T) { func TestResolve_StaticInvalidSubdomainFailsDNS(t *testing.T) { // Same invalid subdomain but NOT marked as dynamic: should fail. appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{ "review": {}, @@ -810,7 +810,7 @@ func TestResolve_StaticInvalidSubdomainFailsDNS(t *testing.T) { }, } platform := &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.2", + APIVersion: "platform/v1-alpha.3", Environments: map[string]spec.PlatformEnvironment{ "review": { Context: "staging-eks", @@ -869,7 +869,7 @@ func platformWithProfiles() *spec.PlatformConfig { func TestLoadPlatform_WithProfiles(t *testing.T) { t.Parallel() yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 profiles: default: nodeSelector: @@ -907,7 +907,7 @@ environments: func TestLoadPlatform_ProfileUnknownDomainRef(t *testing.T) { t.Parallel() yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 profiles: public-web: allowedDomains: [missing] @@ -930,7 +930,7 @@ environments: func TestLoadPlatform_ProfileUnknownStorageClassRef(t *testing.T) { t.Parallel() yaml := ` -apiVersion: platform/v1-alpha.2 +apiVersion: platform/v1-alpha.3 profiles: gpu: storageClass: missing @@ -1032,7 +1032,7 @@ func TestResolve_Profiles(t *testing.T) { { name: "domain ignored without expose", appSpec: &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"production": {}}, Components: map[string]spec.Component{ @@ -1049,7 +1049,7 @@ func TestResolve_Profiles(t *testing.T) { { name: "storage class missing in environment", appSpec: &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"local": {}}, Components: map[string]spec.Component{ @@ -1063,7 +1063,7 @@ func TestResolve_Profiles(t *testing.T) { { name: "storage class resolved to className", appSpec: &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"production": {}}, Components: map[string]spec.Component{ @@ -1204,7 +1204,7 @@ func TestResolve_ComponentStorageClass(t *testing.T) { t.Run("component key wins over profile", func(t *testing.T) { t.Parallel() appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"production": {}}, Components: map[string]spec.Component{ @@ -1234,7 +1234,7 @@ func TestResolve_ComponentStorageClass(t *testing.T) { t.Run("unknown component key errors", func(t *testing.T) { t.Parallel() appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"production": {}}, Components: map[string]spec.Component{ @@ -1257,7 +1257,7 @@ func TestResolve_ComponentStorageClass(t *testing.T) { t.Parallel() p := minimalPlatform() appSpec := &spec.Spec{ - APIVersion: "v1-alpha.3", + APIVersion: "v1-alpha.4", Project: "shop", Environments: map[string]spec.Environment{"local": {}}, Components: map[string]spec.Component{ diff --git a/internal/spec/profile.go b/internal/spec/profile.go index 669ce72..4f41f6b 100644 --- a/internal/spec/profile.go +++ b/internal/spec/profile.go @@ -82,11 +82,15 @@ func ResolveProfileNames(componentProfiles []string, platformProfiles map[string // MergeProfiles looks up names in profiles and merges them left to right. // // Merge rules: -// - maps (nodeSelector, podLabels, podAnnotations): deep merge, last wins +// - maps (nodeSelector, podLabels, podAnnotations, metrics.monitorLabels, +// metrics.annotations): deep merge, last wins // - security contexts: field overlay; non-nil overlay pointers win, including // false *bool values that mergo would skip // - arrays (tolerations): concatenate and deduplicate identical entries -// - scalars (storageClass): last non-empty wins +// - arrays (metrics.relabelings, metrics.metricRelabelings): concatenate +// - scalars (storageClass, metrics.monitorNamespace, metrics.jobLabel, +// metrics.interval, metrics.scrapeTimeout): last non-empty wins +// - metrics.honorLabels: last non-nil wins // - pvcRetentionPolicy: last non-nil wins (field overlay within the policy) // - allowedDomains: intersection of explicit lists; omitted means no constraint // - maxResources: minimum (strictest) ceiling per resource @@ -131,11 +135,57 @@ func MergeProfiles(names []string, profiles map[string]PlatformProfile) (Platfor } } merged.MaxResources = mergeMaxResources(merged.MaxResources, p.MaxResources) + merged.Metrics = mergeProfileMetrics(merged.Metrics, p.Metrics) } return merged, nil } +// mergeProfileMetrics overlays overlay onto base. Maps deep-merge, scalars +// use last-non-empty, bools last-non-nil, and relabel lists concatenate. +func mergeProfileMetrics(base, overlay *ProfileMetrics) *ProfileMetrics { + if overlay == nil { + return base + } + out := &ProfileMetrics{} + if base != nil { + *out = *base + out.MonitorLabels = maps.Clone(base.MonitorLabels) + out.Annotations = maps.Clone(base.Annotations) + out.Relabelings = slices.Clone(base.Relabelings) + out.MetricRelabelings = slices.Clone(base.MetricRelabelings) + if base.HonorLabels != nil { + v := *base.HonorLabels + out.HonorLabels = &v + } + } + out.MonitorLabels = mergeStringMap(out.MonitorLabels, overlay.MonitorLabels) + out.Annotations = mergeStringMap(out.Annotations, overlay.Annotations) + if overlay.MonitorNamespace != "" { + out.MonitorNamespace = overlay.MonitorNamespace + } + if overlay.Interval != "" { + out.Interval = overlay.Interval + } + if overlay.ScrapeTimeout != "" { + out.ScrapeTimeout = overlay.ScrapeTimeout + } + if overlay.JobLabel != "" { + out.JobLabel = overlay.JobLabel + } + if overlay.HonorLabels != nil { + v := *overlay.HonorLabels + out.HonorLabels = &v + } + if len(overlay.Relabelings) > 0 { + out.Relabelings = append(out.Relabelings, overlay.Relabelings...) + } + if len(overlay.MetricRelabelings) > 0 { + out.MetricRelabelings = append(out.MetricRelabelings, overlay.MetricRelabelings...) + } + return out +} + // mergePVCRetentionPolicy overlays overlay onto base. Non-empty overlay fields // win; nil overlay returns base unchanged. func mergePVCRetentionPolicy(base, overlay *PVCRetentionPolicy) *PVCRetentionPolicy { @@ -207,6 +257,17 @@ func ValidateProfileAgainstComponent( } } + if comp.Metrics.IsEnabled() && (merged.Metrics == nil || len(merged.Metrics.MonitorLabels) == 0) { + return &ResolutionError{ + Code: ErrCodeProfileMonitorLabelsMissing, + Message: fmt.Sprintf( + "component %q has metrics enabled but the merged profile has no metrics.monitorLabels; "+ + "set metrics.monitorLabels on a platform profile so Prometheus Operator can discover the monitor", + compName, + ), + } + } + return nil } diff --git a/internal/spec/profile_test.go b/internal/spec/profile_test.go index 213f20d..3c051d7 100644 --- a/internal/spec/profile_test.go +++ b/internal/spec/profile_test.go @@ -473,3 +473,55 @@ func TestValidateProfileAgainstComponent(t *testing.T) { require.NoError(t, err) }) } + +func TestMergeProfiles_MonitorFields(t *testing.T) { + t.Parallel() + honorTrue := true + honorFalse := false + profiles := map[string]spec.PlatformProfile{ + "a": { + Metrics: &spec.ProfileMetrics{ + MonitorLabels: map[string]string{"release": "a"}, + MonitorNamespace: "monitoring", + Interval: "30s", + Relabelings: []any{map[string]any{"action": "keep"}}, + Annotations: map[string]string{"team": "platform"}, + HonorLabels: &honorFalse, + }, + }, + "b": { + Metrics: &spec.ProfileMetrics{ + MonitorLabels: map[string]string{"release": "b", "team": "obs"}, + Interval: "15s", + JobLabel: "app", + HonorLabels: &honorTrue, + MetricRelabelings: []any{map[string]any{"action": "drop"}}, + Annotations: map[string]string{"owner": "sre"}, + }, + }, + } + merged, err := spec.MergeProfiles([]string{"a", "b"}, profiles) + require.NoError(t, err) + require.NotNil(t, merged.Metrics) + assert.Equal(t, map[string]string{"release": "b", "team": "obs"}, merged.Metrics.MonitorLabels) + assert.Equal(t, "monitoring", merged.Metrics.MonitorNamespace) + assert.Equal(t, "15s", merged.Metrics.Interval) + assert.Equal(t, "app", merged.Metrics.JobLabel) + require.NotNil(t, merged.Metrics.HonorLabels) + assert.True(t, *merged.Metrics.HonorLabels) + assert.Len(t, merged.Metrics.Relabelings, 1) + assert.Len(t, merged.Metrics.MetricRelabelings, 1) + assert.Equal(t, map[string]string{"team": "platform", "owner": "sre"}, merged.Metrics.Annotations) +} + +func TestValidateProfileAgainstComponent_MonitorLabelsRequired(t *testing.T) { + t.Parallel() + comp := spec.Component{ + Metrics: &spec.ComponentMetrics{Port: 9090}, + } + err := spec.ValidateProfileAgainstComponent("worker", comp, spec.PlatformProfile{}, nil, "") + require.Error(t, err) + var re *spec.ResolutionError + require.ErrorAs(t, err, &re) + assert.Equal(t, spec.ErrCodeProfileMonitorLabelsMissing, re.Code) +} diff --git a/internal/spec/resolve.go b/internal/spec/resolve.go index e1340a9..7e1674f 100644 --- a/internal/spec/resolve.go +++ b/internal/spec/resolve.go @@ -191,10 +191,8 @@ func resolveComponent( } if comp.Expose == nil { - if rc.MergedProfile != nil { - if profileErr := ValidateProfileAgainstComponent(name, comp, *rc.MergedProfile, platformEnv, ""); profileErr != nil { - return rc, result, profileErr - } + if profileErr := validateMergedProfile(name, comp, rc.MergedProfile, platformEnv, ""); profileErr != nil { + return rc, result, profileErr } if scErr := applyResolvedStorageClass(&rc, &result, name, comp, env, platformEnv); scErr != nil { return rc, result, scErr @@ -367,10 +365,8 @@ func resolveComponent( } } - if rc.MergedProfile != nil { - if profileErr := ValidateProfileAgainstComponent(name, comp, *rc.MergedProfile, platformEnv, domainKey); profileErr != nil { - return rc, result, profileErr - } + if profileErr := validateMergedProfile(name, comp, rc.MergedProfile, platformEnv, domainKey); profileErr != nil { + return rc, result, profileErr } if scErr := applyResolvedStorageClass(&rc, &result, name, comp, env, platformEnv); scErr != nil { return rc, result, scErr @@ -379,6 +375,32 @@ func resolveComponent( return rc, result, nil } +// validateMergedProfile runs profile constraints when a merged profile exists, +// and always enforces monitorLabels when metrics are enabled (even with no +// profile, which is an error). +func validateMergedProfile( + name string, + comp Component, + merged *PlatformProfile, + platformEnv *PlatformEnvironment, + domainKey string, +) error { + if merged != nil { + return ValidateProfileAgainstComponent(name, comp, *merged, platformEnv, domainKey) + } + if comp.Metrics.IsEnabled() { + return &ResolutionError{ + Code: ErrCodeProfileMonitorLabelsMissing, + Message: fmt.Sprintf( + "component %q has metrics enabled but the merged profile has no metrics.monitorLabels; "+ + "set metrics.monitorLabels on a platform profile so Prometheus Operator can discover the monitor", + name, + ), + } + } + return nil +} + // applyResolvedStorageClass resolves the Kubernetes storage class name. // Component persistence.storageClass wins over the merged profile's // storageClass. An explicit key with no env map or unknown key is an error. diff --git a/internal/spec/resolved_spec.go b/internal/spec/resolved_spec.go index 2e625ee..f0fc1bd 100644 --- a/internal/spec/resolved_spec.go +++ b/internal/spec/resolved_spec.go @@ -119,6 +119,7 @@ const ( ErrCodeComponentStorageClassNotFound = "COMPONENT_STORAGE_CLASS_NOT_FOUND" ErrCodeProfileResourceExceeded = "PROFILE_RESOURCE_EXCEEDED" ErrCodeProfileOptOutBlocked = "PROFILE_OPT_OUT_BLOCKED" + ErrCodeProfileMonitorLabelsMissing = "PROFILE_MONITOR_LABELS_MISSING" ) // ResolutionError is a resolution error that carries a machine-readable code. diff --git a/internal/spec/schema/platform/v1-alpha.2/platform.json b/internal/spec/schema/platform/v1-alpha.3/platform.json similarity index 79% rename from internal/spec/schema/platform/v1-alpha.2/platform.json rename to internal/spec/schema/platform/v1-alpha.3/platform.json index 5d583b3..81516c2 100644 --- a/internal/spec/schema/platform/v1-alpha.2/platform.json +++ b/internal/spec/schema/platform/v1-alpha.3/platform.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/platform/v1-alpha.2/platform.json", + "$id": "https://deployah.dev/schemas/platform/v1-alpha.3/platform.json", "title": "Deployah Platform Config", - "description": "Platform-owned configuration file (deployah.platform.yaml). Defines Kubernetes contexts, domain bindings, storage classes per environment, and org-wide deployment profiles. Not subject to envsubst.", + "description": "Platform-owned configuration file (deployah.platform.yaml). Defines Kubernetes contexts, domain bindings, storage classes per environment, and org-wide deployment profiles including Prometheus monitor settings. Not subject to envsubst.", "type": "object", "additionalProperties": false, "required": ["apiVersion", "environments"], @@ -10,8 +10,8 @@ "apiVersion": { "type": "string", "title": "API Version", - "description": "Platform schema version. Must be 'platform/v1-alpha.2'.", - "const": "platform/v1-alpha.2" + "description": "Platform schema version. Must be 'platform/v1-alpha.3'.", + "const": "platform/v1-alpha.3" }, "profiles": { "type": "object", @@ -278,6 +278,9 @@ }, "maxResources": { "$ref": "#/$defs/ProfileMaxResources" + }, + "metrics": { + "$ref": "#/$defs/ProfileMetrics" } }, "examples": [ @@ -293,9 +296,93 @@ "allowPrivilegeEscalation": false }, "maxResources": {"cpu": "1000m", "memory": "2Gi"} + }, + { + "metrics": { + "monitorLabels": {"release": "kube-prometheus-stack"}, + "interval": "30s", + "scrapeTimeout": "10s" + } } ] }, + "ProfileMetrics": { + "type": "object", + "title": "Profile Metrics", + "description": "Platform-owned Prometheus Operator monitor defaults. Required monitorLabels when components enable metrics.", + "additionalProperties": false, + "properties": { + "monitorLabels": { + "type": "object", + "title": "Monitor Labels", + "description": "Discovery labels applied to ServiceMonitor and PodMonitor resources. Required when a component enables metrics. Typically matches the Prometheus Operator serviceMonitorSelector (e.g. release: kube-prometheus-stack).", + "additionalProperties": { + "type": "string" + }, + "examples": [{"release": "kube-prometheus-stack"}] + }, + "monitorNamespace": { + "type": "string", + "title": "Monitor Namespace", + "description": "Namespace where the ServiceMonitor or PodMonitor is created. Empty means the application namespace.", + "minLength": 1, + "examples": ["monitoring"] + }, + "interval": { + "type": "string", + "title": "Scrape Interval", + "description": "Default Prometheus scrape interval for monitors created from this profile.", + "pattern": "^[1-9][0-9]*(s|m|h)$", + "examples": ["30s", "1m"] + }, + "scrapeTimeout": { + "type": "string", + "title": "Scrape Timeout", + "description": "Default Prometheus scrape timeout for monitors created from this profile.", + "pattern": "^[1-9][0-9]*(s|m|h)$", + "examples": ["10s"] + }, + "jobLabel": { + "type": "string", + "title": "Job Label", + "description": "ServiceMonitor/PodMonitor jobLabel field.", + "minLength": 1, + "examples": ["app.kubernetes.io/name"] + }, + "honorLabels": { + "type": "boolean", + "title": "Honor Labels", + "description": "When true, prefer metric labels over target labels during scrape.", + "default": false + }, + "annotations": { + "type": "object", + "title": "Monitor Annotations", + "description": "Annotations applied to ServiceMonitor and PodMonitor resources.", + "additionalProperties": { + "type": "string" + } + }, + "relabelings": { + "type": "array", + "title": "Relabelings", + "description": "Prometheus relabel_configs applied to scrape endpoints.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "metricRelabelings": { + "type": "array", + "title": "Metric Relabelings", + "description": "Prometheus metric_relabel_configs applied to scrape endpoints.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, "PlatformToleration": { "type": "object", "title": "Toleration", @@ -373,7 +460,7 @@ }, "examples": [ { - "apiVersion": "platform/v1-alpha.2", + "apiVersion": "platform/v1-alpha.3", "profiles": { "default": { "nodeSelector": {"workload": "general"} diff --git a/internal/spec/schema/schema.go b/internal/spec/schema/schema.go index 482fb28..6eb9e07 100644 --- a/internal/spec/schema/schema.go +++ b/internal/spec/schema/schema.go @@ -38,7 +38,7 @@ const ( const platformSchemaDir = "platform" var ( - // versionRegex matches version strings such as "v1-alpha.3", "v1-beta.2", + // versionRegex matches version strings such as "v1-alpha.4", "v1-beta.2", // and similar pre-release formats. versionRegex = regexp.MustCompile(`^v(\d+)(?:-(alpha|beta|rc)\.(\d+))?$`) // preReleaseOrder is a map that defines the order of pre-release types. @@ -47,7 +47,7 @@ var ( // GetManifestSchema retrieves the JSON schema for validating manifests at a // specific version. -// Version strings should follow the format "v1-alpha.3", "v1-beta.2", etc. +// Version strings should follow the format "v1-alpha.4", "v1-beta.2", etc. // The schema file must be named "manifest.json" within the version directory. func GetManifestSchema(version string) ([]byte, error) { fileName := version + "/manifest.json" @@ -58,7 +58,7 @@ func GetManifestSchema(version string) ([]byte, error) { } // GetEnvironmentsSchema returns the environments schema for the given version. -// Version strings should follow the format "v1-alpha.3", "v1-beta.2", etc. +// Version strings should follow the format "v1-alpha.4", "v1-beta.2", etc. // The schema file must be named "environments.json" within the version directory. func GetEnvironmentsSchema(version string) ([]byte, error) { fileName := version + "/environments.json" @@ -97,7 +97,7 @@ func GetManifestSchemas() (map[string][]byte, error) { // GetPlatformSchema retrieves the JSON schema for validating platform configs // at a specific version. Version strings should follow the format -// "v1-alpha.3", "v1-beta.2", etc. The schema file must be named +// "v1-alpha.4", "v1-beta.2", etc. The schema file must be named // "platform.json" within the platform/VERSION directory. func GetPlatformSchema(version string) ([]byte, error) { fileName := platformSchemaDir + "/" + version + "/" + SchemaTypePlatform.String() + ".json" diff --git a/internal/spec/schema/schema_test.go b/internal/spec/schema/schema_test.go index b215757..4dd789b 100644 --- a/internal/spec/schema/schema_test.go +++ b/internal/spec/schema/schema_test.go @@ -16,7 +16,7 @@ type SchemaTestSuite struct { // TestGetManifestSchema verifies manifest schema retrieval for a version. func (s *SchemaTestSuite) TestGetManifestSchema() { - schema, err := GetManifestSchema("v1-alpha.3") + schema, err := GetManifestSchema("v1-alpha.4") s.Require().NoError(err) s.Require().NotNil(schema) } @@ -32,7 +32,7 @@ func (s *SchemaTestSuite) TestGetManifestSchema_InvalidVersion() { // TestGetEnvironmentsSchema verifies environments schema retrieval for a // version. func (s *SchemaTestSuite) TestGetEnvironmentsSchema() { - schema, err := GetEnvironmentsSchema("v1-alpha.3") + schema, err := GetEnvironmentsSchema("v1-alpha.4") s.Require().NoError(err) s.Require().NotNil(schema) } diff --git a/internal/spec/schema/v1-alpha.3/environments.json b/internal/spec/schema/v1-alpha.4/environments.json similarity index 94% rename from internal/spec/schema/v1-alpha.3/environments.json rename to internal/spec/schema/v1-alpha.4/environments.json index fe39c17..821cc5f 100644 --- a/internal/spec/schema/v1-alpha.3/environments.json +++ b/internal/spec/schema/v1-alpha.4/environments.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/v1-alpha.3/environments.json", - "title": "Deployah Environments v1-alpha.3", - "description": "Schema for the environments section of the v1-alpha.3 manifest. The section is optional: which environments exist is owned by the platform file; an entry here only adds developer overrides (envFile, variables) for that environment.", + "$id": "https://deployah.dev/schemas/v1-alpha.4/environments.json", + "title": "Deployah Environments v1-alpha.4", + "description": "Schema for the environments section of the v1-alpha.4 manifest. The section is optional: which environments exist is owned by the platform file; an entry here only adds developer overrides (envFile, variables) for that environment.", "type": "object", "additionalProperties": true, "properties": { diff --git a/internal/spec/schema/v1-alpha.3/manifest.json b/internal/spec/schema/v1-alpha.4/manifest.json similarity index 80% rename from internal/spec/schema/v1-alpha.3/manifest.json rename to internal/spec/schema/v1-alpha.4/manifest.json index 8678698..3765f5a 100644 --- a/internal/spec/schema/v1-alpha.3/manifest.json +++ b/internal/spec/schema/v1-alpha.4/manifest.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/v1-alpha.3/manifest.json", - "title": "Deployah Spec v1-alpha.3", - "description": "Deployah developer manifest (deployah.yaml). Environments are a map, context is platform-owned, expose replaces ingress.", + "$id": "https://deployah.dev/schemas/v1-alpha.4/manifest.json", + "title": "Deployah Spec v1-alpha.4", + "description": "Deployah developer manifest (deployah.yaml). Environments are a map, context is platform-owned, expose replaces ingress. Supports role: worker and Prometheus metrics.", "type": "object", "additionalProperties": false, "required": ["apiVersion", "project", "components"], @@ -10,8 +10,8 @@ "apiVersion": { "type": "string", "title": "API Version", - "description": "Schema version. Must be 'v1-alpha.3'.", - "const": "v1-alpha.3" + "description": "Schema version. Must be 'v1-alpha.4'.", + "const": "v1-alpha.4" }, "project": { "type": "string", @@ -150,12 +150,27 @@ "port": { "type": "integer", "title": "Port", - "description": "Primary container port for service components.", - "default": 8080, + "description": "Primary container port for service components. Not allowed on role: worker. Defaults to 8080 for services when omitted.", "minimum": 1, "maximum": 65535, "examples": [8181, 9000] }, + "shutdownTimeout": { + "type": "string", + "title": "Shutdown Timeout", + "description": "How long Kubernetes waits after SIGTERM before force-killing the pod (terminationGracePeriodSeconds). Defaults to 30s for services and 60s for workers.", + "pattern": "^[1-9][0-9]*(s|m|h)$", + "examples": ["30s", "60s", "2m"] + }, + "metrics": { + "title": "Metrics", + "description": "Prometheus scraping configuration. Boolean shorthand or object. Services emit a ServiceMonitor; workers emit a PodMonitor. Workers require metrics.port when enabled. Requires prometheus-operator CRDs and platform profile metrics.monitorLabels.", + "anyOf": [ + {"type": "boolean"}, + {"$ref": "#/$defs/Metrics"} + ], + "examples": [true, false, {"port": 9090, "path": "/metrics"}] + }, "replicas": { "type": "integer", "title": "Replicas", @@ -414,10 +429,59 @@ {"ephemeralStorage": "2Gi"} ] }, + "Metrics": { + "type": "object", + "title": "Metrics Configuration", + "description": "Prometheus scrape settings. enabled defaults to true when omitted. port is required for workers; for services it defaults to the component port.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "When false, disables scraping (useful for per-environment toggle via envsubst). Defaults to true when omitted.", + "default": true + }, + "port": { + "type": "integer", + "title": "Metrics Port", + "description": "Container port exposing metrics. Required for workers. Defaults to the component port for services.", + "minimum": 1, + "maximum": 65535, + "examples": [8080, 9090] + }, + "path": { + "type": "string", + "title": "Metrics Path", + "description": "HTTP path for metrics. Defaults to /metrics.", + "pattern": "^/", + "default": "/metrics", + "examples": ["/metrics", "/actuator/prometheus"] + }, + "interval": { + "type": "string", + "title": "Scrape Interval", + "description": "Override the platform profile scrape interval.", + "pattern": "^[1-9][0-9]*(s|m|h)$", + "examples": ["15s", "30s", "1m"] + }, + "scrapeTimeout": { + "type": "string", + "title": "Scrape Timeout", + "description": "Override the platform profile scrape timeout.", + "pattern": "^[1-9][0-9]*(s|m|h)$", + "examples": ["5s", "10s"] + } + }, + "examples": [ + {"port": 9090}, + {"port": 8080, "path": "/metrics", "interval": "30s"}, + {"enabled": false} + ] + }, "Health": { "type": "object", "title": "Health Checks", - "description": "HTTP health check configuration for service components.", + "description": "Health check configuration. Services support TCP/HTTP ready and alive checks. Workers support optional alive.exec only.", "additionalProperties": false, "properties": { "ready": { @@ -447,13 +511,20 @@ { "type": "object", "additionalProperties": false, - "required": ["path"], "properties": { "path": { "type": "string", "pattern": "^/", "examples": ["/livez", "/healthz", "/alive"] }, + "exec": { + "type": "array", + "title": "Exec Command", + "description": "Command run inside the container; exit 0 means alive. Mutually exclusive with path. Available for service and worker roles.", + "minItems": 1, + "items": {"type": "string"}, + "examples": [["pgrep", "-f", "worker"], ["sh", "-c", "test -f /tmp/healthy"]] + }, "interval": { "type": "string", "pattern": "^[1-9][0-9]*(s|m|h)$", @@ -466,12 +537,17 @@ "default": "60s", "examples": ["60s", "2m", "5m"] } - } + }, + "oneOf": [ + {"required": ["path"]}, + {"required": ["exec"]} + ] } ], "examples": [ {"path": "/livez"}, {"path": "/livez", "interval": "10s", "restartAfter": "60s"}, + {"exec": ["pgrep", "-f", "worker"]}, false ] } @@ -479,13 +555,14 @@ "examples": [ {"ready": {"path": "/health"}}, {"ready": {"path": "/health"}, "alive": {"path": "/livez"}}, + {"alive": {"exec": ["pgrep", "-f", "worker"]}}, {"ready": false, "alive": false} ] } }, "examples": [ { - "apiVersion": "v1-alpha.3", + "apiVersion": "v1-alpha.4", "project": "shop", "environments": { "production": { @@ -500,7 +577,14 @@ "expose": { "domain": "public", "subdomain": "api" - } + }, + "metrics": true + }, + "worker": { + "image": "my-worker:latest", + "role": "worker", + "command": ["./worker"], + "metrics": {"port": 9090} } } } diff --git a/internal/spec/types.go b/internal/spec/types.go index f735eb7..6e2e721 100644 --- a/internal/spec/types.go +++ b/internal/spec/types.go @@ -25,7 +25,7 @@ import ( // Spec defines the structure of the project spec. type Spec struct { - // APIVersion is the schema version of the spec (e.g., "v1-alpha.3"). + // APIVersion is the schema version of the spec (e.g., "v1-alpha.4"). APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` // Project is the project name. Project string `json:"project" yaml:"project"` @@ -108,6 +108,14 @@ type Component struct { Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` // Health configures ready and alive checks for the component. Health *Health `json:"health,omitempty" yaml:"health,omitempty"` + // ShutdownTimeout is how long Kubernetes waits after SIGTERM before + // force-killing the pod (maps to terminationGracePeriodSeconds). When + // empty, defaults are 30s for services and 60s for workers. + ShutdownTimeout string `json:"shutdownTimeout,omitempty" yaml:"shutdownTimeout,omitempty"` + // Metrics configures Prometheus scraping. Accepts true, false, or an + // object with port/path. Services emit a ServiceMonitor; workers emit a + // PodMonitor. Requires prometheus-operator CRDs on the cluster. + Metrics *ComponentMetrics `json:"metrics,omitempty" yaml:"metrics,omitempty"` } // Persistence configures volume storage for a component. @@ -128,14 +136,17 @@ func (c Component) ListensOnPort() bool { return c.Role.IsService() && c.Port > 0 } -// Health configures HTTP health checks for a service component. When omitted, -// TCP checks on the component port run automatically. +// Health configures health checks for a component. For services, when +// omitted, TCP checks on the component port run automatically. For workers, +// the default is process-exit only; optional alive.exec is supported. type Health struct { // Ready controls the readiness check. Provide a path to upgrade from TCP // to HTTP. Set to false to disable readiness and startup checks entirely. + // Not supported on workers. Ready *HealthReady `json:"ready,omitempty" yaml:"ready,omitempty"` - // Alive controls the alive check. Provide a path to upgrade from TCP to - // HTTP. Set to false to disable the alive check entirely. + // Alive controls the alive check. Provide a path (services) or exec + // command (any role) to upgrade from the default. Set to false to + // disable the alive check entirely. Path and exec are mutually exclusive. Alive *HealthAlive `json:"alive,omitempty" yaml:"alive,omitempty"` } @@ -177,18 +188,23 @@ func (r *HealthReady) UnmarshalJSON(data []byte) error { return nil } -// HealthAlive configures the alive check for a service component. It accepts -// either false (to disable) or an object with a path and optional timing. +// HealthAlive configures the alive check for a component. It accepts either +// false (to disable) or an object with a path or exec command and optional +// timing. // // When Alive is nil (field absent), a TCP alive check on the component port -// runs automatically. +// runs automatically for services. Workers default to process-exit only. type HealthAlive struct { // Disabled is true when the developer set alive: false. Disabled bool `json:"-" yaml:"-"` // Path is the HTTP endpoint that must return 2xx for the pod to be // considered alive. Must start with /. Check only internal process - // state here, not external dependencies. + // state here, not external dependencies. Mutually exclusive with Exec. + // Not supported on workers. Path string `json:"path,omitempty" yaml:"path,omitempty"` + // Exec is a command run inside the container; exit 0 means alive. + // Mutually exclusive with Path. Available for both service and worker. + Exec []string `json:"exec,omitempty" yaml:"exec,omitempty"` // Interval is how often to check the endpoint (e.g. "10s", "1m"). // Defaults to "10s" when omitted. Interval string `json:"interval,omitempty" yaml:"interval,omitempty"` @@ -203,6 +219,7 @@ type HealthAlive struct { // // alive: false -> HealthAlive{Disabled: true} // alive: {path: /livez, interval: 10s} -> HealthAlive{Path: "/livez", ...} +// alive: {exec: [sh, -c, pgrep worker]} -> HealthAlive{Exec: [...], ...} func (a *HealthAlive) UnmarshalJSON(data []byte) error { // Check for boolean false. var b bool @@ -211,19 +228,81 @@ func (a *HealthAlive) UnmarshalJSON(data []byte) error { a.Disabled = true return nil } - return fmt.Errorf("health.alive: true is not valid; omit the field to enable the default TCP check") + return fmt.Errorf("health.alive: true is not valid; omit the field to enable the default check") } // Unmarshal as object using an alias to avoid infinite recursion. type healthAliveAlias HealthAlive var alias healthAliveAlias if err := json.Unmarshal(data, &alias); err != nil { - return fmt.Errorf("health.alive: expected false or an object with a path field: %w", err) + return fmt.Errorf("health.alive: expected false or an object with path or exec: %w", err) } *a = HealthAlive(alias) return nil } +// ComponentMetrics configures Prometheus scraping for a component. It accepts +// true (enable with defaults), false (disable), or an object. +// +// metrics: true +// metrics: false +// metrics: {port: 9090, path: /metrics} +type ComponentMetrics struct { + // Disabled is true when the developer set metrics: false. + Disabled bool `json:"-" yaml:"-"` + // Enabled controls scraping when using the object form. Nil means true + // (omitted defaults to enabled). Set to false to disable per environment + // via envsubst. Ignored when Disabled is true. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Port is the container port exposing metrics. Required for workers when + // metrics are enabled. For services, defaults to the component port. + Port int `json:"port,omitempty" yaml:"port,omitempty"` + // Path is the HTTP path for metrics. Defaults to /metrics. + Path string `json:"path,omitempty" yaml:"path,omitempty"` + // Interval overrides the platform profile scrape interval. + Interval string `json:"interval,omitempty" yaml:"interval,omitempty"` + // ScrapeTimeout overrides the platform profile scrape timeout. + ScrapeTimeout string `json:"scrapeTimeout,omitempty" yaml:"scrapeTimeout,omitempty"` +} + +// UnmarshalJSON handles true, false, and object forms: +// +// metrics: true -> ComponentMetrics{} (enabled) +// metrics: false -> ComponentMetrics{Disabled: true} +// metrics: {port: 9090, path: /metrics} -> ComponentMetrics{Port: 9090, ...} +func (m *ComponentMetrics) UnmarshalJSON(data []byte) error { + var b bool + if err := json.Unmarshal(data, &b); err == nil { + if !b { + m.Disabled = true + return nil + } + // metrics: true -> zero value means enabled with defaults. + *m = ComponentMetrics{} + return nil + } + + type metricsAlias ComponentMetrics + var alias metricsAlias + if err := json.Unmarshal(data, &alias); err != nil { + return fmt.Errorf("metrics: expected true, false, or an object: %w", err) + } + *m = ComponentMetrics(alias) + return nil +} + +// IsEnabled reports whether metrics scraping should be configured for the +// component. False when Metrics is nil, metrics: false, or enabled: false. +func (m *ComponentMetrics) IsEnabled() bool { + if m == nil || m.Disabled { + return false + } + if m.Enabled != nil { + return *m.Enabled + } + return true +} + // Autoscaling defines the autoscaling settings for the component. type Autoscaling struct { Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` @@ -321,6 +400,11 @@ func (r ComponentRole) IsService() bool { return r == ComponentRoleService } +// IsWorker reports whether r is the "worker" role. +func (r ComponentRole) IsWorker() bool { + return r == ComponentRoleWorker +} + // ComponentKind specifies the kind of the component. type ComponentKind string diff --git a/internal/spec/validate.go b/internal/spec/validate.go index d7b209a..33ca301 100644 --- a/internal/spec/validate.go +++ b/internal/spec/validate.go @@ -87,7 +87,7 @@ func validateYAMLAgainstSchema( } // ValidateSpec validates spec YAML against the provided JSON schema. -// version should be the version of the schema (e.g., "v1-alpha.3"). +// version should be the version of the schema (e.g., "v1-alpha.4"). // This is a strict validation: unknown fields are not allowed. func ValidateSpec(specObj map[string]any, version string) error { return validateYAMLAgainstSchema( @@ -100,7 +100,7 @@ func ValidateSpec(specObj map[string]any, version string) error { // ValidateEnvironments validates environments YAML against the provided JSON // schema file. -// version should be the version of the schema (e.g., "v1-alpha.3"). +// version should be the version of the schema (e.g., "v1-alpha.4"). // This is a strict validation: unknown fields are not allowed. func ValidateEnvironments(specObj map[string]any, version string) error { return validateYAMLAgainstSchema( @@ -187,21 +187,45 @@ func ValidateComponentAutoscaling(component Component) error { return nil } +// ValidateComponentWorker rejects fields that workers must not set: port, +// expose, health.ready, and health.alive.path. Workers may use +// health.alive.exec and metrics with an explicit port. +func ValidateComponentWorker(component Component) error { + if !component.Role.IsWorker() { + return nil + } + if component.Port > 0 { + return fmt.Errorf("port is not supported on role: worker; use metrics.port for scrape endpoints") + } + if component.Expose != nil { + return fmt.Errorf("expose is not supported on role: worker") + } + if component.Health == nil { + return nil + } + if component.Health.Ready != nil && !component.Health.Ready.Disabled { + return fmt.Errorf("health.ready is not supported on role: worker") + } + if component.Health.Alive != nil && !component.Health.Alive.Disabled && + component.Health.Alive.Path != "" { + return fmt.Errorf("health.alive.path is not supported on role: worker; use health.alive.exec") + } + return nil +} + // ValidateComponentHealth validates the health check configuration of a -// component. Health checks are only supported for role: service components. +// component. Services support TCP/HTTP ready and alive checks. Workers +// support optional alive.exec only (see [ValidateComponentWorker]). func ValidateComponentHealth(component Component) error { if component.Health == nil { return nil } - // Role may still be "" here if this runs before defaults are filled in; - // the schema defaults it to service, so treat empty the same way. - if !component.Role.IsService() && component.Role != "" { - return fmt.Errorf("health checks are only supported for role: service components") + // Job role still rejects all health configuration. + if component.Role == ComponentRoleJob { + return fmt.Errorf("health checks are not supported for role: job components") } - // No port check here: only service roles reach this point, and the - // schema defaults their port to 8080 after validation. if component.Health.Ready != nil && !component.Health.Ready.Disabled { if component.Health.Ready.Path != "" && component.Health.Ready.Path[0] != '/' { return fmt.Errorf("health.ready.path must start with /") @@ -209,12 +233,25 @@ func ValidateComponentHealth(component Component) error { } if component.Health.Alive != nil && !component.Health.Alive.Disabled { - if component.Health.Alive.Path != "" && component.Health.Alive.Path[0] != '/' { + alive := component.Health.Alive + hasPath := alive.Path != "" + hasExec := len(alive.Exec) > 0 + if hasPath && hasExec { + return fmt.Errorf("health.alive.path and health.alive.exec are mutually exclusive") + } + if hasPath && alive.Path[0] != '/' { return fmt.Errorf("health.alive.path must start with /") } + if hasExec { + for i, part := range alive.Exec { + if strings.TrimSpace(part) == "" { + return fmt.Errorf("health.alive.exec[%d] must not be empty", i) + } + } + } - if component.Health.Alive.Interval != "" { - intervalSec, err := ParseDuration(component.Health.Alive.Interval) + if alive.Interval != "" { + intervalSec, err := ParseDuration(alive.Interval) if err != nil { return fmt.Errorf("health.alive.interval: %w", err) } @@ -223,8 +260,8 @@ func ValidateComponentHealth(component Component) error { } } - if component.Health.Alive.RestartAfter != "" { - restartSec, err := ParseDuration(component.Health.Alive.RestartAfter) + if alive.RestartAfter != "" { + restartSec, err := ParseDuration(alive.RestartAfter) if err != nil { return fmt.Errorf("health.alive.restartAfter: %w", err) } @@ -233,7 +270,7 @@ func ValidateComponentHealth(component Component) error { } // Validate that restartAfter >= interval so failureThreshold >= 1. - intervalStr := component.Health.Alive.Interval + intervalStr := alive.Interval if intervalStr == "" { intervalStr = DefaultLivenessInterval } @@ -243,7 +280,7 @@ func ValidateComponentHealth(component Component) error { } if restartSec < intervalSec { return fmt.Errorf("health.alive.restartAfter (%s) must be greater than or equal to health.alive.interval (%s)", - component.Health.Alive.RestartAfter, intervalStr) + alive.RestartAfter, intervalStr) } } } @@ -251,6 +288,52 @@ func ValidateComponentHealth(component Component) error { return nil } +// ValidateComponentMetrics validates Prometheus metrics configuration. +// Workers require an explicit metrics.port when metrics are enabled. +func ValidateComponentMetrics(component Component) error { + if component.Metrics == nil || !component.Metrics.IsEnabled() { + return nil + } + m := component.Metrics + if m.Path != "" && m.Path[0] != '/' { + return fmt.Errorf("metrics.path must start with /") + } + if m.Interval != "" { + if _, err := ParseDuration(m.Interval); err != nil { + return fmt.Errorf("metrics.interval: %w", err) + } + } + if m.ScrapeTimeout != "" { + if _, err := ParseDuration(m.ScrapeTimeout); err != nil { + return fmt.Errorf("metrics.scrapeTimeout: %w", err) + } + } + if component.Role.IsWorker() { + if m.Port <= 0 { + return fmt.Errorf("metrics.port is required when metrics are enabled on role: worker") + } + } + if m.Port != 0 && (m.Port < 1 || m.Port > 65535) { + return fmt.Errorf("metrics.port must be between 1 and 65535") + } + return nil +} + +// ValidateComponentShutdownTimeout validates shutdownTimeout when set. +func ValidateComponentShutdownTimeout(component Component) error { + if component.ShutdownTimeout == "" { + return nil + } + sec, err := ParseDuration(component.ShutdownTimeout) + if err != nil { + return fmt.Errorf("shutdownTimeout: %w", err) + } + if sec <= 0 { + return fmt.Errorf("shutdownTimeout must be a positive duration") + } + return nil +} + // ValidateComponentPersistence validates persistence and replica constraints // for stateful and stateless components. Persistence is optional on stateful // components (identity-only StatefulSet); when set, size and mountPath are @@ -315,9 +398,18 @@ func ValidateSpecComponents(spec *Spec) error { if err := ValidateComponentReplicas(component); err != nil { errs = append(errs, fmt.Errorf("component %s: %w", name, err)) } + if err := ValidateComponentWorker(component); err != nil { + errs = append(errs, fmt.Errorf("component %s: %w", name, err)) + } if err := ValidateComponentHealth(component); err != nil { errs = append(errs, fmt.Errorf("component %s: %w", name, err)) } + if err := ValidateComponentMetrics(component); err != nil { + errs = append(errs, fmt.Errorf("component %s: %w", name, err)) + } + if err := ValidateComponentShutdownTimeout(component); err != nil { + errs = append(errs, fmt.Errorf("component %s: %w", name, err)) + } if err := ValidateComponentExpose(component); err != nil { errs = append(errs, fmt.Errorf("component %s: %w", name, err)) } diff --git a/internal/spec/validate_test.go b/internal/spec/validate_test.go index acd6642..81fa417 100644 --- a/internal/spec/validate_test.go +++ b/internal/spec/validate_test.go @@ -527,13 +527,24 @@ func TestValidateComponentHealth(t *testing.T) { expectErr: false, }, { - name: "health on worker role is invalid", + name: "worker with exec alive is valid", component: Component{ Role: ComponentRoleWorker, - Health: &Health{Ready: &HealthReady{Path: "/health"}}, + Health: &Health{Alive: &HealthAlive{Exec: []string{"pgrep", "-f", "worker"}}}, + }, + expectErr: false, + }, + { + name: "path and exec on alive are mutually exclusive", + component: Component{ + Role: ComponentRoleService, + Port: 8080, + Health: &Health{ + Alive: &HealthAlive{Path: "/livez", Exec: []string{"true"}}, + }, }, expectErr: true, - errMsg: "health checks are only supported for role: service", + errMsg: "mutually exclusive", }, { name: "health on job role is invalid", @@ -542,7 +553,7 @@ func TestValidateComponentHealth(t *testing.T) { Health: &Health{Ready: &HealthReady{Path: "/health"}}, }, expectErr: true, - errMsg: "health checks are only supported for role: service", + errMsg: "health checks are not supported for role: job", }, { name: "ready path without leading slash is invalid", @@ -651,3 +662,147 @@ func TestValidateComponentHealth(t *testing.T) { }) } } + +func TestValidateComponentWorker(t *testing.T) { + t.Parallel() + tests := []struct { + name string + component Component + expectErr bool + errMsg string + }{ + { + name: "service is ignored", + component: Component{Role: ComponentRoleService, Port: 8080}, + }, + { + name: "worker without forbidden fields is valid", + component: Component{Role: ComponentRoleWorker, Image: "worker:1"}, + }, + { + name: "worker with port is invalid", + component: Component{Role: ComponentRoleWorker, Port: 8080}, + expectErr: true, + errMsg: "port is not supported on role: worker", + }, + { + name: "worker with expose is invalid", + component: Component{Role: ComponentRoleWorker, Expose: &Expose{}}, + expectErr: true, + errMsg: "expose is not supported on role: worker", + }, + { + name: "worker with health.ready is invalid", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{Ready: &HealthReady{Path: "/health"}}, + }, + expectErr: true, + errMsg: "health.ready is not supported", + }, + { + name: "worker with health.alive.path is invalid", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{Alive: &HealthAlive{Path: "/livez"}}, + }, + expectErr: true, + errMsg: "health.alive.path is not supported", + }, + { + name: "worker with health.alive.exec is valid", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{Alive: &HealthAlive{Exec: []string{"true"}}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateComponentWorker(tt.component) + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + return + } + assert.NoError(t, err) + }) + } +} + +func TestValidateComponentMetrics(t *testing.T) { + t.Parallel() + tests := []struct { + name string + component Component + expectErr bool + errMsg string + }{ + { + name: "nil metrics is valid", + component: Component{Role: ComponentRoleService}, + }, + { + name: "worker metrics true without port is invalid", + component: Component{ + Role: ComponentRoleWorker, + Metrics: &ComponentMetrics{}, + }, + expectErr: true, + errMsg: "metrics.port is required", + }, + { + name: "worker metrics with port is valid", + component: Component{ + Role: ComponentRoleWorker, + Metrics: &ComponentMetrics{Port: 9090}, + }, + }, + { + name: "service metrics without port is valid", + component: Component{ + Role: ComponentRoleService, + Port: 8080, + Metrics: &ComponentMetrics{}, + }, + }, + { + name: "metrics path must start with slash", + component: Component{ + Role: ComponentRoleService, + Metrics: &ComponentMetrics{Path: "metrics"}, + }, + expectErr: true, + errMsg: "metrics.path must start with /", + }, + { + name: "disabled metrics skips port check", + component: Component{ + Role: ComponentRoleWorker, + Metrics: &ComponentMetrics{Disabled: true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateComponentMetrics(tt.component) + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + return + } + assert.NoError(t, err) + }) + } +} + +func TestValidateComponentShutdownTimeout(t *testing.T) { + t.Parallel() + assert.NoError(t, ValidateComponentShutdownTimeout(Component{})) + assert.NoError(t, ValidateComponentShutdownTimeout(Component{ShutdownTimeout: "60s"})) + err := ValidateComponentShutdownTimeout(Component{ShutdownTimeout: "0s"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "shutdownTimeout") +} diff --git a/scenarios/autoscaling-hpa/deployah.yaml b/scenarios/autoscaling-hpa/deployah.yaml index c44f273..27d5b37 100644 --- a/scenarios/autoscaling-hpa/deployah.yaml +++ b/scenarios/autoscaling-hpa/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: autoscaling-hpa components: api: diff --git a/scenarios/autoscaling-hpa/expected/deployment-autoscaling-hpa-production-api.yaml b/scenarios/autoscaling-hpa/expected/deployment-autoscaling-hpa-production-api.yaml index d9aad5c..d233198 100644 --- a/scenarios/autoscaling-hpa/expected/deployment-autoscaling-hpa-production-api.yaml +++ b/scenarios/autoscaling-hpa/expected/deployment-autoscaling-hpa-production-api.yaml @@ -78,3 +78,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/basic-web-service/deployah.yaml b/scenarios/basic-web-service/deployah.yaml index 8454ced..39fe7c8 100644 --- a/scenarios/basic-web-service/deployah.yaml +++ b/scenarios/basic-web-service/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: basic-web-service components: web: diff --git a/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml b/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml index 7206028..9abd99f 100644 --- a/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml +++ b/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/command-args-resources/deployah.yaml b/scenarios/command-args-resources/deployah.yaml index 3ef4198..8e0cedd 100644 --- a/scenarios/command-args-resources/deployah.yaml +++ b/scenarios/command-args-resources/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: command-args-resources components: processor: diff --git a/scenarios/command-args-resources/expected/deployment-command-args-resources-production-processor.yaml b/scenarios/command-args-resources/expected/deployment-command-args-resources-production-processor.yaml index d034f19..a34b2ca 100644 --- a/scenarios/command-args-resources/expected/deployment-command-args-resources-production-processor.yaml +++ b/scenarios/command-args-resources/expected/deployment-command-args-resources-production-processor.yaml @@ -84,3 +84,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/env-substitution/deployah.yaml b/scenarios/env-substitution/deployah.yaml index c55b73a..e7d0649 100644 --- a/scenarios/env-substitution/deployah.yaml +++ b/scenarios/env-substitution/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: env-substitution components: api: diff --git a/scenarios/env-substitution/expected/deployment-env-substitution-dev-api.yaml b/scenarios/env-substitution/expected/deployment-env-substitution-dev-api.yaml index c4f793b..1336cec 100644 --- a/scenarios/env-substitution/expected/deployment-env-substitution-dev-api.yaml +++ b/scenarios/env-substitution/expected/deployment-env-substitution-dev-api.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/error-apex-subdomain/deployah.yaml b/scenarios/error-apex-subdomain/deployah.yaml index 685d185..153f74c 100644 --- a/scenarios/error-apex-subdomain/deployah.yaml +++ b/scenarios/error-apex-subdomain/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-apex-subdomain components: api: diff --git a/scenarios/error-empty-resources/deployah.yaml b/scenarios/error-empty-resources/deployah.yaml index bce31bb..2bb3a08 100644 --- a/scenarios/error-empty-resources/deployah.yaml +++ b/scenarios/error-empty-resources/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-empty-resources components: api: diff --git a/scenarios/error-health-durations/deployah.yaml b/scenarios/error-health-durations/deployah.yaml index dc897b5..d398e36 100644 --- a/scenarios/error-health-durations/deployah.yaml +++ b/scenarios/error-health-durations/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-health-durations components: api: diff --git a/scenarios/error-health-on-worker/deployah.yaml b/scenarios/error-health-on-worker/deployah.yaml index c2a7626..32a84ac 100644 --- a/scenarios/error-health-on-worker/deployah.yaml +++ b/scenarios/error-health-on-worker/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-health-on-worker components: worker: diff --git a/scenarios/error-health-on-worker/error-config.yaml b/scenarios/error-health-on-worker/error-config.yaml index 79a0cd1..58ce9d5 100644 --- a/scenarios/error-health-on-worker/error-config.yaml +++ b/scenarios/error-health-on-worker/error-config.yaml @@ -1,2 +1,2 @@ expectedErrors: - - "health checks are only supported for role: service components" + - "health.ready is not supported on role: worker" diff --git a/scenarios/error-health-path/deployah.yaml b/scenarios/error-health-path/deployah.yaml index e51184c..3ddb341 100644 --- a/scenarios/error-health-path/deployah.yaml +++ b/scenarios/error-health-path/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-health-path components: api: diff --git a/scenarios/error-metric-type/deployah.yaml b/scenarios/error-metric-type/deployah.yaml index 4139be0..407a815 100644 --- a/scenarios/error-metric-type/deployah.yaml +++ b/scenarios/error-metric-type/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-metric-type components: api: diff --git a/scenarios/error-multiple-issues/deployah.yaml b/scenarios/error-multiple-issues/deployah.yaml index 0c26aab..5ae76e6 100644 --- a/scenarios/error-multiple-issues/deployah.yaml +++ b/scenarios/error-multiple-issues/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-multiple-issues components: web: diff --git a/scenarios/error-profile-ceiling/deployah.platform.yaml b/scenarios/error-profile-ceiling/deployah.platform.yaml index 3267c42..179eb9e 100644 --- a/scenarios/error-profile-ceiling/deployah.platform.yaml +++ b/scenarios/error-profile-ceiling/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: capped: maxResources: diff --git a/scenarios/error-profile-ceiling/deployah.yaml b/scenarios/error-profile-ceiling/deployah.yaml index 94ed662..b5308c1 100644 --- a/scenarios/error-profile-ceiling/deployah.yaml +++ b/scenarios/error-profile-ceiling/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-profile-ceiling components: web: diff --git a/scenarios/error-profile-domain/deployah.platform.yaml b/scenarios/error-profile-domain/deployah.platform.yaml index 16797d9..aa652ad 100644 --- a/scenarios/error-profile-domain/deployah.platform.yaml +++ b/scenarios/error-profile-domain/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: public-only: allowedDomains: [public] diff --git a/scenarios/error-profile-domain/deployah.yaml b/scenarios/error-profile-domain/deployah.yaml index 3d3956a..f2bced4 100644 --- a/scenarios/error-profile-domain/deployah.yaml +++ b/scenarios/error-profile-domain/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-profile-domain components: web: diff --git a/scenarios/error-profile-unknown/deployah.platform.yaml b/scenarios/error-profile-unknown/deployah.platform.yaml index bee7940..4b7863c 100644 --- a/scenarios/error-profile-unknown/deployah.platform.yaml +++ b/scenarios/error-profile-unknown/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: public-web: podLabels: diff --git a/scenarios/error-profile-unknown/deployah.yaml b/scenarios/error-profile-unknown/deployah.yaml index 427502d..1e8086d 100644 --- a/scenarios/error-profile-unknown/deployah.yaml +++ b/scenarios/error-profile-unknown/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-profile-unknown components: web: diff --git a/scenarios/error-replicas-autoscaling/deployah.yaml b/scenarios/error-replicas-autoscaling/deployah.yaml index 44f4287..645d983 100644 --- a/scenarios/error-replicas-autoscaling/deployah.yaml +++ b/scenarios/error-replicas-autoscaling/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-replicas-autoscaling components: web: diff --git a/scenarios/error-stateless-persistence-replicas/deployah.yaml b/scenarios/error-stateless-persistence-replicas/deployah.yaml index 43b8d74..4b25ce3 100644 --- a/scenarios/error-stateless-persistence-replicas/deployah.yaml +++ b/scenarios/error-stateless-persistence-replicas/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: error-stateless-persistence-replicas components: web: diff --git a/scenarios/error-worker-expose/deployah.yaml b/scenarios/error-worker-expose/deployah.yaml new file mode 100644 index 0000000..7c2c5ce --- /dev/null +++ b/scenarios/error-worker-expose/deployah.yaml @@ -0,0 +1,12 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: error-worker-expose +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + environments: [dev] + expose: + subdomain: jobs +environments: + dev: {} diff --git a/scenarios/error-worker-expose/error-config.yaml b/scenarios/error-worker-expose/error-config.yaml new file mode 100644 index 0000000..9a78de0 --- /dev/null +++ b/scenarios/error-worker-expose/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "expose is not supported on role: worker" diff --git a/scenarios/error-worker-http-health/deployah.yaml b/scenarios/error-worker-http-health/deployah.yaml new file mode 100644 index 0000000..7a171f5 --- /dev/null +++ b/scenarios/error-worker-http-health/deployah.yaml @@ -0,0 +1,13 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: error-worker-http-health +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + environments: [dev] + health: + alive: + path: /livez +environments: + dev: {} diff --git a/scenarios/error-worker-http-health/error-config.yaml b/scenarios/error-worker-http-health/error-config.yaml new file mode 100644 index 0000000..84312a6 --- /dev/null +++ b/scenarios/error-worker-http-health/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "health.alive.path is not supported on role: worker" diff --git a/scenarios/error-worker-metrics-no-port/deployah.yaml b/scenarios/error-worker-metrics-no-port/deployah.yaml new file mode 100644 index 0000000..1cf6d8d --- /dev/null +++ b/scenarios/error-worker-metrics-no-port/deployah.yaml @@ -0,0 +1,11 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: error-worker-metrics-no-port +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + environments: [dev] + metrics: true +environments: + dev: {} diff --git a/scenarios/error-worker-metrics-no-port/error-config.yaml b/scenarios/error-worker-metrics-no-port/error-config.yaml new file mode 100644 index 0000000..a89fd4b --- /dev/null +++ b/scenarios/error-worker-metrics-no-port/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "metrics.port is required when metrics are enabled on role: worker" diff --git a/scenarios/error-worker-port/deployah.yaml b/scenarios/error-worker-port/deployah.yaml new file mode 100644 index 0000000..0850c52 --- /dev/null +++ b/scenarios/error-worker-port/deployah.yaml @@ -0,0 +1,11 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: error-worker-port +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + port: 8080 + environments: [dev] +environments: + dev: {} diff --git a/scenarios/error-worker-port/error-config.yaml b/scenarios/error-worker-port/error-config.yaml new file mode 100644 index 0000000..9b8587c --- /dev/null +++ b/scenarios/error-worker-port/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "port is not supported on role: worker" diff --git a/scenarios/expose-apex-certmanager/deployah.platform.yaml b/scenarios/expose-apex-certmanager/deployah.platform.yaml index 2fdb87b..f7f0c03 100644 --- a/scenarios/expose-apex-certmanager/deployah.platform.yaml +++ b/scenarios/expose-apex-certmanager/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 environments: production: domains: diff --git a/scenarios/expose-apex-certmanager/deployah.yaml b/scenarios/expose-apex-certmanager/deployah.yaml index 04650eb..13336b4 100644 --- a/scenarios/expose-apex-certmanager/deployah.yaml +++ b/scenarios/expose-apex-certmanager/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: expose-apex-certmanager components: api: diff --git a/scenarios/expose-apex-certmanager/expected/deployment-expose-apex-certmanager-production-api.yaml b/scenarios/expose-apex-certmanager/expected/deployment-expose-apex-certmanager-production-api.yaml index 74d88e7..43772a1 100644 --- a/scenarios/expose-apex-certmanager/expected/deployment-expose-apex-certmanager-production-api.yaml +++ b/scenarios/expose-apex-certmanager/expected/deployment-expose-apex-certmanager-production-api.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/expose-secretname/deployah.platform.yaml b/scenarios/expose-secretname/deployah.platform.yaml index 23744ba..49d939d 100644 --- a/scenarios/expose-secretname/deployah.platform.yaml +++ b/scenarios/expose-secretname/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 environments: production: domains: diff --git a/scenarios/expose-secretname/deployah.yaml b/scenarios/expose-secretname/deployah.yaml index 0ad62c3..a457268 100644 --- a/scenarios/expose-secretname/deployah.yaml +++ b/scenarios/expose-secretname/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: expose-secretname components: api: diff --git a/scenarios/expose-secretname/expected/deployment-expose-secretname-production-api.yaml b/scenarios/expose-secretname/expected/deployment-expose-secretname-production-api.yaml index e2984cf..6abe48e 100644 --- a/scenarios/expose-secretname/expected/deployment-expose-secretname-production-api.yaml +++ b/scenarios/expose-secretname/expected/deployment-expose-secretname-production-api.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/expose-selfsigned/deployah.platform.yaml b/scenarios/expose-selfsigned/deployah.platform.yaml index bac96a4..d466a5f 100644 --- a/scenarios/expose-selfsigned/deployah.platform.yaml +++ b/scenarios/expose-selfsigned/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 environments: production: domains: diff --git a/scenarios/expose-selfsigned/deployah.yaml b/scenarios/expose-selfsigned/deployah.yaml index 2512418..ef5f8da 100644 --- a/scenarios/expose-selfsigned/deployah.yaml +++ b/scenarios/expose-selfsigned/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: expose-selfsigned components: web: diff --git a/scenarios/expose-selfsigned/expected/deployment-expose-selfsigned-production-web.yaml b/scenarios/expose-selfsigned/expected/deployment-expose-selfsigned-production-web.yaml index 60cb830..9b82ce7 100644 --- a/scenarios/expose-selfsigned/expected/deployment-expose-selfsigned-production-web.yaml +++ b/scenarios/expose-selfsigned/expected/deployment-expose-selfsigned-production-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/extras-env-and-crds/deployah.yaml b/scenarios/extras-env-and-crds/deployah.yaml index e440ed6..820c937 100644 --- a/scenarios/extras-env-and-crds/deployah.yaml +++ b/scenarios/extras-env-and-crds/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: extras-env-and-crds components: web: diff --git a/scenarios/extras-env-and-crds/expected-dev/deployment-extras-env-and-crds-dev-web.yaml b/scenarios/extras-env-and-crds/expected-dev/deployment-extras-env-and-crds-dev-web.yaml index 9405391..8b38552 100644 --- a/scenarios/extras-env-and-crds/expected-dev/deployment-extras-env-and-crds-dev-web.yaml +++ b/scenarios/extras-env-and-crds/expected-dev/deployment-extras-env-and-crds-dev-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/extras-env-and-crds/expected-prod/deployment-extras-env-and-crds-prod-web.yaml b/scenarios/extras-env-and-crds/expected-prod/deployment-extras-env-and-crds-prod-web.yaml index fcf628f..9284583 100644 --- a/scenarios/extras-env-and-crds/expected-prod/deployment-extras-env-and-crds-prod-web.yaml +++ b/scenarios/extras-env-and-crds/expected-prod/deployment-extras-env-and-crds-prod-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/extras-manifest/deployah.yaml b/scenarios/extras-manifest/deployah.yaml index 529b315..d43e2dc 100644 --- a/scenarios/extras-manifest/deployah.yaml +++ b/scenarios/extras-manifest/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: extras-manifest components: web: diff --git a/scenarios/extras-manifest/expected/deployment-extras-manifest-dev-web.yaml b/scenarios/extras-manifest/expected/deployment-extras-manifest-dev-web.yaml index c9150af..7b63e41 100644 --- a/scenarios/extras-manifest/expected/deployment-extras-manifest-dev-web.yaml +++ b/scenarios/extras-manifest/expected/deployment-extras-manifest-dev-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/health-check-http/deployah.yaml b/scenarios/health-check-http/deployah.yaml index c6014fc..54b7240 100644 --- a/scenarios/health-check-http/deployah.yaml +++ b/scenarios/health-check-http/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: health-check-http components: api: diff --git a/scenarios/health-check-http/expected/deployment-health-check-http-dev-api.yaml b/scenarios/health-check-http/expected/deployment-health-check-http-dev-api.yaml index 7ce2c19..2e720a2 100644 --- a/scenarios/health-check-http/expected/deployment-health-check-http-dev-api.yaml +++ b/scenarios/health-check-http/expected/deployment-health-check-http-dev-api.yaml @@ -82,3 +82,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/health-disabled/deployah.yaml b/scenarios/health-disabled/deployah.yaml index f1dc4c7..41e1631 100644 --- a/scenarios/health-disabled/deployah.yaml +++ b/scenarios/health-disabled/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: health-disabled components: api: diff --git a/scenarios/health-disabled/expected/deployment-health-disabled-dev-api.yaml b/scenarios/health-disabled/expected/deployment-health-disabled-dev-api.yaml index 20a4a48..d06ac79 100644 --- a/scenarios/health-disabled/expected/deployment-health-disabled-dev-api.yaml +++ b/scenarios/health-disabled/expected/deployment-health-disabled-dev-api.yaml @@ -61,3 +61,4 @@ spec: memory: 512Mi restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/invalid-manifest/deployah.yaml b/scenarios/invalid-manifest/deployah.yaml index 2c10430..089cb86 100644 --- a/scenarios/invalid-manifest/deployah.yaml +++ b/scenarios/invalid-manifest/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: invalid-manifest components: web: diff --git a/scenarios/multi-component/deployah.yaml b/scenarios/multi-component/deployah.yaml index 5197cf9..f047b64 100644 --- a/scenarios/multi-component/deployah.yaml +++ b/scenarios/multi-component/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: multi-component components: web: diff --git a/scenarios/multi-component/expected/deployment-multi-component-production-api.yaml b/scenarios/multi-component/expected/deployment-multi-component-production-api.yaml index 652281d..e323bed 100644 --- a/scenarios/multi-component/expected/deployment-multi-component-production-api.yaml +++ b/scenarios/multi-component/expected/deployment-multi-component-production-api.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/multi-component/expected/deployment-multi-component-production-web.yaml b/scenarios/multi-component/expected/deployment-multi-component-production-web.yaml index 495972c..4ba6733 100644 --- a/scenarios/multi-component/expected/deployment-multi-component-production-web.yaml +++ b/scenarios/multi-component/expected/deployment-multi-component-production-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/multi-env/deployah.yaml b/scenarios/multi-env/deployah.yaml index a631fd6..370ca36 100644 --- a/scenarios/multi-env/deployah.yaml +++ b/scenarios/multi-env/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: multi-env components: web: diff --git a/scenarios/multi-env/expected-dev/deployment-multi-env-dev-debug-sidecar.yaml b/scenarios/multi-env/expected-dev/deployment-multi-env-dev-debug-sidecar.yaml index ee894c4..be63b09 100644 --- a/scenarios/multi-env/expected-dev/deployment-multi-env-dev-debug-sidecar.yaml +++ b/scenarios/multi-env/expected-dev/deployment-multi-env-dev-debug-sidecar.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/multi-env/expected-dev/deployment-multi-env-dev-web.yaml b/scenarios/multi-env/expected-dev/deployment-multi-env-dev-web.yaml index 95cc5ae..6a38bd0 100644 --- a/scenarios/multi-env/expected-dev/deployment-multi-env-dev-web.yaml +++ b/scenarios/multi-env/expected-dev/deployment-multi-env-dev-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/multi-env/expected-production/deployment-multi-env-production-web.yaml b/scenarios/multi-env/expected-production/deployment-multi-env-production-web.yaml index b693583..7c22700 100644 --- a/scenarios/multi-env/expected-production/deployment-multi-env-production-web.yaml +++ b/scenarios/multi-env/expected-production/deployment-multi-env-production-web.yaml @@ -79,3 +79,4 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/plan-after-failed-upgrade/deployah.yaml b/scenarios/plan-after-failed-upgrade/deployah.yaml index d721584..25ba371 100644 --- a/scenarios/plan-after-failed-upgrade/deployah.yaml +++ b/scenarios/plan-after-failed-upgrade/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-after-failed-upgrade components: web: diff --git a/scenarios/plan-after-failed-upgrade/previous.yaml b/scenarios/plan-after-failed-upgrade/previous.yaml index 59636e5..8d3a86f 100644 --- a/scenarios/plan-after-failed-upgrade/previous.yaml +++ b/scenarios/plan-after-failed-upgrade/previous.yaml @@ -101,6 +101,7 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 --- apiVersion: v1 kind: Service diff --git a/scenarios/plan-command-change/before.yaml b/scenarios/plan-command-change/before.yaml index 3a6aa09..bbca0d1 100644 --- a/scenarios/plan-command-change/before.yaml +++ b/scenarios/plan-command-change/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-command-change components: api: diff --git a/scenarios/plan-command-change/deployah.yaml b/scenarios/plan-command-change/deployah.yaml index 9c8200f..78d349b 100644 --- a/scenarios/plan-command-change/deployah.yaml +++ b/scenarios/plan-command-change/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-command-change components: api: diff --git a/scenarios/plan-extras-fresh-install/deployah.yaml b/scenarios/plan-extras-fresh-install/deployah.yaml index 6c690c2..c0fdd2a 100644 --- a/scenarios/plan-extras-fresh-install/deployah.yaml +++ b/scenarios/plan-extras-fresh-install/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-extras-fresh-install components: web: diff --git a/scenarios/plan-fresh-install/deployah.yaml b/scenarios/plan-fresh-install/deployah.yaml index 196eeec..57b51c1 100644 --- a/scenarios/plan-fresh-install/deployah.yaml +++ b/scenarios/plan-fresh-install/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-fresh-install components: web: diff --git a/scenarios/plan-hpa-change/before.yaml b/scenarios/plan-hpa-change/before.yaml index 5949a16..a3f41ea 100644 --- a/scenarios/plan-hpa-change/before.yaml +++ b/scenarios/plan-hpa-change/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-hpa-change components: api: diff --git a/scenarios/plan-hpa-change/deployah.yaml b/scenarios/plan-hpa-change/deployah.yaml index f9b2e87..078736a 100644 --- a/scenarios/plan-hpa-change/deployah.yaml +++ b/scenarios/plan-hpa-change/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-hpa-change components: api: diff --git a/scenarios/plan-image-bump/before.yaml b/scenarios/plan-image-bump/before.yaml index b92a7c8..2ef7b9e 100644 --- a/scenarios/plan-image-bump/before.yaml +++ b/scenarios/plan-image-bump/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-image-bump components: web: diff --git a/scenarios/plan-image-bump/deployah.yaml b/scenarios/plan-image-bump/deployah.yaml index cab2eb9..fa9dc5c 100644 --- a/scenarios/plan-image-bump/deployah.yaml +++ b/scenarios/plan-image-bump/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-image-bump components: web: diff --git a/scenarios/plan-ingress-added/before.yaml b/scenarios/plan-ingress-added/before.yaml index d72501a..a145d19 100644 --- a/scenarios/plan-ingress-added/before.yaml +++ b/scenarios/plan-ingress-added/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-ingress-added components: api: diff --git a/scenarios/plan-ingress-added/deployah.platform.yaml b/scenarios/plan-ingress-added/deployah.platform.yaml index 23744ba..49d939d 100644 --- a/scenarios/plan-ingress-added/deployah.platform.yaml +++ b/scenarios/plan-ingress-added/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 environments: production: domains: diff --git a/scenarios/plan-ingress-added/deployah.yaml b/scenarios/plan-ingress-added/deployah.yaml index 352dc5b..d5ea140 100644 --- a/scenarios/plan-ingress-added/deployah.yaml +++ b/scenarios/plan-ingress-added/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-ingress-added components: api: diff --git a/scenarios/plan-mixed-changes/before.yaml b/scenarios/plan-mixed-changes/before.yaml index 17fff2d..d7950b4 100644 --- a/scenarios/plan-mixed-changes/before.yaml +++ b/scenarios/plan-mixed-changes/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-mixed-changes components: web: diff --git a/scenarios/plan-mixed-changes/deployah.yaml b/scenarios/plan-mixed-changes/deployah.yaml index 2f87df5..5ffd246 100644 --- a/scenarios/plan-mixed-changes/deployah.yaml +++ b/scenarios/plan-mixed-changes/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-mixed-changes components: web: diff --git a/scenarios/plan-no-changes/before.yaml b/scenarios/plan-no-changes/before.yaml index c82d1a4..3767119 100644 --- a/scenarios/plan-no-changes/before.yaml +++ b/scenarios/plan-no-changes/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-no-changes components: web: diff --git a/scenarios/plan-no-changes/deployah.yaml b/scenarios/plan-no-changes/deployah.yaml index c82d1a4..3767119 100644 --- a/scenarios/plan-no-changes/deployah.yaml +++ b/scenarios/plan-no-changes/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-no-changes components: web: diff --git a/scenarios/plan-resource-added/before.yaml b/scenarios/plan-resource-added/before.yaml index 165eaa7..f056c82 100644 --- a/scenarios/plan-resource-added/before.yaml +++ b/scenarios/plan-resource-added/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-resource-added components: web: diff --git a/scenarios/plan-resource-added/deployah.yaml b/scenarios/plan-resource-added/deployah.yaml index 851634a..583e556 100644 --- a/scenarios/plan-resource-added/deployah.yaml +++ b/scenarios/plan-resource-added/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-resource-added components: web: diff --git a/scenarios/plan-resource-removed/before.yaml b/scenarios/plan-resource-removed/before.yaml index 59ad4e0..9bbea9b 100644 --- a/scenarios/plan-resource-removed/before.yaml +++ b/scenarios/plan-resource-removed/before.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-resource-removed components: web: diff --git a/scenarios/plan-resource-removed/deployah.yaml b/scenarios/plan-resource-removed/deployah.yaml index 8dc3cf5..b4a9d3d 100644 --- a/scenarios/plan-resource-removed/deployah.yaml +++ b/scenarios/plan-resource-removed/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: plan-resource-removed components: web: diff --git a/scenarios/profile-basic/deployah.platform.yaml b/scenarios/profile-basic/deployah.platform.yaml index 977b905..54ced8a 100644 --- a/scenarios/profile-basic/deployah.platform.yaml +++ b/scenarios/profile-basic/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: public-web: nodeSelector: diff --git a/scenarios/profile-basic/deployah.yaml b/scenarios/profile-basic/deployah.yaml index 6b8c4b4..f7e9081 100644 --- a/scenarios/profile-basic/deployah.yaml +++ b/scenarios/profile-basic/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: profile-basic components: web: diff --git a/scenarios/profile-basic/expected/deployment-profile-basic-production-web.yaml b/scenarios/profile-basic/expected/deployment-profile-basic-production-web.yaml index b3e7146..d991a46 100644 --- a/scenarios/profile-basic/expected/deployment-profile-basic-production-web.yaml +++ b/scenarios/profile-basic/expected/deployment-profile-basic-production-web.yaml @@ -91,6 +91,7 @@ spec: fsGroup: 0 runAsNonRoot: true serviceAccountName: default + terminationGracePeriodSeconds: 30 tolerations: - effect: NoSchedule key: ingress diff --git a/scenarios/profile-merge/deployah.platform.yaml b/scenarios/profile-merge/deployah.platform.yaml index e5f1485..bb0368e 100644 --- a/scenarios/profile-merge/deployah.platform.yaml +++ b/scenarios/profile-merge/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: default: nodeSelector: diff --git a/scenarios/profile-merge/deployah.yaml b/scenarios/profile-merge/deployah.yaml index cceb9f7..f570719 100644 --- a/scenarios/profile-merge/deployah.yaml +++ b/scenarios/profile-merge/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: profile-merge components: api: diff --git a/scenarios/profile-merge/expected/deployment-profile-merge-production-api.yaml b/scenarios/profile-merge/expected/deployment-profile-merge-production-api.yaml index d5fad9b..5014758 100644 --- a/scenarios/profile-merge/expected/deployment-profile-merge-production-api.yaml +++ b/scenarios/profile-merge/expected/deployment-profile-merge-production-api.yaml @@ -92,6 +92,7 @@ spec: fsGroup: 0 runAsNonRoot: true serviceAccountName: default + terminationGracePeriodSeconds: 30 tolerations: - effect: NoSchedule key: ingress diff --git a/scenarios/service-metrics-dedicated/deployah.platform.yaml b/scenarios/service-metrics-dedicated/deployah.platform.yaml new file mode 100644 index 0000000..9046bf8 --- /dev/null +++ b/scenarios/service-metrics-dedicated/deployah.platform.yaml @@ -0,0 +1,10 @@ +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 +profiles: + observability: + metrics: + monitorLabels: + release: prometheus +environments: + production: + context: prod diff --git a/scenarios/service-metrics-dedicated/deployah.yaml b/scenarios/service-metrics-dedicated/deployah.yaml new file mode 100644 index 0000000..8e8bb3e --- /dev/null +++ b/scenarios/service-metrics-dedicated/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: service-metrics-dedicated +components: + api: + image: ghcr.io/acme/api:1.0.0 + port: 8080 + resourcePreset: small + environments: [production] + profiles: [observability] + metrics: + port: 9090 + path: /metrics +environments: + production: {} diff --git a/scenarios/service-metrics-dedicated/expected/deployment-service-metrics-dedicated-production-api.yaml b/scenarios/service-metrics-dedicated/expected/deployment-service-metrics-dedicated-production-api.yaml new file mode 100644 index 0000000..7b3ade3 --- /dev/null +++ b/scenarios/service-metrics-dedicated/expected/deployment-service-metrics-dedicated-production-api.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: service-metrics-dedicated + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics-dedicated + helm.sh/chart: api-0.1.0 + name: service-metrics-dedicated-production-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics-dedicated + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: ghcr.io/acme/api:1.0.0 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + - containerPort: 9090 + name: metrics + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/service-metrics-dedicated/expected/service-service-metrics-dedicated-production-api.yaml b/scenarios/service-metrics-dedicated/expected/service-service-metrics-dedicated-production-api.yaml new file mode 100644 index 0000000..f70e8e2 --- /dev/null +++ b/scenarios/service-metrics-dedicated/expected/service-service-metrics-dedicated-production-api.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: service-metrics-dedicated + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics-dedicated + helm.sh/chart: api-0.1.0 + name: service-metrics-dedicated-production-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + - name: metrics + port: 9090 + protocol: TCP + targetPort: metrics + selector: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/service-metrics-dedicated/expected/servicemonitor-service-metrics-dedicated-production-api.yaml b/scenarios/service-metrics-dedicated/expected/servicemonitor-service-metrics-dedicated-production-api.yaml new file mode 100644 index 0000000..501d2c7 --- /dev/null +++ b/scenarios/service-metrics-dedicated/expected/servicemonitor-service-metrics-dedicated-production-api.yaml @@ -0,0 +1,28 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + deployah.dev/project: service-metrics-dedicated + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics-dedicated + helm.sh/chart: api-0.1.0 + release: prometheus + name: service-metrics-dedicated-production-api + namespace: default +spec: + endpoints: + - path: /metrics + port: metrics + namespaceSelector: + matchNames: + - default + selector: + matchLabels: + app.kubernetes.io/instance: service-metrics-dedicated-production + app.kubernetes.io/name: api diff --git a/scenarios/service-metrics/deployah.platform.yaml b/scenarios/service-metrics/deployah.platform.yaml new file mode 100644 index 0000000..9046bf8 --- /dev/null +++ b/scenarios/service-metrics/deployah.platform.yaml @@ -0,0 +1,10 @@ +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 +profiles: + observability: + metrics: + monitorLabels: + release: prometheus +environments: + production: + context: prod diff --git a/scenarios/service-metrics/deployah.yaml b/scenarios/service-metrics/deployah.yaml new file mode 100644 index 0000000..909a8d6 --- /dev/null +++ b/scenarios/service-metrics/deployah.yaml @@ -0,0 +1,13 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: service-metrics +components: + api: + image: ghcr.io/acme/api:1.0.0 + port: 8080 + resourcePreset: small + environments: [production] + profiles: [observability] + metrics: true +environments: + production: {} diff --git a/scenarios/service-metrics/expected/deployment-service-metrics-production-api.yaml b/scenarios/service-metrics/expected/deployment-service-metrics-production-api.yaml new file mode 100644 index 0000000..7b9fe96 --- /dev/null +++ b/scenarios/service-metrics/expected/deployment-service-metrics-production-api.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: service-metrics + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics + helm.sh/chart: api-0.1.0 + name: service-metrics-production-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: ghcr.io/acme/api:1.0.0 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/service-metrics/expected/service-service-metrics-production-api.yaml b/scenarios/service-metrics/expected/service-service-metrics-production-api.yaml new file mode 100644 index 0000000..2adcf4a --- /dev/null +++ b/scenarios/service-metrics/expected/service-service-metrics-production-api.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: service-metrics + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics + helm.sh/chart: api-0.1.0 + name: service-metrics-production-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/service-metrics/expected/servicemonitor-service-metrics-production-api.yaml b/scenarios/service-metrics/expected/servicemonitor-service-metrics-production-api.yaml new file mode 100644 index 0000000..fe80b5f --- /dev/null +++ b/scenarios/service-metrics/expected/servicemonitor-service-metrics-production-api.yaml @@ -0,0 +1,28 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + deployah.dev/project: service-metrics + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: production + deployah.dev/project: service-metrics + helm.sh/chart: api-0.1.0 + release: prometheus + name: service-metrics-production-api + namespace: default +spec: + endpoints: + - path: /metrics + port: http + namespaceSelector: + matchNames: + - default + selector: + matchLabels: + app.kubernetes.io/instance: service-metrics-production + app.kubernetes.io/name: api diff --git a/scenarios/stateful-basic/deployah.yaml b/scenarios/stateful-basic/deployah.yaml index 5e66a34..0643c3e 100644 --- a/scenarios/stateful-basic/deployah.yaml +++ b/scenarios/stateful-basic/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: stateful-basic components: db: diff --git a/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml index 0559307..bd46d2f 100644 --- a/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml +++ b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml @@ -85,6 +85,7 @@ spec: name: data restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 updateStrategy: type: RollingUpdate volumeClaimTemplates: diff --git a/scenarios/stateful-hpa/deployah.yaml b/scenarios/stateful-hpa/deployah.yaml index e9020c8..74acc54 100644 --- a/scenarios/stateful-hpa/deployah.yaml +++ b/scenarios/stateful-hpa/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: stateful-hpa components: cache: diff --git a/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml b/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml index 1eef0b6..e1e6154 100644 --- a/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml +++ b/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml @@ -84,6 +84,7 @@ spec: name: data restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 updateStrategy: type: RollingUpdate volumeClaimTemplates: diff --git a/scenarios/stateful-identity/deployah.yaml b/scenarios/stateful-identity/deployah.yaml index 37df046..3d622cd 100644 --- a/scenarios/stateful-identity/deployah.yaml +++ b/scenarios/stateful-identity/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: stateful-identity components: peer: diff --git a/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml b/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml index 98fe88f..cda139e 100644 --- a/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml +++ b/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml @@ -79,5 +79,6 @@ spec: timeoutSeconds: 3 restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 updateStrategy: type: RollingUpdate diff --git a/scenarios/stateful-profile-retention/deployah.platform.yaml b/scenarios/stateful-profile-retention/deployah.platform.yaml index a2d61f7..5f69904 100644 --- a/scenarios/stateful-profile-retention/deployah.platform.yaml +++ b/scenarios/stateful-profile-retention/deployah.platform.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json -apiVersion: platform/v1-alpha.2 +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 profiles: stateful-store: storageClass: fast diff --git a/scenarios/stateful-profile-retention/deployah.yaml b/scenarios/stateful-profile-retention/deployah.yaml index 3994388..b5d0587 100644 --- a/scenarios/stateful-profile-retention/deployah.yaml +++ b/scenarios/stateful-profile-retention/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: stateful-profile-retention components: db: diff --git a/scenarios/stateful-profile-retention/expected/statefulset-stateful-profile-retention-production-db.yaml b/scenarios/stateful-profile-retention/expected/statefulset-stateful-profile-retention-production-db.yaml index 0ddbdb0..68c6db6 100644 --- a/scenarios/stateful-profile-retention/expected/statefulset-stateful-profile-retention-production-db.yaml +++ b/scenarios/stateful-profile-retention/expected/statefulset-stateful-profile-retention-production-db.yaml @@ -85,6 +85,7 @@ spec: name: data restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 updateStrategy: type: RollingUpdate volumeClaimTemplates: diff --git a/scenarios/stateless-persistence/deployah.yaml b/scenarios/stateless-persistence/deployah.yaml index a460002..5c43c3b 100644 --- a/scenarios/stateless-persistence/deployah.yaml +++ b/scenarios/stateless-persistence/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json -apiVersion: v1-alpha.3 +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 project: stateless-persistence components: web: diff --git a/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml b/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml index 7196083..2eb74b1 100644 --- a/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml +++ b/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml @@ -82,6 +82,7 @@ spec: name: data restartPolicy: Always serviceAccountName: default + terminationGracePeriodSeconds: 30 volumes: - name: data persistentVolumeClaim: diff --git a/scenarios/worker-exec-health/deployah.yaml b/scenarios/worker-exec-health/deployah.yaml new file mode 100644 index 0000000..3977511 --- /dev/null +++ b/scenarios/worker-exec-health/deployah.yaml @@ -0,0 +1,16 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: worker-exec-health +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + resourcePreset: small + environments: [dev] + health: + alive: + exec: ["/bin/grpc_health_probe", "-addr=:50051"] + interval: 10s + restartAfter: 60s +environments: + dev: {} diff --git a/scenarios/worker-exec-health/expected/deployment-worker-exec-health-dev.yaml b/scenarios/worker-exec-health/expected/deployment-worker-exec-health-dev.yaml new file mode 100644 index 0000000..dbc15e2 --- /dev/null +++ b/scenarios/worker-exec-health/expected/deployment-worker-exec-health-dev.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: worker-exec-health + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-exec-health-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-exec-health + helm.sh/chart: worker-0.1.0 + name: worker-exec-health-dev + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-exec-health-dev + app.kubernetes.io/name: worker + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-exec-health-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-exec-health + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-exec-health-dev + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: ghcr.io/acme/worker:1.0.0 + imagePullPolicy: IfNotPresent + livenessProbe: + exec: + command: + - /bin/grpc_health_probe + - -addr=:50051 + failureThreshold: 6 + periodSeconds: 10 + timeoutSeconds: 3 + name: worker + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60 diff --git a/scenarios/worker-hpa/deployah.yaml b/scenarios/worker-hpa/deployah.yaml new file mode 100644 index 0000000..494eb89 --- /dev/null +++ b/scenarios/worker-hpa/deployah.yaml @@ -0,0 +1,18 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: worker-hpa +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + resourcePreset: small + environments: [production] + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + metrics: + - type: cpu + target: 70 +environments: + production: {} diff --git a/scenarios/worker-hpa/expected/deployment-worker-hpa-production.yaml b/scenarios/worker-hpa/expected/deployment-worker-hpa-production.yaml new file mode 100644 index 0000000..7d18ee4 --- /dev/null +++ b/scenarios/worker-hpa/expected/deployment-worker-hpa-production.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: worker-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-hpa-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-hpa + helm.sh/chart: worker-0.1.0 + name: worker-hpa-production + namespace: default +spec: + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-hpa-production + app.kubernetes.io/name: worker + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-hpa-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-hpa + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-hpa-production + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: ghcr.io/acme/worker:1.0.0 + imagePullPolicy: IfNotPresent + name: worker + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60 diff --git a/scenarios/worker-hpa/expected/horizontalpodautoscaler-worker-hpa-production.yaml b/scenarios/worker-hpa/expected/horizontalpodautoscaler-worker-hpa-production.yaml new file mode 100644 index 0000000..0db0c7c --- /dev/null +++ b/scenarios/worker-hpa/expected/horizontalpodautoscaler-worker-hpa-production.yaml @@ -0,0 +1,36 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + annotations: + deployah.dev/project: worker-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-hpa-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-hpa + helm.sh/chart: worker-0.1.0 + name: worker-hpa-production + namespace: default +spec: + maxReplicas: 8 + metrics: + - resource: + name: cpu + target: + averageUtilization: 70 + type: Utilization + type: Resource + - resource: + name: memory + target: + averageUtilization: 80 + type: Utilization + type: Resource + minReplicas: 2 + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: worker-hpa-production diff --git a/scenarios/worker-metrics/deployah.platform.yaml b/scenarios/worker-metrics/deployah.platform.yaml new file mode 100644 index 0000000..a08e405 --- /dev/null +++ b/scenarios/worker-metrics/deployah.platform.yaml @@ -0,0 +1,11 @@ +# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json +apiVersion: platform/v1-alpha.3 +profiles: + observability: + metrics: + monitorLabels: + release: prometheus + interval: 30s +environments: + production: + context: prod diff --git a/scenarios/worker-metrics/deployah.yaml b/scenarios/worker-metrics/deployah.yaml new file mode 100644 index 0000000..9678e6d --- /dev/null +++ b/scenarios/worker-metrics/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: worker-metrics +components: + worker: + role: worker + image: ghcr.io/acme/worker:1.0.0 + resourcePreset: small + environments: [production] + profiles: [observability] + metrics: + port: 9090 + path: /metrics +environments: + production: {} diff --git a/scenarios/worker-metrics/expected/deployment-worker-metrics-production.yaml b/scenarios/worker-metrics/expected/deployment-worker-metrics-production.yaml new file mode 100644 index 0000000..caf46e5 --- /dev/null +++ b/scenarios/worker-metrics/expected/deployment-worker-metrics-production.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: worker-metrics + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-metrics + helm.sh/chart: worker-0.1.0 + name: worker-metrics-production + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/name: worker + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-metrics + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: ghcr.io/acme/worker:1.0.0 + imagePullPolicy: IfNotPresent + name: worker + ports: + - containerPort: 9090 + name: metrics + protocol: TCP + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60 diff --git a/scenarios/worker-metrics/expected/podmonitor-worker-metrics-production.yaml b/scenarios/worker-metrics/expected/podmonitor-worker-metrics-production.yaml new file mode 100644 index 0000000..6dcdaf2 --- /dev/null +++ b/scenarios/worker-metrics/expected/podmonitor-worker-metrics-production.yaml @@ -0,0 +1,30 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + annotations: + deployah.dev/project: worker-metrics + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: production + deployah.dev/project: worker-metrics + helm.sh/chart: worker-0.1.0 + release: prometheus + name: worker-metrics-production + namespace: default +spec: + namespaceSelector: + matchNames: + - default + podMetricsEndpoints: + - interval: 30s + path: /metrics + port: metrics + scrapeTimeout: 10s + selector: + matchLabels: + app.kubernetes.io/instance: worker-metrics-production + app.kubernetes.io/name: worker diff --git a/scenarios/worker-stateful/deployah.yaml b/scenarios/worker-stateful/deployah.yaml new file mode 100644 index 0000000..3a7c03d --- /dev/null +++ b/scenarios/worker-stateful/deployah.yaml @@ -0,0 +1,17 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: worker-stateful +components: + worker: + role: worker + kind: stateful + image: busybox:1.36 + command: ["sleep"] + args: ["infinity"] + resourcePreset: small + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/scenarios/worker-stateful/expected/service-worker-stateful-dev-headless.yaml b/scenarios/worker-stateful/expected/service-worker-stateful-dev-headless.yaml new file mode 100644 index 0000000..602ea5b --- /dev/null +++ b/scenarios/worker-stateful/expected/service-worker-stateful-dev-headless.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: worker-stateful + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateful + helm.sh/chart: worker-0.1.0 + name: worker-stateful-dev-headless + namespace: default +spec: + clusterIP: None + ports: + - name: identity + port: 9 + protocol: TCP + targetPort: identity + publishNotReadyAddresses: true + selector: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/name: worker + type: ClusterIP diff --git a/scenarios/worker-stateful/expected/statefulset-worker-stateful-dev.yaml b/scenarios/worker-stateful/expected/statefulset-worker-stateful-dev.yaml new file mode 100644 index 0000000..1e9d5af --- /dev/null +++ b/scenarios/worker-stateful/expected/statefulset-worker-stateful-dev.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + deployah.dev/project: worker-stateful + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateful + helm.sh/chart: worker-0.1.0 + name: worker-stateful-dev + namespace: default +spec: + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + podManagementPolicy: OrderedReady + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/name: worker + serviceName: worker-stateful-dev-headless + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateful + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - infinity + command: + - sleep + image: docker.io/library/busybox:1.36 + imagePullPolicy: IfNotPresent + name: worker + ports: + - containerPort: 9 + name: identity + protocol: TCP + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + volumeMounts: + - mountPath: /data + name: data + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60 + updateStrategy: + type: RollingUpdate + volumeClaimTemplates: + - metadata: + annotations: + deployah.dev/project: worker-stateful + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-stateful-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateful + helm.sh/chart: worker-0.1.0 + name: data + spec: + accessModes: + - ReadWriteOncePod + resources: + requests: + storage: 1Gi diff --git a/scenarios/worker-stateless/deployah.yaml b/scenarios/worker-stateless/deployah.yaml new file mode 100644 index 0000000..ab9995d --- /dev/null +++ b/scenarios/worker-stateless/deployah.yaml @@ -0,0 +1,13 @@ +# $schema: ../../internal/spec/schema/v1-alpha.4/manifest.json +apiVersion: v1-alpha.4 +project: worker-stateless +components: + worker: + role: worker + image: busybox:1.36 + command: ["sleep"] + args: ["infinity"] + resourcePreset: small + environments: [dev] +environments: + dev: {} diff --git a/scenarios/worker-stateless/expected/deployment-worker-stateless-dev.yaml b/scenarios/worker-stateless/expected/deployment-worker-stateless-dev.yaml new file mode 100644 index 0000000..0c1ed80 --- /dev/null +++ b/scenarios/worker-stateless/expected/deployment-worker-stateless-dev.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: worker-stateless + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-stateless-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateless + helm.sh/chart: worker-0.1.0 + name: worker-stateless-dev + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-stateless-dev + app.kubernetes.io/name: worker + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-stateless-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-stateless + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-stateless-dev + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - infinity + command: + - sleep + image: docker.io/library/busybox:1.36 + imagePullPolicy: IfNotPresent + name: worker + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60 From 4937e9de3d2ba976dfc021ac1b3df6df83caed13 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Mon, 10 Aug 2026 17:03:54 +0200 Subject: [PATCH 2/3] add tests for worker and metrics coverage - cover metrics unmarshal, defaults, validation, and resolve errors - exercise monitor profile fields and RequiredAPIs in plan/deploy --- internal/cmd/deploy/deploy_test.go | 10 +++ internal/cmd/plan/plan_test.go | 68 ++++++++++++++++++ internal/helm/generate_test.go | 75 ++++++++++++++++++++ internal/k8s/requirements_test.go | 110 +++++++++++++++++++++++++++++ internal/spec/defaults_test.go | 59 ++++++++++++++++ internal/spec/profile_test.go | 31 ++++++++ internal/spec/types_test.go | 93 ++++++++++++++++++++++++ internal/spec/validate_test.go | 42 +++++++++++ 8 files changed, 488 insertions(+) create mode 100644 internal/k8s/requirements_test.go diff --git a/internal/cmd/deploy/deploy_test.go b/internal/cmd/deploy/deploy_test.go index 3135620..1fd7855 100644 --- a/internal/cmd/deploy/deploy_test.go +++ b/internal/cmd/deploy/deploy_test.go @@ -242,6 +242,16 @@ func TestRequiredAPIs(t *testing.T) { environment: "staging", wantEmpty: true, }, + { + name: "metrics enabled requires prometheus operator API", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "api": {Metrics: &spec.ComponentMetrics{}}, + }, + }, + environment: "production", + wantContains: []string{"monitoring.coreos.com/v1"}, + }, } for _, tt := range tests { diff --git a/internal/cmd/plan/plan_test.go b/internal/cmd/plan/plan_test.go index 5f14adf..b6d0621 100644 --- a/internal/cmd/plan/plan_test.go +++ b/internal/cmd/plan/plan_test.go @@ -27,6 +27,8 @@ import ( "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/release/common" "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" "nabat.dev/nabat" "nabat.dev/nabat/nabattest" @@ -37,6 +39,7 @@ import ( planengine "deployah.dev/deployah/internal/plan" v1 "helm.sh/helm/v4/pkg/release/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // stubHelmClient implements [session.HelmClient] for plan command tests. @@ -116,6 +119,29 @@ func sessionWithStub(stub *stubHelmClient) *session.Session { })) } +// sessionWithStubAndK8s builds a session with Helm and Kubernetes stubs so +// runOnline can exercise API capability checks. +func sessionWithStubAndK8s(stub *stubHelmClient, k8sClient kubernetes.Interface) *session.Session { + return session.New( + session.WithHelmFactory(func(*session.Session) (session.HelmClient, error) { + return stub, nil + }), + session.WithKubernetesFactory(func(*session.Session) (kubernetes.Interface, error) { + return k8sClient, nil + }), + ) +} + +func fakeClientWithAPIs(groupVersions ...string) *fake.Clientset { + cs := fake.NewClientset() + resources := make([]*metav1.APIResourceList, 0, len(groupVersions)) + for _, gv := range groupVersions { + resources = append(resources, &metav1.APIResourceList{GroupVersion: gv}) + } + cs.Resources = resources + return cs +} + func releaseAt(version int, status common.Status, manifest string) *v1.Release { return &v1.Release{ Name: "web-production", @@ -482,3 +508,45 @@ spec: require.NoError(t, err) assert.Contains(t, out.String(), "CRDs: 1 pending from .deployah/crds/") } + +func TestRunOnline_MetricsRequiresPrometheusOperatorAPI(t *testing.T) { + t.Parallel() + + manifest := &spec.Spec{ + Project: "shop", + APIVersion: spec.CurrentManifestVersion, + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "api:1", + Port: 8080, + Metrics: &spec.ComponentMetrics{}, + }, + }, + } + t.Run("missing monitoring API fails plan", func(t *testing.T) { + t.Parallel() + stub := &stubHelmClient{ + historyErr: helm.ErrReleaseNotFound, + renderResult: renderResult(deploymentV1), + } + sess := sessionWithStubAndK8s(stub, fakeClientWithAPIs("v1")) + c, _ := nabatContext(t) + err := runOnline(c, sess, nil, manifest, testOptions(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "monitoring.coreos.com/v1") + }) + + t.Run("monitoring API present allows plan", func(t *testing.T) { + t.Parallel() + stub := &stubHelmClient{ + historyErr: helm.ErrReleaseNotFound, + renderResult: renderResult(deploymentV1), + } + sess := sessionWithStubAndK8s(stub, fakeClientWithAPIs("v1", "monitoring.coreos.com/v1")) + c, out := nabatContext(t) + err := runOnline(c, sess, nil, manifest, testOptions(), nil) + require.NoError(t, err) + assert.NotEmpty(t, out.String()) + }) +} diff --git a/internal/helm/generate_test.go b/internal/helm/generate_test.go index 29a24dc..d1dd44c 100644 --- a/internal/helm/generate_test.go +++ b/internal/helm/generate_test.go @@ -1146,6 +1146,81 @@ func TestMapSpecToChartValues_WorkerMetrics(t *testing.T) { assert.Equal(t, map[string]string{"release": "kube-prometheus-stack"}, pm["labels"]) } +func TestMapSpecToChartValues_MonitorProfileFields(t *testing.T) { + t.Parallel() + honor := true + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "ghcr.io/acme/api:1.0.0", + Port: 8080, + Metrics: &spec.ComponentMetrics{ + Port: 9090, + Interval: "10s", + ScrapeTimeout: "5s", + }, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + profile := &spec.PlatformProfile{Metrics: &spec.ProfileMetrics{ + MonitorLabels: map[string]string{"release": "prom"}, + MonitorNamespace: "monitoring", + Interval: "30s", + ScrapeTimeout: "10s", + JobLabel: "app", + HonorLabels: &honor, + Annotations: map[string]string{"team": "obs"}, + Relabelings: []any{map[string]any{"action": "keep"}}, + MetricRelabelings: []any{map[string]any{"action": "drop"}}, + }} + resolved := &spec.ResolvedSpec{Components: map[string]spec.ResolvedComponent{ + "api": {MergedProfile: profile}, + }} + values, err := MapSpecToChartValues(manifest, "dev", resolved) + require.NoError(t, err) + sm := mustNestedMap(t, mustNestedMap(t, values, "api"), "serviceMonitor") + assert.Equal(t, true, sm["enabled"]) + assert.Equal(t, "monitoring", sm["namespace"]) + assert.Equal(t, "10s", sm["interval"], "component interval overrides profile") + assert.Equal(t, "5s", sm["scrapeTimeout"], "component scrapeTimeout overrides profile") + assert.Equal(t, "app", sm["jobLabel"]) + assert.Equal(t, true, sm["honorLabels"]) + assert.Equal(t, map[string]string{"team": "obs"}, sm["annotations"]) + assert.Equal(t, map[string]string{"release": "prom"}, sm["labels"]) + require.Len(t, sm["relabelings"], 1) + require.Len(t, sm["metricRelabelings"], 1) +} + +func TestMapSpecToChartValues_NilProfileMetricsSkipped(t *testing.T) { + t.Parallel() + manifest := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "ghcr.io/acme/api:1.0.0", + Port: 8080, + Metrics: &spec.ComponentMetrics{}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + resolved := &spec.ResolvedSpec{Components: map[string]spec.ResolvedComponent{ + "api": {MergedProfile: &spec.PlatformProfile{}}, + }} + values, err := MapSpecToChartValues(manifest, "dev", resolved) + require.NoError(t, err) + sm := mustNestedMap(t, mustNestedMap(t, values, "api"), "serviceMonitor") + assert.Equal(t, true, sm["enabled"]) + assert.Nil(t, sm["labels"]) + assert.Nil(t, sm["namespace"]) +} + func TestMapSpecToChartValues_ServiceMetricsDedicatedPort(t *testing.T) { t.Parallel() manifest := &spec.Spec{ diff --git a/internal/k8s/requirements_test.go b/internal/k8s/requirements_test.go new file mode 100644 index 0000000..c4b2df7 --- /dev/null +++ b/internal/k8s/requirements_test.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/spec" +) + +func TestRequiredAPIs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest *spec.Spec + environment string + resolved *spec.ResolvedSpec + wantGVs []string + wantEmpty bool + }{ + { + name: "metrics enabled", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "worker": { + Role: spec.ComponentRoleWorker, + Metrics: &spec.ComponentMetrics{Port: 9090}, + }, + }, + }, + environment: "production", + wantGVs: []string{"monitoring.coreos.com/v1"}, + }, + { + name: "expose requires networking", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "api": {Expose: &spec.Expose{}}, + }, + }, + environment: "production", + wantGVs: []string{"networking.k8s.io/v1"}, + }, + { + name: "inactive environment filter skips requirements", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "api": { + Environments: []string{"staging"}, + Metrics: &spec.ComponentMetrics{}, + }, + }, + }, + environment: "production", + wantEmpty: true, + }, + { + name: "cert-manager from resolved TLS mode", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "api": {Expose: &spec.Expose{}}, + }, + }, + environment: "production", + resolved: &spec.ResolvedSpec{ + Components: map[string]spec.ResolvedComponent{ + "api": {TLSMode: spec.TLSModeCertManager}, + }, + }, + wantGVs: []string{"networking.k8s.io/v1", "cert-manager.io/v1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reqs := RequiredAPIs(tt.manifest, tt.environment, tt.resolved) + if tt.wantEmpty { + assert.Empty(t, reqs) + return + } + got := map[string]struct{}{} + for _, req := range reqs { + for _, gv := range req.GroupVersions { + got[gv] = struct{}{} + } + } + for _, gv := range tt.wantGVs { + _, ok := got[gv] + require.True(t, ok, "missing GroupVersion %s in %#v", gv, reqs) + } + }) + } +} diff --git a/internal/spec/defaults_test.go b/internal/spec/defaults_test.go index d7dba08..7e9cc91 100644 --- a/internal/spec/defaults_test.go +++ b/internal/spec/defaults_test.go @@ -919,6 +919,65 @@ func TestDefaultValuesCopy(t *testing.T) { assert.NotEqual(t, original, copied) } +// TestFillSpecWithDefaults_RoleDependentDefaults covers shutdownTimeout, +// worker port omission, and metrics path/port defaults that schema alone +// cannot express. +func TestFillSpecWithDefaults_RoleDependentDefaults(t *testing.T) { + t.Parallel() + + t.Run("empty role becomes service with service defaults", func(t *testing.T) { + t.Parallel() + m := &Spec{ + APIVersion: CurrentManifestVersion, + Project: "shop", + Components: map[string]Component{ + "api": {Image: "api:1"}, + }, + } + require.NoError(t, FillSpecWithDefaults(m, CurrentManifestVersion)) + api := m.Components["api"] + assert.Equal(t, ComponentRoleService, api.Role) + assert.Equal(t, DefaultServiceShutdownTimeout, api.ShutdownTimeout) + assert.Equal(t, 8080, api.Port) + }) + + t.Run("worker gets longer shutdown and no default port", func(t *testing.T) { + t.Parallel() + m := &Spec{ + APIVersion: CurrentManifestVersion, + Project: "shop", + Components: map[string]Component{ + "worker": {Role: ComponentRoleWorker, Image: "worker:1"}, + }, + } + require.NoError(t, FillSpecWithDefaults(m, CurrentManifestVersion)) + w := m.Components["worker"] + assert.Equal(t, DefaultWorkerShutdownTimeout, w.ShutdownTimeout) + assert.Equal(t, 0, w.Port) + }) + + t.Run("service metrics fills path and port from component port", func(t *testing.T) { + t.Parallel() + m := &Spec{ + APIVersion: CurrentManifestVersion, + Project: "shop", + Components: map[string]Component{ + "api": { + Role: ComponentRoleService, + Image: "api:1", + Port: 8080, + Metrics: &ComponentMetrics{}, + }, + }, + } + require.NoError(t, FillSpecWithDefaults(m, CurrentManifestVersion)) + api := m.Components["api"] + require.NotNil(t, api.Metrics) + assert.Equal(t, DefaultMetricsPath, api.Metrics.Path) + assert.Equal(t, 8080, api.Metrics.Port) + }) +} + // TestFillSpecWithDefaults_GuardClauses verifies the nil-spec and // empty-version guard clauses, which [TestFillSpecWithDefaults] above does // not exercise. diff --git a/internal/spec/profile_test.go b/internal/spec/profile_test.go index 3c051d7..5c5cd7c 100644 --- a/internal/spec/profile_test.go +++ b/internal/spec/profile_test.go @@ -525,3 +525,34 @@ func TestValidateProfileAgainstComponent_MonitorLabelsRequired(t *testing.T) { require.ErrorAs(t, err, &re) assert.Equal(t, spec.ErrCodeProfileMonitorLabelsMissing, re.Code) } + +func TestResolve_MetricsWithoutProfileErrors(t *testing.T) { + t.Parallel() + appSpec := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Environments: map[string]spec.Environment{"production": {}}, + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "api:1", + Port: 8080, + Metrics: &spec.ComponentMetrics{}, + }, + }, + } + platform := &spec.PlatformConfig{ + APIVersion: spec.CurrentPlatformVersion, + Environments: map[string]spec.PlatformEnvironment{ + "production": {Context: "prod"}, + }, + } + env := spec.NormalizeEnv("production") + _, report, err := spec.Resolve(appSpec, platform, env, spec.SubstitutionReport{}) + require.Error(t, err) + var re *spec.ResolutionError + require.ErrorAs(t, err, &re) + assert.Equal(t, spec.ErrCodeProfileMonitorLabelsMissing, re.Code) + require.NotNil(t, report) + assert.Equal(t, spec.ErrCodeProfileMonitorLabelsMissing, report.ErrorCode) +} diff --git a/internal/spec/types_test.go b/internal/spec/types_test.go index 1a56102..aa7ba57 100644 --- a/internal/spec/types_test.go +++ b/internal/spec/types_test.go @@ -15,6 +15,7 @@ package spec import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -40,6 +41,16 @@ func TestComponentRole_IsService(t *testing.T) { assert.False(t, ComponentRoleJob.IsService()) } +// TestComponentRole_IsWorker verifies only the "worker" role is treated +// as a worker. +func TestComponentRole_IsWorker(t *testing.T) { + t.Parallel() + + assert.True(t, ComponentRoleWorker.IsWorker()) + assert.False(t, ComponentRoleService.IsWorker()) + assert.False(t, ComponentRoleJob.IsWorker()) +} + // TestComponent_ListensOnPort verifies a component listens on a port only // when it has both the service role and a positive port. func TestComponent_ListensOnPort(t *testing.T) { @@ -259,4 +270,86 @@ image: my-app:latest `) assert.Nil(t, c.Health) }) + + t.Run("object with exec", func(t *testing.T) { + t.Parallel() + c := unmarshalComponent(t, ` +health: + alive: + exec: [pgrep, -f, worker] +`) + require.NotNil(t, c.Health) + require.NotNil(t, c.Health.Alive) + assert.Equal(t, []string{"pgrep", "-f", "worker"}, c.Health.Alive.Exec) + assert.Empty(t, c.Health.Alive.Path) + }) +} + +// TestComponentMetrics_UnmarshalJSON covers true/false/object forms and +// reject paths for metrics dual-parse. +func TestComponentMetrics_UnmarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("true enables with defaults", func(t *testing.T) { + t.Parallel() + c := unmarshalComponent(t, "metrics: true\n") + require.NotNil(t, c.Metrics) + assert.False(t, c.Metrics.Disabled) + assert.True(t, c.Metrics.IsEnabled()) + }) + + t.Run("false disables", func(t *testing.T) { + t.Parallel() + c := unmarshalComponent(t, "metrics: false\n") + require.NotNil(t, c.Metrics) + assert.True(t, c.Metrics.Disabled) + assert.False(t, c.Metrics.IsEnabled()) + }) + + t.Run("object form", func(t *testing.T) { + t.Parallel() + c := unmarshalComponent(t, ` +metrics: + port: 9090 + path: /metrics + interval: 15s +`) + require.NotNil(t, c.Metrics) + assert.Equal(t, 9090, c.Metrics.Port) + assert.Equal(t, "/metrics", c.Metrics.Path) + assert.Equal(t, "15s", c.Metrics.Interval) + assert.True(t, c.Metrics.IsEnabled()) + }) + + t.Run("enabled false in object", func(t *testing.T) { + t.Parallel() + c := unmarshalComponent(t, ` +metrics: + enabled: false + port: 9090 +`) + require.NotNil(t, c.Metrics) + assert.False(t, c.Metrics.IsEnabled()) + }) + + t.Run("invalid scalar rejected", func(t *testing.T) { + t.Parallel() + var m ComponentMetrics + err := json.Unmarshal([]byte(`"nope"`), &m) + require.Error(t, err) + assert.Contains(t, err.Error(), "metrics:") + }) +} + +// TestComponentMetrics_IsEnabled covers nil and pointer-enabled branches. +func TestComponentMetrics_IsEnabled(t *testing.T) { + t.Parallel() + + assert.False(t, (*ComponentMetrics)(nil).IsEnabled()) + assert.False(t, (&ComponentMetrics{Disabled: true}).IsEnabled()) + enabled := false + assert.False(t, (&ComponentMetrics{Enabled: &enabled}).IsEnabled()) + enabled = true + assert.True(t, (&ComponentMetrics{Enabled: &enabled}).IsEnabled()) + assert.True(t, (&ComponentMetrics{}).IsEnabled()) } diff --git a/internal/spec/validate_test.go b/internal/spec/validate_test.go index 81fa417..44c5177 100644 --- a/internal/spec/validate_test.go +++ b/internal/spec/validate_test.go @@ -546,6 +546,17 @@ func TestValidateComponentHealth(t *testing.T) { expectErr: true, errMsg: "mutually exclusive", }, + { + name: "empty exec part is invalid", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{ + Alive: &HealthAlive{Exec: []string{"pgrep", " "}}, + }, + }, + expectErr: true, + errMsg: "health.alive.exec[1] must not be empty", + }, { name: "health on job role is invalid", component: Component{ @@ -783,6 +794,33 @@ func TestValidateComponentMetrics(t *testing.T) { Metrics: &ComponentMetrics{Disabled: true}, }, }, + { + name: "invalid metrics interval", + component: Component{ + Role: ComponentRoleService, + Metrics: &ComponentMetrics{Interval: "not-a-duration"}, + }, + expectErr: true, + errMsg: "metrics.interval", + }, + { + name: "invalid metrics scrapeTimeout", + component: Component{ + Role: ComponentRoleService, + Metrics: &ComponentMetrics{ScrapeTimeout: "nope"}, + }, + expectErr: true, + errMsg: "metrics.scrapeTimeout", + }, + { + name: "metrics port out of range", + component: Component{ + Role: ComponentRoleService, + Metrics: &ComponentMetrics{Port: 70000}, + }, + expectErr: true, + errMsg: "metrics.port must be between 1 and 65535", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -805,4 +843,8 @@ func TestValidateComponentShutdownTimeout(t *testing.T) { err := ValidateComponentShutdownTimeout(Component{ShutdownTimeout: "0s"}) require.Error(t, err) assert.Contains(t, err.Error(), "shutdownTimeout") + + err = ValidateComponentShutdownTimeout(Component{ShutdownTimeout: "not-a-duration"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "shutdownTimeout") } From 7541b4f4b547ade1283b9b9856a2e934b50d7d9e Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Mon, 10 Aug 2026 22:12:59 +0200 Subject: [PATCH 3/3] raise coverage for worker and metrics paths - extract init apply helpers and non-interactive defaults - cover helm generate and RequiredAPIs edge cases - add health restartAfter and role-default tests --- internal/cmd/initialize/components.go | 42 +++++++++--- internal/cmd/initialize/components_test.go | 69 ++++++++++++++++++++ internal/helm/generate_test.go | 74 ++++++++++++++++++++++ internal/k8s/requirements_test.go | 36 +++++++++-- internal/spec/defaults_test.go | 9 +++ internal/spec/profile_test.go | 2 + internal/spec/validate_test.go | 28 ++++++++ 7 files changed, 246 insertions(+), 14 deletions(-) diff --git a/internal/cmd/initialize/components.go b/internal/cmd/initialize/components.go index 8832937..9101126 100644 --- a/internal/cmd/initialize/components.go +++ b/internal/cmd/initialize/components.go @@ -237,14 +237,8 @@ func collectComponentEssentials(c *nabat.Context, component *spec.Component, com } if component.Role.IsWorker() { - if err := collectComponentCommand(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component command: %w", err) - } - if err := collectComponentArgs(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component args: %w", err) - } - if err := collectComponentMetricsPort(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component metrics port: %w", err) + if err := collectWorkerEssentials(c, component, componentName); err != nil { + return err } } @@ -268,6 +262,7 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo fmt.Sprintf("Configure advanced options for %s?", componentName), nabat.WithAffirmative("Yes"), nabat.WithNegative("No, use defaults"), + nabat.WithDefault(false), ) if err != nil { return fmt.Errorf("failed to get advanced options preference: %w", err) @@ -348,6 +343,21 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo return nil } +// collectWorkerEssentials asks worker-only essentials: command, args, and +// optional metrics port. +func collectWorkerEssentials(c *nabat.Context, component *spec.Component, componentName string) error { + if err := collectComponentCommand(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component command: %w", err) + } + if err := collectComponentArgs(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component args: %w", err) + } + if err := collectComponentMetricsPort(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component metrics port: %w", err) + } + return nil +} + func collectComponentRole(c *nabat.Context, component *spec.Component, componentName string) error { labels := make([]string, 0, len(roleOrder)) for _, r := range roleOrder { @@ -584,6 +594,7 @@ func collectComponentCommand(c *nabat.Context, component *spec.Component, compon fmt.Sprintf("Add custom command for %s? Would you like to override the container's default command?", componentName), nabat.WithAffirmative("Yes"), nabat.WithNegative("No, use image default"), + nabat.WithDefault(false), ) if err != nil { return fmt.Errorf("failed to get command preference: %w", err) @@ -615,6 +626,7 @@ func collectComponentArgs(c *nabat.Context, component *spec.Component, component fmt.Sprintf("Add arguments for %s? Would you like to add arguments to the command?", componentName), nabat.WithAffirmative("Yes"), nabat.WithNegative("No"), + nabat.WithDefault(false), ) if err != nil { return fmt.Errorf("failed to get args preference: %w", err) @@ -827,6 +839,7 @@ func collectComponentMetricsPort(c *nabat.Context, component *spec.Component, co fmt.Sprintf("Expose Prometheus metrics for %s?", componentName), nabat.WithAffirmative("Yes"), nabat.WithNegative("No"), + nabat.WithDefault(false), ) if err != nil { return fmt.Errorf("failed to collect metrics preference: %w", err) @@ -843,6 +856,11 @@ func collectComponentMetricsPort(c *nabat.Context, component *spec.Component, co if err != nil { return fmt.Errorf("failed to collect metrics port: %w", err) } + return applyCollectedMetricsPort(component, portStr) +} + +// applyCollectedMetricsPort sets metrics.port from a wizard answer. +func applyCollectedMetricsPort(component *spec.Component, portStr string) error { port, err := strconv.Atoi(portStr) if err != nil { return fmt.Errorf("invalid metrics port: %w", err) @@ -858,6 +876,7 @@ func collectComponentExecHealth(c *nabat.Context, component *spec.Component, com fmt.Sprintf("Add an exec alive check for %s? (default is process-exit only)", componentName), nabat.WithAffirmative("Yes"), nabat.WithNegative("No"), + nabat.WithDefault(false), ) if err != nil { return fmt.Errorf("failed to collect exec health preference: %w", err) @@ -868,6 +887,7 @@ func collectComponentExecHealth(c *nabat.Context, component *spec.Component, com cmdStr, err := c.Input( fmt.Sprintf("Alive exec command for %s (space-separated)", componentName), nabat.WithHint("pgrep -f worker"), + nabat.WithDefault("pgrep -f worker"), nabat.WithValidate(func(s string) error { return validate.ValidateNonEmpty(s, "exec command") }), @@ -875,6 +895,12 @@ func collectComponentExecHealth(c *nabat.Context, component *spec.Component, com if err != nil { return fmt.Errorf("failed to collect exec health command: %w", err) } + return applyCollectedExecHealth(component, cmdStr) +} + +// applyCollectedExecHealth sets health.alive.exec from a space-separated +// wizard answer. +func applyCollectedExecHealth(component *spec.Component, cmdStr string) error { parts := strings.Fields(cmdStr) if len(parts) == 0 { return fmt.Errorf("exec command must not be empty") diff --git a/internal/cmd/initialize/components_test.go b/internal/cmd/initialize/components_test.go index f78d165..72fb44a 100644 --- a/internal/cmd/initialize/components_test.go +++ b/internal/cmd/initialize/components_test.go @@ -6,10 +6,19 @@ import ( "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nabat.dev/nabat" + "nabat.dev/nabat/nabattest" "deployah.dev/deployah/internal/spec" ) +func nonInteractiveContext(t *testing.T) *nabat.Context { + t.Helper() + io, _, _, _ := nabattest.NewIO() + app := nabat.MustNew("test", nabat.WithIO(io)) + return nabattest.Context(t, app) +} + // TestPresetLabel verifies preset labels include the preset name and its // request-level CPU/memory values, so the merged resources select is // self-explanatory without consulting docs. @@ -173,3 +182,63 @@ func TestShlexSplit_QuotedArguments(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"hello world", "--flag"}, tokens) } + +func TestCollectWorkerEssentials_DefaultsSkipOptionalFields(t *testing.T) { + t.Parallel() + c := nonInteractiveContext(t) + comp := &spec.Component{Role: spec.ComponentRoleWorker, Image: "worker:1"} + require.NoError(t, collectWorkerEssentials(c, comp, "worker")) + assert.Nil(t, comp.Command) + assert.Nil(t, comp.Args) + assert.Nil(t, comp.Metrics) +} + +func TestCollectComponentMetricsPort_DefaultDeclines(t *testing.T) { + t.Parallel() + c := nonInteractiveContext(t) + comp := &spec.Component{Role: spec.ComponentRoleWorker} + require.NoError(t, collectComponentMetricsPort(c, comp, "worker")) + assert.Nil(t, comp.Metrics) +} + +func TestCollectComponentExecHealth_DefaultDeclines(t *testing.T) { + t.Parallel() + c := nonInteractiveContext(t) + comp := &spec.Component{Role: spec.ComponentRoleWorker} + require.NoError(t, collectComponentExecHealth(c, comp, "worker")) + assert.Nil(t, comp.Health) +} + +func TestCollectComponentAdvanced_DefaultSkips(t *testing.T) { + t.Parallel() + c := nonInteractiveContext(t) + comp := &spec.Component{Role: spec.ComponentRoleWorker, Image: "worker:1"} + require.NoError(t, collectComponentAdvanced(c, comp, "worker", []string{"dev"})) + assert.Empty(t, comp.Kind) + assert.Nil(t, comp.Health) +} + +func TestApplyCollectedMetricsPort(t *testing.T) { + t.Parallel() + comp := &spec.Component{} + require.NoError(t, applyCollectedMetricsPort(comp, "9090")) + require.NotNil(t, comp.Metrics) + assert.Equal(t, 9090, comp.Metrics.Port) + + err := applyCollectedMetricsPort(comp, "nope") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid metrics port") +} + +func TestApplyCollectedExecHealth(t *testing.T) { + t.Parallel() + comp := &spec.Component{} + require.NoError(t, applyCollectedExecHealth(comp, "pgrep -f worker")) + require.NotNil(t, comp.Health) + require.NotNil(t, comp.Health.Alive) + assert.Equal(t, []string{"pgrep", "-f", "worker"}, comp.Health.Alive.Exec) + + err := applyCollectedExecHealth(comp, " ") + require.Error(t, err) + assert.Contains(t, err.Error(), "exec command must not be empty") +} diff --git a/internal/helm/generate_test.go b/internal/helm/generate_test.go index d1dd44c..16cbb8b 100644 --- a/internal/helm/generate_test.go +++ b/internal/helm/generate_test.go @@ -1283,3 +1283,77 @@ func TestMapSpecToChartValues_WorkerExecHealth(t *testing.T) { exec := mustNestedMap(t, liveness, "exec") assert.Equal(t, []any{"pgrep", "-f", "worker"}, exec["command"]) } + +func TestApplyPortsAndService_ServiceMetricsDefaultsToAppPort(t *testing.T) { + t.Parallel() + vals := map[string]any{} + err := applyPortsAndService(vals, spec.Component{ + Role: spec.ComponentRoleService, + Port: 8080, + Metrics: &spec.ComponentMetrics{}, // port 0 -> use app port, no dedicated metrics port + }) + require.NoError(t, err) + ports, ok := vals["ports"].([]map[string]any) + require.True(t, ok) + require.Len(t, ports, 1) + assert.Equal(t, "http", ports[0]["name"]) +} + +func TestApplyPortsAndService_ServiceWithoutPortIsNoop(t *testing.T) { + t.Parallel() + vals := map[string]any{} + require.NoError(t, applyPortsAndService(vals, spec.Component{ + Role: spec.ComponentRoleService, + Port: 0, + })) + assert.Empty(t, vals) +} + +func TestApplyShutdownTimeout_InvalidDuration(t *testing.T) { + t.Parallel() + err := applyShutdownTimeout(map[string]any{}, spec.Component{ShutdownTimeout: "nope"}) + require.Error(t, err) +} + +func TestBuildProbeValues_WorkerAliveDisabled(t *testing.T) { + t.Parallel() + probes, err := buildProbeValues(spec.Component{ + Role: spec.ComponentRoleWorker, + Health: &spec.Health{Alive: &spec.HealthAlive{Disabled: true}}, + }) + require.NoError(t, err) + assert.Empty(t, probes) +} + +func TestBuildProbeValues_WorkerWithoutExec(t *testing.T) { + t.Parallel() + probes, err := buildProbeValues(spec.Component{Role: spec.ComponentRoleWorker}) + require.NoError(t, err) + assert.Empty(t, probes) +} + +func TestCopyMonitorProfile_RelabelingsMustBeSlice(t *testing.T) { + t.Parallel() + monitor := map[string]any{} + err := copyMonitorProfile(monitor, &spec.PlatformProfile{ + Metrics: &spec.ProfileMetrics{ + Relabelings: []any{make(chan int)}, // not JSON-marshalable + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "relabelings") +} + +func TestApplyMetricsValues_CopyProfileError(t *testing.T) { + t.Parallel() + err := applyMetricsValues(map[string]any{}, spec.Component{ + Role: spec.ComponentRoleService, + Port: 8080, + Metrics: &spec.ComponentMetrics{Path: "/metrics"}, + }, &spec.PlatformProfile{ + Metrics: &spec.ProfileMetrics{ + MetricRelabelings: []any{make(chan int)}, + }, + }) + require.Error(t, err) +} diff --git a/internal/k8s/requirements_test.go b/internal/k8s/requirements_test.go index c4b2df7..4523450 100644 --- a/internal/k8s/requirements_test.go +++ b/internal/k8s/requirements_test.go @@ -27,12 +27,14 @@ func TestRequiredAPIs(t *testing.T) { t.Parallel() tests := []struct { - name string - manifest *spec.Spec - environment string - resolved *spec.ResolvedSpec - wantGVs []string - wantEmpty bool + name string + manifest *spec.Spec + environment string + resolved *spec.ResolvedSpec + wantGVs []string + wantEmpty bool + wantReasonContains string + wantReasonForGroupV string }{ { name: "metrics enabled", @@ -85,6 +87,25 @@ func TestRequiredAPIs(t *testing.T) { }, wantGVs: []string{"networking.k8s.io/v1", "cert-manager.io/v1"}, }, + { + name: "autoscaling and shared monitoring noun is plural", + manifest: &spec.Spec{ + Components: map[string]spec.Component{ + "api": { + Autoscaling: &spec.Autoscaling{Enabled: true}, + Metrics: &spec.ComponentMetrics{}, + }, + "worker": { + Role: spec.ComponentRoleWorker, + Metrics: &spec.ComponentMetrics{Port: 9090}, + }, + }, + }, + environment: "production", + wantGVs: []string{"autoscaling/v2", "monitoring.coreos.com/v1"}, + wantReasonContains: "components", + wantReasonForGroupV: "monitoring.coreos.com/v1", + }, } for _, tt := range tests { @@ -99,6 +120,9 @@ func TestRequiredAPIs(t *testing.T) { for _, req := range reqs { for _, gv := range req.GroupVersions { got[gv] = struct{}{} + if tt.wantReasonForGroupV != "" && gv == tt.wantReasonForGroupV { + assert.Contains(t, req.Reason, tt.wantReasonContains) + } } } for _, gv := range tt.wantGVs { diff --git a/internal/spec/defaults_test.go b/internal/spec/defaults_test.go index 7e9cc91..f89f6ea 100644 --- a/internal/spec/defaults_test.go +++ b/internal/spec/defaults_test.go @@ -919,6 +919,15 @@ func TestDefaultValuesCopy(t *testing.T) { assert.NotEqual(t, original, copied) } +func TestApplyRoleDependentDefaults_EmptyRole(t *testing.T) { + t.Parallel() + c := &Component{Image: "api:1"} + applyRoleDependentDefaults(c) + assert.Equal(t, ComponentRoleService, c.Role) + assert.Equal(t, DefaultServiceShutdownTimeout, c.ShutdownTimeout) + assert.Equal(t, 8080, c.Port) +} + // TestFillSpecWithDefaults_RoleDependentDefaults covers shutdownTimeout, // worker port omission, and metrics path/port defaults that schema alone // cannot express. diff --git a/internal/spec/profile_test.go b/internal/spec/profile_test.go index 5c5cd7c..5008673 100644 --- a/internal/spec/profile_test.go +++ b/internal/spec/profile_test.go @@ -493,6 +493,7 @@ func TestMergeProfiles_MonitorFields(t *testing.T) { Metrics: &spec.ProfileMetrics{ MonitorLabels: map[string]string{"release": "b", "team": "obs"}, Interval: "15s", + ScrapeTimeout: "5s", JobLabel: "app", HonorLabels: &honorTrue, MetricRelabelings: []any{map[string]any{"action": "drop"}}, @@ -506,6 +507,7 @@ func TestMergeProfiles_MonitorFields(t *testing.T) { assert.Equal(t, map[string]string{"release": "b", "team": "obs"}, merged.Metrics.MonitorLabels) assert.Equal(t, "monitoring", merged.Metrics.MonitorNamespace) assert.Equal(t, "15s", merged.Metrics.Interval) + assert.Equal(t, "5s", merged.Metrics.ScrapeTimeout) assert.Equal(t, "app", merged.Metrics.JobLabel) require.NotNil(t, merged.Metrics.HonorLabels) assert.True(t, *merged.Metrics.HonorLabels) diff --git a/internal/spec/validate_test.go b/internal/spec/validate_test.go index 44c5177..3bf6e4b 100644 --- a/internal/spec/validate_test.go +++ b/internal/spec/validate_test.go @@ -613,6 +613,34 @@ func TestValidateComponentHealth(t *testing.T) { expectErr: true, errMsg: "must be greater than or equal to", }, + { + name: "alive invalid restartAfter format", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{ + Alive: &HealthAlive{ + Exec: []string{"true"}, + RestartAfter: "10", + }, + }, + }, + expectErr: true, + errMsg: "health.alive.restartAfter", + }, + { + name: "alive restartAfter below default interval when interval omitted", + component: Component{ + Role: ComponentRoleWorker, + Health: &Health{ + Alive: &HealthAlive{ + Exec: []string{"true"}, + RestartAfter: "1s", + }, + }, + }, + expectErr: true, + errMsg: "must be greater than or equal to", + }, { name: "alive restartAfter equal to interval is valid", component: Component{