diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml index a4d5a74..5c970c3 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -74,7 +74,7 @@ body: label: Relevant config description: Minimal deployah.yaml (and platform file if used). Redact secrets. placeholder: | - apiVersion: v1-alpha.2 + apiVersion: v1-alpha.3 project: my-first-app components: web: diff --git a/.golangci.yaml b/.golangci.yaml index e745b3f..dc97e0b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -242,14 +242,14 @@ linters: godoclint: default: all + disable: + # staticcheck ST1021/ST1022: comment should start with symbol name. + - start-with-name + # staticcheck ST1020: exported identifiers must have a doc comment. + - require-doc options: max-len: length: 80 - disable: - # staticcheck ST1021/ST1022: comment should start with symbol name. - - start-with-name - # staticcheck ST1020: exported identifiers must have a doc comment. - - require-doc gocritic: enabled-tags: @@ -276,8 +276,10 @@ linters: - set-status staticcheck: - # Enable all checks (SA*, S*, ST*, QF*) - checks: ["all"] + # Enable all checks (SA*, S*, ST*, QF*). + # SA4023: false positives on nabat Context.Form (can return nil) with the + # Go 1.27 staticcheck snapshot; re-enable when upstream settles. + checks: ["all", "-SA4023"] nolintlint: # Fail unused //nolint and require naming the suppressed linter (no bare //nolint). diff --git a/README.md b/README.md index 86bede2..5060186 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ but for the deploy step: S2I builds your image, and Deployah runs your release. - [How Deployah works](#how-deployah-works) - [Concepts](#concepts) - [Writing your spec](#writing-your-spec) +- [Stateful workloads](#stateful-workloads) - [Platform file](#platform-file) - [Profiles](#profiles) - [Health checks](#health-checks) @@ -108,7 +109,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.2 +apiVersion: v1-alpha.3 project: my-first-app components: web: @@ -210,13 +211,14 @@ A few words you will see often. - `service`: it serves traffic and can be exposed (the default). - `worker`: a long-running background task, not exposed. - `job`: a one-off task that runs and then stops. -- **Kind.** `stateless` (the default, easy to scale) or `stateful` (needs - persistent storage). Platform teams can already declare storage classes for - when stateful deploys land; see [Storage classes](#storage-classes). -- **What deploys today.** Deployah currently deploys `stateless` `service` - components. The `worker` and `job` roles and the `stateful` kind are in the - schema but are not deployable yet, so a deploy that uses them stops with a - "not supported yet" error. +- **Kind.** `stateless` (the default, easy to scale) or `stateful` + (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. - **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 @@ -259,7 +261,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.2 # required: the schema version +apiVersion: v1-alpha.3 # required: the schema version project: shop # required: your project name components: # required: one or more components @@ -307,7 +309,7 @@ Top level: | Field | Required | Notes | |---|---|---| -| `apiVersion` | Yes | The schema version. Must be `v1-alpha.2`. | +| `apiVersion` | Yes | The schema version. Must be `v1-alpha.3`. | | `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`. | @@ -325,17 +327,19 @@ Component: | `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). | +| `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`. | | `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`, -> `kind: stateful`, and the `env`, `envFile`, and `configFile` fields, but -> Deployah does not apply them at deploy time yet. Today, deploy a -> `stateless` `service` using `image`, `port`, `resources` or -> `resourcePreset`, `expose`, `autoscaling`, and `profiles`. +> 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`. Environment: @@ -417,7 +421,7 @@ Every example below is complete and valid. Copy one and change the values. **Smallest spec.** One service, one environment. ```yaml -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: hello components: web: @@ -430,7 +434,7 @@ environments: **Two components.** A web app and an API in one project. ```yaml -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -449,7 +453,7 @@ environments: from the platform file, not from here. ```yaml -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -471,7 +475,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.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -484,7 +488,7 @@ components: **Set exact resources.** Use `resources` instead of a preset. ```yaml -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -501,7 +505,7 @@ environments: **Autoscale on CPU.** Scale between 2 and 6 replicas at 70% CPU. ```yaml -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -519,6 +523,156 @@ environments: prod: {} ``` +## Stateful workloads + +Use `kind: stateful` when a component needs **stable network identity** +(ordinal hostnames, headless DNS, ordered start/scale), and optionally a +**per-pod volume**: single-writer stores, disk-backed caches or queues, or +any process that must remount the same PVC after a restart. + +Deployah models this as a Kubernetes StatefulSet, a ClusterIP Service, and a +headless Service (`...-headless`). When you set `persistence`, each replica +gets its own `volumeClaimTemplates` PVC. For highly available databases with +operator-managed failover, prefer a dedicated operator or managed service. +Deployah gives you a solid StatefulSet (and PVCs when requested); it does not +replace PostgresOperator, CloudNativePG, or similar. + +Stateful components **with persistence** require **Kubernetes 1.32 or +newer**. Deployah checks the cluster API version before deploy and fails fast +on older clusters. That floor matches stable `ReadWriteOncePod` and PVC +retention support. Identity-only stateful components (no `persistence`) do +not require that floor. + +### Persistence and replicas + +`persistence` is optional. Omit it for identity-only StatefulSets. When set, +`size` and `mountPath` are required: + +```yaml +apiVersion: v1-alpha.3 +project: shop +components: + # Identity only: stable DNS / ordinals, no PVC + peer: + kind: stateful + image: ghcr.io/acme/peer:1.0.0 + port: 8080 + resourcePreset: nano + environments: [dev] + replicas: 3 + # Per-pod durable volume + cache: + kind: stateful + image: redis:7-alpine + port: 6379 + resourcePreset: nano + environments: [dev] + replicas: 1 + persistence: + size: 20Gi + mountPath: /data + # storageClass: fast # optional logical key; see Storage classes +environments: + dev: {} +``` + +| Field | Required | Notes | +|---|---|---| +| `persistence.size` | When `persistence` is set | Kubernetes quantity (`1Gi`, `20Gi`, ...). | +| `persistence.mountPath` | When `persistence` is set | Absolute path inside the container. | +| `persistence.storageClass` | No | Logical key from the platform environment `storageClasses` map. Overrides the profile `storageClass` when set. | +| `replicas` | No | Desired StatefulSet replicas (default `1`). Cannot be set together with `autoscaling.enabled`. | + +Autoscaling is allowed on stateful components when you omit `replicas`. Plan +and deploy warn that scale-down may leave PVCs behind when retention is +`Retain` (the default) and persistence is enabled. + +You can also set `persistence` on `kind: stateless`. Deployah uses a shared +PVC and forces the Deployment strategy to `Recreate`. It rejects `replicas` +greater than `1` and rejects enabled HPA for that component. + +### Storage class resolution + +Order of precedence for the Kubernetes StorageClass name: + +1. Component `persistence.storageClass` (logical key) +2. Merged platform profile `storageClass` (logical key) +3. Cluster default (empty `storageClassName`) when neither sets a key + +If a logical key is set and the target environment has no `storageClasses` +map, or the key is missing from that map, resolve fails with a hard error. +See [Storage classes](#storage-classes) for the platform map shape. + +### Access mode and PVC retention + +Stateful volume claim templates default to `ReadWriteOncePod`. That access +mode keeps a single pod attached to each volume, which matches StatefulSet +identity. If your StorageClass or CSI driver cannot provide RWOP, the PVC +stays Pending and the StatefulSet will not become ready. + +Chart defaults keep volumes when the StatefulSet is deleted or scaled down +(`Retain` / `Retain`). A platform profile may override with +`pvcRetentionPolicy`: + +```yaml +profiles: + ephemeral-data: + pvcRetentionPolicy: + whenDeleted: Delete + whenScaled: Delete +``` + +Values are `Retain` or `Delete` for each of `whenDeleted` and `whenScaled`. + +### Services and Ingress + +Each stateful service component gets: + +- A normal ClusterIP Service named like the component release + (`{{ project }}-{{ env }}-{{ component }}`) +- A headless Service named `...-headless` (`clusterIP: None`) for stable + DNS (`pod-0.{{ headless }}...`) + +Exposing a multi-replica stateful component through Ingress still points at +the ClusterIP Service. Clients that need sticky per-pod routing should use +headless DNS or an application-aware proxy; a single Ingress backend does not +fan out to a specific ordinal. + +### Growing volumes + +Decreasing `persistence.size` is rejected. Increasing size needs an explicit +`--resize-volumes` opt-in because Deployah must expand live PVCs. For +stateful components it also orphan-deletes the StatefulSet controller so Helm +can re-apply `volumeClaimTemplates`. Stateless shared PVCs are patched in +place (no orphan-delete). + +1. Confirm the StorageClass has `allowVolumeExpansion: true`. +1. Raise `persistence.size` in `deployah.yaml` (for example `20Gi` to `40Gi`). +1. Deploy with the flag: + + ```sh + deployah deploy --resize-volumes --yes + ``` + +1. Deployah then: + - Patches each matching PVC `spec.resources.requests.storage` + - Waits for expansion to progress + - For stateful: orphan-deletes the StatefulSet (pods and PVCs keep running) + - Runs the normal Helm upgrade + +If resize fails after an orphan-delete, pods and PVCs should still be +running. Fix the cause (expansion support, permissions, quota) and re-run +`deployah deploy ... --resize-volumes`. Without the flag, a size increase +stops with an error that tells you to pass `--resize-volumes`. + +### Kind flips and other guards + +Deployah rejects changing a component between `stateless` and `stateful` on +an existing release (delete and redeploy instead). It also rejects adding or +removing `persistence` on an existing StatefulSet (`volumeClaimTemplates` are +immutable). It warns when you combine stateful + persistence with HPA, +Ingress with replicas > 1, or a changed `mountPath` after the first deploy. + ## Platform file A second file, `deployah.platform.yaml`, lives next to `deployah.yaml`. It @@ -531,7 +685,7 @@ requires it. This file is not processed with `${...}` substitution: it holds real values, not templates. ```yaml -apiVersion: platform/v1-alpha.1 +apiVersion: platform/v1-alpha.2 profiles: default: nodeSelector: @@ -608,7 +762,7 @@ A component's expose block resolves against the active environment's Each environment can declare a `storageClasses` map: logical names that map to real Kubernetes [StorageClass](https://kubernetes.io/docs/concepts/storage/storage-classes/) names. This is the same idea as `domains`: the platform file owns the cluster -details; a future stateful component will pick a logical name instead of a +details; a component or profile picks a logical name instead of a cluster-specific class string. | Field | Notes | @@ -626,9 +780,9 @@ environments: className: gp3 ``` -> [!NOTE] -> Profiles can reference a logical `storageClass` from this map. Direct use by -> `kind: stateful` components is not deployable yet. +Profiles can set `storageClass` to a logical key from this map. A component +may override with `persistence.storageClass`. See +[Stateful workloads](#stateful-workloads). ### Profiles @@ -655,7 +809,7 @@ components: ```yaml # deployah.platform.yaml (platform team) -apiVersion: platform/v1-alpha.1 +apiVersion: platform/v1-alpha.2 profiles: default: nodeSelector: @@ -1012,7 +1166,7 @@ These work with every command: | `deployah resolve ` | Preview the fully resolved hostname, TLS mode, and context, offline. Use `--output json` for machine-readable output. | | `deployah resolve --environments` | List every environment from both files: where it is registered, its context (or the kubeconfig fallback), domains, and overrides. | | `deployah plan ` | Preview what a deploy would change, without applying anything. Extra manifests from `.deployah/manifests/` appear in the diff; pending CRDs are reported but not applied. Use `--offline` to render with no cluster access, `--drift` to also compare against live cluster state, or `--output json` for CI. | -| `deployah deploy ` | Deploy your project. Shows the plan and asks for confirmation before applying; use `-y`/`--yes` to skip the prompt, `--reapply` to upgrade even with no changes, `--crds` for [CRD install policy](#crd-policy) (`create` or `create-replace`), `--explain` to print the resolution report first, or `--force-hostname-change` to bypass the hostname guard. | +| `deployah deploy ` | Deploy your project. Shows the plan and asks for confirmation before applying; use `-y`/`--yes` to skip the prompt, `--reapply` to upgrade even with no changes, `--crds` for [CRD install policy](#crd-policy) (`create` or `create-replace`), `--explain` to print the resolution report first, `--force-hostname-change` to bypass the hostname guard, or `--resize-volumes` to grow [persistence](#growing-volumes) sizes. | | `deployah status ` | Show the status of a deployed project. Use `--detailed` for pod details, `-e` for an environment. | | `deployah logs ` | Stream logs. Filter with `--component`, `-e`, `--container`, `--since`, `--tail`. Use `--no-follow` for a one-off read. | | `deployah shell ` | Open a shell in a running container. Choose with `--component` and `--container`. | @@ -1492,11 +1646,11 @@ deployah --help Deployah validates your spec and platform file with JSON Schema. -- **Manifest schema version:** v1-alpha.2 -- **Manifest schema:** `internal/spec/schema/v1-alpha.2/manifest.json` -- **Manifest environments schema:** `internal/spec/schema/v1-alpha.2/environments.json` -- **Platform schema version:** platform/v1-alpha.1 -- **Platform schema:** `internal/spec/schema/platform/v1-alpha.1/platform.json` +- **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` For the latest schema and examples, see the [schema directory](internal/spec/schema/) in the repository. diff --git a/docs/cli/deployah_deploy.md b/docs/cli/deployah_deploy.md index e6fbf95..0c08aa1 100644 --- a/docs/cli/deployah_deploy.md +++ b/docs/cli/deployah_deploy.md @@ -17,6 +17,7 @@ deployah deploy [flags] --explain Print the resolution report before cluster checks (visible even when cluster is unreachable) --force-hostname-change Allow changing the resolved hostname even though it may break existing traffic (skips the hostname guard) --reapply Upgrade the release even when the plan shows no changes + --resize-volumes Allow persistence.size increases by expanding PVCs; StatefulSet controllers are orphan-deleted when needed so volumeClaimTemplates can be rewritten -y, --yes Apply without an interactive confirmation prompt ``` diff --git a/docs/comparison.md b/docs/comparison.md index c7ff545..805784c 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -91,7 +91,7 @@ must install a platform into a cluster first (Epinio, Kubero). | **Local cluster included?** | **Yes** (`deployah cluster up`, kind) | No | No | No | No | No | | **Builds your image?** | No (you bring it) | Yes (and a dev loop) | Yes (Dockerfile/Stapel) | No (you bring it) | Yes (buildpacks) | Yes (buildpacks) | | **Output** | **Helm release** | Helm release | Helm release (via Nelm) | Raw YAML (no install) | Helm release (hidden) | K8s objects via operator | -| **Multi-component** (service/worker/job, stateless/stateful) | **Yes, named** | Partial | No (you template each) | No (one workload per file) | No (web apps) | web/worker/cron, plus DB add-ons | +| **Multi-component** (service/worker/job, stateless/stateful) | **Yes, named** (`kind: stateful` with per-pod PVCs; see [stateful workloads](../README.md#stateful-workloads)) | Partial | No (you template each) | No (one workload per file) | No (web apps) | web/worker/cron, plus DB add-ons | | **Multiple environments** | **Yes** (own context, config, env, vars) | Partial (profiles and vars) | Yes (env name; you template the diffs) | No (the platform decides) | Namespaces only | Pipelines (up to 4 stages) | | **Installs and day-2** | **Yes** | Yes (and dev mode) | Yes (converge/plan/dismiss/status/logs) | No (you run `kubectl apply`) | Yes (and UI) | Yes (and UI) | | **Maturity (mid-2026)** | Early, independent | Mature, CNCF, ~4.9k★ | Mature, CNCF, Flant, ~4.7k★ | Mature spec, CNCF | Active, ~585★ | Active, ~4.3k★ | @@ -158,8 +158,9 @@ Choose **Deployah** if you want: - To deploy with **zero Helm knowledge**, **zero cluster-side setup**, and **one binary**. - To **start a local cluster** with one command and try things fast. -- A **short spec** for a project with **many components** (service, worker, job) - across **many environments** (each with its own cluster and settings). +- A **short spec** for a project with **many components** (stateless or + stateful services today; worker and job roles planned) across **many + environments** (each with its own cluster and settings). - A real **Helm release** at the end, which works well with GitOps and `helm`. - You already **build your images in CI** and just want to ship them. diff --git a/examples/nginx/deployah.platform.yaml b/examples/nginx/deployah.platform.yaml index 5e760ef..ddd1443 100644 --- a/examples/nginx/deployah.platform.yaml +++ b/examples/nginx/deployah.platform.yaml @@ -1,4 +1,4 @@ -apiVersion: platform/v1-alpha.1 +apiVersion: platform/v1-alpha.2 environments: local: context: kind-deployah diff --git a/examples/nginx/deployah.yaml b/examples/nginx/deployah.yaml index ca6006a..802d628 100644 --- a/examples/nginx/deployah.yaml +++ b/examples/nginx/deployah.yaml @@ -1,4 +1,4 @@ -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: nginx components: web: diff --git a/flake.nix b/flake.nix index 55fd3e4..7b83dff 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,17 @@ deployahVendorHash = "sha256-KmIlfzjPCysvdQu7O0oIdsVjkdsXK+vLWNtQLdYDJ5A="; + golangci-lint = import ./nix/golangci-lint.nix { + buildGoModule = buildGoModule'; + inherit (pkgs) + fetchFromGitHub + installShellFiles + lib + stdenv + buildPackages + ; + }; + deployah = import ./nix/deployah.nix { buildGoModule = buildGoModule'; deployahVersion = "dev"; @@ -44,6 +55,7 @@ go git-hooks system + golangci-lint ; src = ./.; }; @@ -54,6 +66,7 @@ packages = { default = deployah; deployah = deployah; + golangci-lint = golangci-lint; }; checks = { @@ -67,12 +80,18 @@ deployah system go + golangci-lint ; lib = lib'; }; devShells.default = import ./nix/devshell.nix { - inherit pkgs go pre-commit-check; + inherit + pkgs + go + pre-commit-check + golangci-lint + ; }; } ); diff --git a/internal/action/deploy_test.go b/internal/action/deploy_test.go index e9346e6..36c73e0 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.2", + APIVersion: "v1-alpha.3", Project: "my-app", } diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index 1b34cb2..4536c3c 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -29,6 +29,7 @@ type Options struct { Environment string `nabat:"environment"` Explain bool `nabat:"explain"` ForceHostnameChange bool `nabat:"force-hostname-change"` + ResizeVolumes bool `nabat:"resize-volumes"` Yes bool `nabat:"yes"` Reapply bool `nabat:"reapply"` CRDs string `nabat:"crds"` @@ -45,6 +46,7 @@ func Register(app *nabat.App) { nabat.WithArg("environment", "", nabat.WithRequired(), nabat.WithUsage("Environment to deploy to"), nabat.WithPrompt("Environment", "", nabat.WithHint("e.g. prod, staging"))), nabat.WithFlag("explain", false, nabat.WithUsage("Print the resolution report before cluster checks (visible even when cluster is unreachable)")), nabat.WithFlag("force-hostname-change", false, nabat.WithUsage("Allow changing the resolved hostname even though it may break existing traffic (skips the hostname guard)")), + nabat.WithFlag("resize-volumes", false, nabat.WithUsage("Allow persistence.size increases by expanding PVCs; StatefulSet controllers are orphan-deleted when needed so volumeClaimTemplates can be rewritten")), nabat.WithFlag("yes", false, nabat.WithShort('y'), nabat.WithUsage("Apply without an interactive confirmation prompt")), nabat.WithFlag("reapply", false, nabat.WithUsage("Upgrade the release even when the plan shows no changes")), nabat.WithSelectFlag("crds", string(extras.PolicyCreate), crdPolicies, nabat.WithUsage("CRD install policy: create (install if missing) or create-replace")), @@ -211,6 +213,20 @@ func runDeploy(c *nabat.Context) error { } } + prevResolved, prevErr := loadPreviousResolvedComponents(c, helmClient, manifest.Project, opts.Environment) + if prevErr != nil { + return prevErr + } + if guardErr := checkWorkloadGuards(manifest, opts.Environment, prevResolved); guardErr != nil { + return guardErr + } + emitWorkloadWarnings(c, manifest, opts.Environment, prevResolved) + + resizes := detectPersistenceResizes(manifest, opts.Environment, resolvedSpec, prevResolved) + if resizeFlagErr := requireResizeFlag(resizes, opts.ResizeVolumes); resizeFlagErr != nil { + return resizeFlagErr + } + helmIdle := !plan.diff.HasChanges() && !opts.Reapply if skipWhenIdle(helmIdle, len(bundle.CRDs)) { return skipDeploy(c, k8sClient, k8sErr, plan) @@ -227,6 +243,16 @@ func runDeploy(c *nabat.Context) error { return capErr } } + if hasStatefulWithPersistence(manifest, opts.Environment) { + if verErr := k8s.CheckMinimumVersion( + k8sClient, + k8s.MinStatefulMajor, + k8s.MinStatefulMinor, + "kind: stateful with persistence requires Kubernetes 1.32+", + ); verErr != nil { + return verErr + } + } } prompt := "Apply these changes?" @@ -246,7 +272,7 @@ func runDeploy(c *nabat.Context) error { if helmIdle { return applyCRDsOnly(c, sess, cluster, k8sClient, k8sErr, plan, bundle, opts) } - return applyDeploy(c, sess, cluster, helmClient, platform, manifest, opts, resolvedSpec, plan, k8sClient, k8sErr, bundle, postRenderer) + return applyDeploy(c, sess, cluster, helmClient, platform, manifest, opts, resolvedSpec, plan, k8sClient, k8sErr, bundle, postRenderer, resizes) } // skipWhenIdle reports whether deploy should exit without cluster writes: @@ -356,8 +382,10 @@ func applyBundleCRDs(c *nabat.Context, sess *session.Session, cluster *session.C } // applyDeploy re-renders and verifies determinism before the real Helm -// install/upgrade. CRDs from the bundle are applied first. -func applyDeploy(c *nabat.Context, sess *session.Session, cluster *session.Cluster, helmClient session.HelmClient, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolved *spec.ResolvedSpec, plan *deployPlan, k8sClient kubernetes.Interface, k8sErr error, bundle *extras.Bundle, postRenderer postrenderer.PostRenderer) error { +// install/upgrade. CRDs from the bundle are applied first. When resizes is +// non-empty, PVC expansion (and StatefulSet orphan-delete when needed) run +// before Helm. +func applyDeploy(c *nabat.Context, sess *session.Session, cluster *session.Cluster, helmClient session.HelmClient, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolved *spec.ResolvedSpec, plan *deployPlan, k8sClient kubernetes.Interface, k8sErr error, bundle *extras.Bundle, postRenderer postrenderer.PostRenderer, resizes []persistenceResize) error { verify, verifyCleanup, err := helmClient.RenderManifests(c, manifest, opts.Environment, resolved, postRenderer) if verifyCleanup != nil { defer verifyCleanup() @@ -376,6 +404,16 @@ func applyDeploy(c *nabat.Context, sess *session.Session, cluster *session.Clust return crdErr } + if len(resizes) > 0 { + if k8sErr != nil { + return fmt.Errorf("resize volumes: kubernetes client unavailable: %w", k8sErr) + } + c.Printf("Resizing volumes for %d component(s)...\n", len(resizes)) + if resizeErr := resizeVolumes(c, k8sClient, cluster.Namespace(), plan.result.ReleaseName, resizes); resizeErr != nil { + return fmt.Errorf("%s: %w", resizeFailureHint(resizes), resizeErr) + } + } + resolvedCtx := cluster.Context() ctxSuffix := "" if resolvedCtx != "" { diff --git a/internal/cmd/deploy/deploy_flow_test.go b/internal/cmd/deploy/deploy_flow_test.go index 9da61d0..06ff808 100644 --- a/internal/cmd/deploy/deploy_flow_test.go +++ b/internal/cmd/deploy/deploy_flow_test.go @@ -158,7 +158,7 @@ func TestApplyDeploy_RenderMismatch_AbortsBeforeApply(t *testing.T) { opts := &Options{Environment: "production"} manifest := &spec.Spec{Project: "web"} - err := applyDeploy(c, sess, cluster, stub, nil, manifest, opts, nil, planned, nil, nil, &extras.Bundle{}, nil) + err := applyDeploy(c, sess, cluster, stub, nil, manifest, opts, nil, planned, nil, nil, &extras.Bundle{}, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "changed between plan and apply") assert.Equal(t, 1, stub.renderCallCount, "must re-render exactly once before comparing") @@ -390,7 +390,7 @@ func TestApplyDeploy_CallsInstallAfterEmptyCRDs(t *testing.T) { c, _, _, stderr := nabatContextWithIO(t) opts := &Options{Environment: "production", CRDs: string(extras.PolicyCreate)} - err := applyDeploy(c, sess, cluster, stub, nil, &spec.Spec{Project: "web"}, opts, nil, planned, nil, assertNever{}, &extras.Bundle{}, nil) + err := applyDeploy(c, sess, cluster, stub, nil, &spec.Spec{Project: "web"}, opts, nil, planned, nil, assertNever{}, &extras.Bundle{}, nil, nil) require.NoError(t, err) assert.Equal(t, 1, stub.installCallCount) assert.Contains(t, stderr.String(), "Deployed") @@ -419,7 +419,7 @@ func TestApplyDeploy_PropagatesCRDApplyError(t *testing.T) { c := nabatContext(t) opts := &Options{Environment: "production", CRDs: string(extras.PolicyCreate)} - err = applyDeploy(c, sess, cluster, stub, nil, &spec.Spec{Project: "web"}, opts, nil, planned, nil, nil, sampleBundleCRD(t), nil) + err = applyDeploy(c, sess, cluster, stub, nil, &spec.Spec{Project: "web"}, opts, nil, planned, nil, nil, sampleBundleCRD(t), nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "rest config for CRDs") assert.Equal(t, 0, stub.installCallCount) diff --git a/internal/cmd/deploy/guards.go b/internal/cmd/deploy/guards.go new file mode 100644 index 0000000..12f8b20 --- /dev/null +++ b/internal/cmd/deploy/guards.go @@ -0,0 +1,243 @@ +// 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 deploy + +import ( + "fmt" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/api/resource" + "nabat.dev/nabat" + + "deployah.dev/deployah/internal/plan" + "deployah.dev/deployah/internal/session" + "deployah.dev/deployah/internal/spec" +) + +// loadPreviousResolvedComponents reads deployah.resolved.components from the +// last successful Helm release. An empty map means no prior release (or no +// resolved block); callers treat that as a first deploy. Never returns nil on +// success. +func loadPreviousResolvedComponents( + c *nabat.Context, + helmClient session.HelmClient, + project, environment string, +) (map[string]map[string]any, error) { + rel, _, err := plan.LastSuccessfulRelease(c, helmClient, project, environment) + if err != nil { + return nil, fmt.Errorf("load previous release: %w", err) + } + if rel == nil || rel.Chart == nil { + return map[string]map[string]any{}, nil + } + return previousResolvedComponents(rel.Chart.Values), nil +} + +// checkWorkloadGuards enforces hard deploy-time rules that need the previous +// release's chart values: kind flips, StatefulSet persistence add/remove, and +// persistence size decreases. prevResolved must be non-nil (use an empty map +// when there is no prior release). +func checkWorkloadGuards( + manifest *spec.Spec, + environment string, + prevResolved map[string]map[string]any, +) error { + var errors []string + for name, component := range manifest.Components { + if !componentActiveInEnv(component, environment) { + continue + } + prev, ok := prevResolved[name] + if !ok { + continue + } + + wantKind := "Deployment" + if component.Kind == spec.ComponentKindStateful { + wantKind = "StatefulSet" + } + prevKind, hasKind := prev["workloadKind"].(string) + if hasKind && prevKind != "" && prevKind != wantKind { + errors = append(errors, fmt.Sprintf( + " %s: kind change %s -> %s is not supported; delete the release and redeploy", + name, prevKind, wantKind, + )) + } + + prevSize, hasPrevSize := prev["persistenceSize"].(string) + prevHadPersistence := hasPrevSize && prevSize != "" + nowHasPersistence := component.Persistence != nil + // volumeClaimTemplates are immutable: adding or removing persistence + // on a StatefulSet (past or present) requires delete + redeploy. + wasOrWillBeStateful := wantKind == "StatefulSet" || prevKind == "StatefulSet" + if wasOrWillBeStateful && prevHadPersistence != nowHasPersistence { + switch { + case !prevHadPersistence && nowHasPersistence: + errors = append(errors, fmt.Sprintf( + " %s: adding persistence to an existing StatefulSet is not supported; delete the release and redeploy", + name, + )) + case prevHadPersistence && !nowHasPersistence: + errors = append(errors, fmt.Sprintf( + " %s: removing persistence from an existing StatefulSet is not supported; delete the release and redeploy", + name, + )) + } + } + + if component.Persistence == nil || prevSize == "" { + continue + } + decreased, cmpErr := persistenceSizeDecreased(prevSize, component.Persistence.Size) + if cmpErr != nil { + errors = append(errors, fmt.Sprintf(" %s: %v", name, cmpErr)) + continue + } + if decreased { + errors = append(errors, fmt.Sprintf( + " %s: persistence.size decrease %s -> %s is not supported", + name, prevSize, component.Persistence.Size, + )) + } + } + + if len(errors) == 0 { + return nil + } + slices.Sort(errors) + return fmt.Errorf( + "workload change rejected for %s/%s:\n%s", + manifest.Project, environment, strings.Join(errors, "\n"), + ) +} + +// emitWorkloadWarnings prints non-fatal plan warnings for stateful/HPA, +// multi-replica expose, and mountPath changes. +func emitWorkloadWarnings( + c *nabat.Context, + manifest *spec.Spec, + environment string, + prevResolved map[string]map[string]any, +) { + var warnings []string + for name, component := range manifest.Components { + if !componentActiveInEnv(component, environment) { + continue + } + + replicas := 1 + if component.Replicas != nil { + replicas = *component.Replicas + } + if component.Autoscaling != nil && component.Autoscaling.Enabled && component.Autoscaling.MaxReplicas > replicas { + replicas = component.Autoscaling.MaxReplicas + } + + if component.Kind == spec.ComponentKindStateful { + if component.Persistence != nil && + component.Autoscaling != nil && component.Autoscaling.Enabled { + warnings = append(warnings, fmt.Sprintf( + " %s: HPA on a stateful component retains PVCs on scale-down by default (whenScaled: Retain); scaled-down volume cost remains until deleted", + name, + )) + } + if component.Expose != nil && replicas > 1 { + warnings = append(warnings, fmt.Sprintf( + " %s: expose with replicas > 1 load-balances across pods; only use this when every replica can serve the same traffic", + name, + )) + } + } + + if component.Persistence != nil { + if prev, ok := prevResolved[name]; ok { + if prevMount, hasMount := prev["persistenceMountPath"].(string); hasMount && + prevMount != "" && prevMount != component.Persistence.MountPath { + warnings = append(warnings, fmt.Sprintf( + " %s: persistence.mountPath change %s -> %s leaves existing data at the old path", + name, prevMount, component.Persistence.MountPath, + )) + } + } + } + } + + if len(warnings) == 0 { + return + } + slices.Sort(warnings) + c.Warn("workload warnings:\n" + strings.Join(warnings, "\n")) +} + +// hasStatefulWithPersistence reports whether any active stateful component +// declares persistence (triggers the Kubernetes 1.32+ RWOP/retention floor). +func hasStatefulWithPersistence(manifest *spec.Spec, environment string) bool { + for _, component := range manifest.Components { + if !componentActiveInEnv(component, environment) { + continue + } + if component.Kind == spec.ComponentKindStateful && component.Persistence != nil { + return true + } + } + return false +} + +func componentActiveInEnv(component spec.Component, environment string) bool { + if len(component.Environments) == 0 { + return true + } + _, ok := spec.MatchEnvKey(environment, component.Environments) + return ok +} + +// previousResolvedComponents extracts deployah.resolved.components from chart +// values. Always returns a non-nil map (empty when the resolved block is absent). +func previousResolvedComponents(chartValues map[string]any) map[string]map[string]any { + deployahBlock, ok := chartValues["deployah"].(map[string]any) + if !ok { + return map[string]map[string]any{} + } + resolvedBlock, ok := deployahBlock["resolved"].(map[string]any) + if !ok { + return map[string]map[string]any{} + } + componentsBlock, ok := resolvedBlock["components"].(map[string]any) + if !ok { + return map[string]map[string]any{} + } + out := make(map[string]map[string]any, len(componentsBlock)) + for name, raw := range componentsBlock { + m, isMap := raw.(map[string]any) + if !isMap { + continue + } + out[name] = m + } + return out +} + +func persistenceSizeDecreased(prev, next string) (bool, error) { + prevQ, err := resource.ParseQuantity(prev) + if err != nil { + return false, fmt.Errorf("parse previous persistence.size %q: %w", prev, err) + } + nextQ, err := resource.ParseQuantity(next) + if err != nil { + return false, fmt.Errorf("parse new persistence.size %q: %w", next, err) + } + return nextQ.Cmp(prevQ) < 0, nil +} diff --git a/internal/cmd/deploy/guards_test.go b/internal/cmd/deploy/guards_test.go new file mode 100644 index 0000000..a0bbfe8 --- /dev/null +++ b/internal/cmd/deploy/guards_test.go @@ -0,0 +1,316 @@ +// 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 deploy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/spec" + + chart "helm.sh/helm/v4/pkg/chart/v2" + v1 "helm.sh/helm/v4/pkg/release/v1" +) + +func releaseWithResolved(component string, fields map[string]any) *v1.Release { + components := map[string]any{} + if component != "" { + components[component] = fields + } + return &v1.Release{ + Chart: &chart.Chart{ + Values: map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "schemaVersion": "1", + "components": components, + }, + }, + }, + }, + } +} + +func TestCheckWorkloadGuards_KindChangeRejected(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "Deployment", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + }, + }, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "kind change") + assert.Contains(t, err.Error(), "Deployment -> StatefulSet") +} + +func TestCheckWorkloadGuards_SizeDecreaseRejected(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "20Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{ + Size: "10Gi", + MountPath: "/data", + }, + }, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "persistence.size decrease") +} + +func TestCheckWorkloadGuards_SizeIncreaseAllowed(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "10Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + }, + }, + }, + } + require.NoError(t, checkWorkloadGuards(manifest, "production", prev)) +} + +func TestCheckWorkloadGuards_PersistenceAddRejected(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("peer", map[string]any{ + "workloadKind": "StatefulSet", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "peer": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{ + Size: "1Gi", + MountPath: "/data", + }, + }, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "adding persistence") +} + +func TestCheckWorkloadGuards_PersistenceRemoveRejected(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "20Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": {Kind: spec.ComponentKindStateful}, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "removing persistence") +} + +func TestPersistenceSizeDecreased(t *testing.T) { + t.Parallel() + decreased, err := persistenceSizeDecreased("20Gi", "10Gi") + require.NoError(t, err) + assert.True(t, decreased) + + decreased, err = persistenceSizeDecreased("10Gi", "20Gi") + require.NoError(t, err) + assert.False(t, decreased) +} + +func TestHasStatefulWithPersistence(t *testing.T) { + t.Parallel() + identityOnly := &spec.Spec{ + Components: map[string]spec.Component{ + "peer": {Kind: spec.ComponentKindStateful}, + }, + } + assert.False(t, hasStatefulWithPersistence(identityOnly, "dev")) + + withDisk := &spec.Spec{ + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "1Gi", MountPath: "/data"}, + }, + }, + } + assert.True(t, hasStatefulWithPersistence(withDisk, "dev")) +} + +func TestEmitWorkloadWarnings_HPAOnStatefulWithPersistence(t *testing.T) { + t.Parallel() + c, _, _, stderr := nabatContextWithIO(t) + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "cache": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "5Gi", MountPath: "/data"}, + Autoscaling: &spec.Autoscaling{Enabled: true, MinReplicas: 1, MaxReplicas: 3}, + }, + }, + } + emitWorkloadWarnings(c, manifest, "dev", map[string]map[string]any{}) + assert.Contains(t, stderr.String(), "retains PVCs on scale-down") +} + +func TestEmitWorkloadWarnings_ExposeMultiReplicaStateful(t *testing.T) { + t.Parallel() + c, _, _, stderr := nabatContextWithIO(t) + replicas := 3 + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "peer": { + Kind: spec.ComponentKindStateful, + Replicas: &replicas, + Expose: &spec.Expose{}, + }, + }, + } + emitWorkloadWarnings(c, manifest, "dev", map[string]map[string]any{}) + assert.Contains(t, stderr.String(), "expose with replicas > 1") +} + +func TestEmitWorkloadWarnings_MountPathChange(t *testing.T) { + t.Parallel() + c, _, _, stderr := nabatContextWithIO(t) + prev := map[string]map[string]any{ + "db": { + "workloadKind": "StatefulSet", + "persistenceSize": "10Gi", + "persistenceMountPath": "/old/data", + }, + } + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "10Gi", MountPath: "/new/data"}, + }, + }, + } + emitWorkloadWarnings(c, manifest, "dev", prev) + assert.Contains(t, stderr.String(), "persistence.mountPath change") + assert.Contains(t, stderr.String(), "/old/data") + assert.Contains(t, stderr.String(), "/new/data") +} + +func TestEmitWorkloadWarnings_NoWarningsWhenClean(t *testing.T) { + t.Parallel() + c, _, _, stderr := nabatContextWithIO(t) + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "web": {Kind: spec.ComponentKindStateless}, + }, + } + emitWorkloadWarnings(c, manifest, "dev", map[string]map[string]any{}) + assert.Empty(t, stderr.String()) +} + +func TestComponentActiveInEnv(t *testing.T) { + t.Parallel() + assert.True(t, componentActiveInEnv(spec.Component{}, "dev"), "no filter = active everywhere") + assert.True(t, componentActiveInEnv(spec.Component{Environments: []string{"dev"}}, "dev")) + assert.False(t, componentActiveInEnv(spec.Component{Environments: []string{"staging"}}, "dev")) + assert.True(t, componentActiveInEnv(spec.Component{Environments: []string{"review"}}, "review/pr-42"), "prefix match") +} + +func TestPreviousResolvedComponents_NonMapComponent(t *testing.T) { + t.Parallel() + values := map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "components": map[string]any{ + "db": map[string]any{"workloadKind": "StatefulSet"}, + "invalid": "not-a-map", + }, + }, + }, + } + got := previousResolvedComponents(values) + assert.Len(t, got, 1) + assert.Contains(t, got, "db") +} + +func TestCheckWorkloadGuards_InactiveComponentSkipped(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "Deployment", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Environments: []string{"staging"}, + }, + }, + } + require.NoError(t, checkWorkloadGuards(manifest, "production", prev)) +} + +func TestCheckWorkloadGuards_SizeParseError(t *testing.T) { + t.Parallel() + prev := map[string]map[string]any{ + "db": { + "workloadKind": "StatefulSet", + "persistenceSize": "not-a-quantity", + }, + } + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "10Gi", MountPath: "/data"}, + }, + }, + } + err := checkWorkloadGuards(manifest, "production", prev) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse previous persistence.size") +} diff --git a/internal/cmd/deploy/resize.go b/internal/cmd/deploy/resize.go new file mode 100644 index 0000000..20a613b --- /dev/null +++ b/internal/cmd/deploy/resize.go @@ -0,0 +1,405 @@ +// 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 deploy + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/kubernetes" + + "deployah.dev/deployah/internal/spec" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + resizeWaitTimeout = 5 * time.Minute + resizePollInterval = 2 * time.Second + defaultSCAnnotKey = "storageclass.kubernetes.io/is-default-class" +) + +// persistenceResize describes one component whose persistence.size must grow. +type persistenceResize struct { + Component string + PreviousSize string + NewSize string + StorageClass string // resolved Kubernetes class name; may be empty + Stateful bool // true when workload is StatefulSet (needs orphan-delete) +} + +// detectPersistenceResizes compares previous resolved sizes against the +// current manifest. Only increases are returned; decreases are rejected by +// checkWorkloadGuards. Works for both stateful and stateless persistence. +// prevResolved must be non-nil (use an empty map when there is no prior release). +func detectPersistenceResizes( + manifest *spec.Spec, + environment string, + resolved *spec.ResolvedSpec, + prevResolved map[string]map[string]any, +) []persistenceResize { + var out []persistenceResize + for name, component := range manifest.Components { + if !componentActiveInEnv(component, environment) || component.Persistence == nil { + continue + } + prev, hasPrev := prevResolved[name] + if !hasPrev { + continue + } + prevSize, hasPrevSize := prev["persistenceSize"].(string) + if !hasPrevSize || prevSize == "" || prevSize == component.Persistence.Size { + continue + } + decreased, cmpErr := persistenceSizeDecreased(prevSize, component.Persistence.Size) + if cmpErr != nil || decreased { + continue // size-decrease guard owns that error + } + sc := "" + if resolved != nil { + if rc, found := resolved.Components[name]; found { + sc = rc.StorageClass + } + } + out = append(out, persistenceResize{ + Component: name, + PreviousSize: prevSize, + NewSize: component.Persistence.Size, + StorageClass: sc, + Stateful: component.Kind == spec.ComponentKindStateful, + }) + } + slices.SortFunc(out, func(a, b persistenceResize) int { + return cmp.Compare(a.Component, b.Component) + }) + return out +} + +// requireResizeFlag errors when volume growth is needed and --resize-volumes +// was not passed. +func requireResizeFlag(resizes []persistenceResize, enabled bool) error { + if len(resizes) == 0 || enabled { + return nil + } + lines := make([]string, 0, len(resizes)) + for _, r := range resizes { + lines = append(lines, fmt.Sprintf(" %s: %s -> %s", r.Component, r.PreviousSize, r.NewSize)) + } + hint := "re-run deploy with --resize-volumes to expand PVCs" + if hasStatefulResize(resizes) { + hint += " (StatefulSet controllers are orphan-deleted so Helm can re-apply volumeClaimTemplates)" + } + return fmt.Errorf( + "persistence.size increase requires --resize-volumes:\n%s\n%s", + strings.Join(lines, "\n"), hint, + ) +} + +// resizeFailureHint describes recovery after a failed resizeVolumes call. +// Mentions orphan-delete only when at least one resize targeted a StatefulSet. +func resizeFailureHint(resizes []persistenceResize) string { + if hasStatefulResize(resizes) { + return "resize volumes failed (PVCs may already be patched; StatefulSets may have been orphan-deleted; pods/PVCs should still be running; re-run deploy with --resize-volumes after fixing the cause)" + } + return "resize volumes failed (PVCs may already be patched; re-run deploy with --resize-volumes after fixing the cause)" +} + +func hasStatefulResize(resizes []persistenceResize) bool { + for _, r := range resizes { + if r.Stateful { + return true + } + } + return false +} + +// resizeVolumes patches PVC requests, waits for expansion, then orphan-deletes +// StatefulSets that need volumeClaimTemplates rewritten. Stateless components +// only patch the shared PVC; Helm upgrade updates the claim template in values. +func resizeVolumes( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace, releaseName string, + resizes []persistenceResize, +) error { + if k8sClient == nil { + return fmt.Errorf("resize volumes: kubernetes client is required") + } + if len(resizes) == 0 { + return nil + } + + var patched []string + var statefulComponents []string + checkedClasses := map[string]struct{}{} + + for _, r := range resizes { + qty, parseErr := resource.ParseQuantity(r.NewSize) + if parseErr != nil { + return fmt.Errorf("component %s: parse size %q: %w", r.Component, r.NewSize, parseErr) + } + + pvcNames, listErr := componentPVCNames(ctx, k8sClient, namespace, releaseName, r) + if listErr != nil { + return listErr + } + if len(pvcNames) == 0 { + return fmt.Errorf("resize volumes: no PVCs found for component %s", r.Component) + } + + for _, pvcName := range pvcNames { + pvc, getErr := k8sClient.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if getErr != nil { + return fmt.Errorf("get PVC %s: %w", pvcName, getErr) + } + className, classErr := resolveStorageClassForExpansion(ctx, k8sClient, pvc, r.StorageClass) + if classErr != nil { + return classErr + } + if _, seen := checkedClasses[className]; !seen { + if expandErr := ensureVolumeExpansionAllowed(ctx, k8sClient, className); expandErr != nil { + return expandErr + } + checkedClasses[className] = struct{}{} + } + if patchErr := patchPVCSize(ctx, k8sClient, namespace, pvc, qty); patchErr != nil { + return patchErr + } + patched = append(patched, pvcName) + } + if r.Stateful { + statefulComponents = append(statefulComponents, r.Component) + } + } + + for _, pvcName := range patched { + if waitErr := waitForPVCExpansion(ctx, k8sClient, namespace, pvcName); waitErr != nil { + return waitErr + } + } + + return orphanDeleteStatefulSets(ctx, k8sClient, namespace, releaseName, statefulComponents) +} + +// orphanDeleteStatefulSets removes StatefulSet controllers (orphan propagation) +// for each listed component so Helm can recreate them with updated +// volumeClaimTemplates. Every component must match exactly one StatefulSet. +func orphanDeleteStatefulSets( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace, releaseName string, + components []string, +) error { + if len(components) == 0 { + return nil + } + + propagation := metav1.DeletePropagationOrphan + for _, comp := range components { + stsList, err := k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf( + "app.kubernetes.io/instance=%s,%s=%s", + releaseName, spec.LabelComponent, comp, + ), + }) + if err != nil { + return fmt.Errorf("list StatefulSets for component %s: %w", comp, err) + } + if len(stsList.Items) == 0 { + return fmt.Errorf("orphan-delete: StatefulSet for component %q not found", comp) + } + if len(stsList.Items) > 1 { + return fmt.Errorf( + "orphan-delete: expected 1 StatefulSet for component %q, found %d", + comp, len(stsList.Items), + ) + } + sts := &stsList.Items[0] + if delErr := k8sClient.AppsV1().StatefulSets(namespace).Delete(ctx, sts.Name, metav1.DeleteOptions{ + PropagationPolicy: &propagation, + }); delErr != nil { + return fmt.Errorf("orphan-delete StatefulSet %s: %w", sts.Name, delErr) + } + } + return nil +} + +func componentPVCNames( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace, releaseName string, + r persistenceResize, +) ([]string, error) { + if r.Stateful { + // StatefulSet PVCs inherit labels from volumeClaimTemplates. + return listPVCsByComponentLabels(ctx, k8sClient, namespace, releaseName, r.Component) + } + return deploymentComponentPVCNames(ctx, k8sClient, namespace, releaseName, r.Component) +} + +func deploymentComponentPVCNames( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace, releaseName, component string, +) ([]string, error) { + // Chart PVC name is common.names.fullname = {release}-{component}. + direct := releaseName + "-" + component + _, err := k8sClient.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, direct, metav1.GetOptions{}) + if err == nil { + return []string{direct}, nil + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("get PVC %s: %w", direct, err) + } + + return listPVCsByComponentLabels(ctx, k8sClient, namespace, releaseName, component) +} + +func resolveStorageClassForExpansion( + ctx context.Context, + k8sClient kubernetes.Interface, + pvc *corev1.PersistentVolumeClaim, + hint string, +) (string, error) { + // Prefer the live PVC class: that is what expansion actually targets. + if pvc.Spec.StorageClassName != nil && *pvc.Spec.StorageClassName != "" { + return *pvc.Spec.StorageClassName, nil + } + if hint != "" { + return hint, nil + } + defaultName, err := defaultStorageClassName(ctx, k8sClient) + if err != nil { + return "", err + } + if defaultName == "" { + return "", fmt.Errorf( + "pvc %s has no storage class and the cluster has no default storage class; cannot verify volume expansion", + pvc.Name, + ) + } + return defaultName, nil +} + +func defaultStorageClassName(ctx context.Context, k8sClient kubernetes.Interface) (string, error) { + list, err := k8sClient.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("list storage classes: %w", err) + } + for i := range list.Items { + sc := &list.Items[i] + if sc.Annotations[defaultSCAnnotKey] == "true" { + return sc.Name, nil + } + } + return "", nil +} + +func ensureVolumeExpansionAllowed(ctx context.Context, k8sClient kubernetes.Interface, className string) error { + sc, err := k8sClient.StorageV1().StorageClasses().Get(ctx, className, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get storage class %q: %w", className, err) + } + if sc.AllowVolumeExpansion == nil || !*sc.AllowVolumeExpansion { + return fmt.Errorf( + "storage class %q does not allow volume expansion (allowVolumeExpansion is not true); cannot resize volumes", + className, + ) + } + return nil +} + +func listPVCsByComponentLabels( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace, releaseName, component string, +) ([]string, error) { + pvcList, err := k8sClient.CoreV1().PersistentVolumeClaims(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf( + "app.kubernetes.io/instance=%s,%s=%s", + releaseName, spec.LabelComponent, component, + ), + }) + if err != nil { + return nil, fmt.Errorf("list PVCs for component %s: %w", component, err) + } + names := make([]string, 0, len(pvcList.Items)) + for _, pvc := range pvcList.Items { + names = append(names, pvc.Name) + } + return names, nil +} + +func patchPVCSize( + ctx context.Context, + k8sClient kubernetes.Interface, + namespace string, + pvc *corev1.PersistentVolumeClaim, + size resource.Quantity, +) error { + if pvc.Spec.Resources.Requests == nil { + pvc.Spec.Resources.Requests = corev1.ResourceList{} + } + current := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if current.Cmp(size) >= 0 { + return nil + } + pvc.Spec.Resources.Requests[corev1.ResourceStorage] = size + if _, err := k8sClient.CoreV1().PersistentVolumeClaims(namespace).Update(ctx, pvc, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("patch PVC %s storage to %s: %w", pvc.Name, size.String(), err) + } + return nil +} + +func waitForPVCExpansion(ctx context.Context, k8sClient kubernetes.Interface, namespace, name string) error { + deadline := time.Now().Add(resizeWaitTimeout) + ticker := time.NewTicker(resizePollInterval) + defer ticker.Stop() + + for { + pvc, err := k8sClient.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("wait for PVC %s expansion: %w", name, err) + } + req := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + capQty := pvc.Status.Capacity[corev1.ResourceStorage] + if !capQty.IsZero() && capQty.Cmp(req) >= 0 { + return nil + } + for _, cond := range pvc.Status.Conditions { + if cond.Type == corev1.PersistentVolumeClaimFileSystemResizePending && + cond.Status == corev1.ConditionTrue { + // Online/offline FS resize will finish after pod restart. + return nil + } + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for PVC %s expansion (requested %s, capacity %s)", + name, req.String(), capQty.String()) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/internal/cmd/deploy/resize_test.go b/internal/cmd/deploy/resize_test.go new file mode 100644 index 0000000..f9bd14c --- /dev/null +++ b/internal/cmd/deploy/resize_test.go @@ -0,0 +1,592 @@ +// 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 deploy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/kubernetes/fake" + + "deployah.dev/deployah/internal/spec" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestRequireResizeFlag(t *testing.T) { + t.Parallel() + stateful := []persistenceResize{{ + Component: "db", PreviousSize: "10Gi", NewSize: "20Gi", Stateful: true, + }} + require.NoError(t, requireResizeFlag(stateful, true)) + err := requireResizeFlag(stateful, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "--resize-volumes") + assert.Contains(t, err.Error(), "db: 10Gi -> 20Gi") + assert.Contains(t, err.Error(), "orphan-deleted") + + stateless := []persistenceResize{{ + Component: "web", PreviousSize: "1Gi", NewSize: "2Gi", Stateful: false, + }} + err = requireResizeFlag(stateless, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "--resize-volumes") + assert.NotContains(t, err.Error(), "orphan-deleted") +} + +func TestResizeFailureHint(t *testing.T) { + t.Parallel() + stateful := resizeFailureHint([]persistenceResize{{Component: "db", Stateful: true}}) + assert.Contains(t, stateful, "orphan-deleted") + assert.Contains(t, stateful, "PVCs may already be patched") + + stateless := resizeFailureHint([]persistenceResize{{Component: "web", Stateful: false}}) + assert.NotContains(t, stateless, "orphan-deleted") + assert.Contains(t, stateless, "PVCs may already be patched") +} + +func TestDetectPersistenceResizes(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "10Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + }, + }, + }, + } + resolved := &spec.ResolvedSpec{ + Components: map[string]spec.ResolvedComponent{ + "db": {StorageClass: "fast-ssd"}, + }, + } + resizes := detectPersistenceResizes(manifest, "production", resolved, prev) + require.Len(t, resizes, 1) + assert.Equal(t, "db", resizes[0].Component) + assert.Equal(t, "10Gi", resizes[0].PreviousSize) + assert.Equal(t, "20Gi", resizes[0].NewSize) + assert.Equal(t, "fast-ssd", resizes[0].StorageClass) + assert.True(t, resizes[0].Stateful) +} + +func TestDetectPersistenceResizes_Stateless(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("web", map[string]any{ + "workloadKind": "Deployment", + "persistenceSize": "1Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Project: "shop", + Components: map[string]spec.Component{ + "web": { + Kind: spec.ComponentKindStateless, + Persistence: &spec.Persistence{ + Size: "2Gi", + MountPath: "/data", + }, + }, + }, + } + resizes := detectPersistenceResizes(manifest, "production", nil, prev) + require.Len(t, resizes, 1) + assert.Equal(t, "web", resizes[0].Component) + assert.False(t, resizes[0].Stateful) +} + +func TestResizeVolumes_HappyPath(t *testing.T) { + t.Parallel() + + allow := true + sc := &storagev1.StorageClass{ + Name: "fast-ssd", + AllowVolumeExpansion: &allow, + } + sts := &appsv1.StatefulSet{ + Name: "shop-production-db", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {Name: "data"}, + }, + }, + } + qty10 := resource.MustParse("10Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "data-shop-production-db-0", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: qty10}, + }, + StorageClassName: new("fast-ssd"), + }, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: qty10}, + Conditions: []corev1.PersistentVolumeClaimCondition{{ + Type: corev1.PersistentVolumeClaimFileSystemResizePending, + Status: corev1.ConditionTrue, + }}, + }, + } + + client := fake.NewSimpleClientset(sc, sts, pvc) + resizes := []persistenceResize{{ + Component: "db", PreviousSize: "10Gi", NewSize: "20Gi", + StorageClass: "fast-ssd", Stateful: true, + }} + + err := resizeVolumes(t.Context(), client, "default", "shop-production", resizes) + require.NoError(t, err) + + updated, getErr := client.CoreV1().PersistentVolumeClaims("default").Get(t.Context(), "data-shop-production-db-0", metav1.GetOptions{}) + require.NoError(t, getErr) + assert.Equal(t, "20Gi", updated.Spec.Resources.Requests.Storage().String()) + + _, stsErr := client.AppsV1().StatefulSets("default").Get(t.Context(), "shop-production-db", metav1.GetOptions{}) + require.Error(t, stsErr, "StatefulSet should be orphan-deleted") +} + +func TestResizeVolumes_StatelessSharedPVC(t *testing.T) { + t.Parallel() + + allow := true + sc := &storagev1.StorageClass{ + Name: "fast-ssd", + AllowVolumeExpansion: &allow, + } + qty1 := resource.MustParse("1Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "shop-production-web", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "web", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: qty1}, + }, + StorageClassName: new("fast-ssd"), + }, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("2Gi")}, + }, + } + client := fake.NewSimpleClientset(sc, pvc) + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "web", NewSize: "2Gi", StorageClass: "fast-ssd", Stateful: false, + }}) + require.NoError(t, err) + + updated, getErr := client.CoreV1().PersistentVolumeClaims("default").Get(t.Context(), "shop-production-web", metav1.GetOptions{}) + require.NoError(t, getErr) + assert.Equal(t, "2Gi", updated.Spec.Resources.Requests.Storage().String()) +} + +func TestResizeVolumes_ExpansionNotAllowed(t *testing.T) { + t.Parallel() + allow := false + sc := &storagev1.StorageClass{ + Name: "slow", + AllowVolumeExpansion: &allow, + } + pvc := &corev1.PersistentVolumeClaim{ + Name: "shop-production-web", + Namespace: "default", + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + StorageClassName: new("slow"), + }, + } + client := fake.NewSimpleClientset(sc, pvc) + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "web", NewSize: "20Gi", StorageClass: "slow", Stateful: false, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not allow volume expansion") +} + +func TestResizeVolumes_DefaultStorageClass(t *testing.T) { + t.Parallel() + allow := true + sc := &storagev1.StorageClass{ + Name: "standard", + Annotations: map[string]string{ + defaultSCAnnotKey: "true", + }, + AllowVolumeExpansion: &allow, + } + pvc := &corev1.PersistentVolumeClaim{ + Name: "shop-production-web", + Namespace: "default", + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("2Gi")}, + }, + } + client := fake.NewSimpleClientset(sc, pvc) + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "web", NewSize: "2Gi", Stateful: false, + }}) + require.NoError(t, err) +} + +func TestResizeVolumes_PrefersPVCStorageClassOverHint(t *testing.T) { + t.Parallel() + allowFast := true + allowSlow := false + fast := &storagev1.StorageClass{ + Name: "fast-ssd", + AllowVolumeExpansion: &allowFast, + } + slow := &storagev1.StorageClass{ + Name: "slow", + AllowVolumeExpansion: &allowSlow, + } + pvc := &corev1.PersistentVolumeClaim{ + Name: "shop-production-web", + Namespace: "default", + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + StorageClassName: new("slow"), + }, + } + client := fake.NewSimpleClientset(fast, slow, pvc) + // Hint says fast (expandable), but live PVC is on slow (not expandable). + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "web", NewSize: "2Gi", StorageClass: "fast-ssd", Stateful: false, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "slow") + assert.Contains(t, err.Error(), "does not allow volume expansion") +} + +func TestResolveStorageClassForExpansion_Order(t *testing.T) { + t.Parallel() + allow := true + defaultSC := &storagev1.StorageClass{ + Name: "standard", + Annotations: map[string]string{ + defaultSCAnnotKey: "true", + }, + AllowVolumeExpansion: &allow, + } + client := fake.NewSimpleClientset(defaultSC) + + pvcWithClass := &corev1.PersistentVolumeClaim{ + Name: "with-class", + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: new("pvc-class")}, + } + got, err := resolveStorageClassForExpansion(t.Context(), client, pvcWithClass, "hint-class") + require.NoError(t, err) + assert.Equal(t, "pvc-class", got) + + pvcNoClass := &corev1.PersistentVolumeClaim{Name: "no-class"} + got, err = resolveStorageClassForExpansion(t.Context(), client, pvcNoClass, "hint-class") + require.NoError(t, err) + assert.Equal(t, "hint-class", got) + + got, err = resolveStorageClassForExpansion(t.Context(), client, pvcNoClass, "") + require.NoError(t, err) + assert.Equal(t, "standard", got) +} + +func TestResizeVolumes_OrphanDeleteMissingStatefulSet(t *testing.T) { + t.Parallel() + + allow := true + sc := &storagev1.StorageClass{ + Name: "fast-ssd", + AllowVolumeExpansion: &allow, + } + qty := resource.MustParse("10Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "data-shop-production-db-0", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: qty}, + }, + StorageClassName: new("fast-ssd"), + }, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")}, + Conditions: []corev1.PersistentVolumeClaimCondition{{ + Type: corev1.PersistentVolumeClaimFileSystemResizePending, + Status: corev1.ConditionTrue, + }}, + }, + } + client := fake.NewSimpleClientset(sc, pvc) + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "db", NewSize: "20Gi", StorageClass: "fast-ssd", Stateful: true, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), `orphan-delete: StatefulSet for component "db" not found`) +} + +func TestResizeVolumes_OrphanDeleteLeavesPods(t *testing.T) { + t.Parallel() + + allow := true + sc := &storagev1.StorageClass{ + Name: "fast-ssd", + AllowVolumeExpansion: &allow, + } + sts := &appsv1.StatefulSet{ + Name: "shop-production-db", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {Name: "data"}, + }, + }, + } + qty := resource.MustParse("10Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "data-shop-production-db-0", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: qty}, + }, + StorageClassName: new("fast-ssd"), + }, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")}, + Conditions: []corev1.PersistentVolumeClaimCondition{{ + Type: corev1.PersistentVolumeClaimFileSystemResizePending, + Status: corev1.ConditionTrue, + }}, + }, + } + pod := &corev1.Pod{ + Name: "shop-production-db-0", + Namespace: "default", + Labels: map[string]string{"app.kubernetes.io/instance": "shop-production"}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + client := fake.NewSimpleClientset(sc, sts, pvc, pod) + require.NoError(t, resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "db", NewSize: "20Gi", StorageClass: "fast-ssd", Stateful: true, + }})) + + livePod, err := client.CoreV1().Pods("default").Get(t.Context(), "shop-production-db-0", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, corev1.PodRunning, livePod.Status.Phase) + + livePVC, err := client.CoreV1().PersistentVolumeClaims("default").Get(t.Context(), "data-shop-production-db-0", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "20Gi", livePVC.Spec.Resources.Requests.Storage().String()) +} + +func TestResizeVolumes_NilClient(t *testing.T) { + t.Parallel() + err := resizeVolumes(t.Context(), nil, "default", "shop-production", []persistenceResize{{ + Component: "db", NewSize: "20Gi", Stateful: true, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes client is required") +} + +func TestResizeVolumes_EmptyResizes(t *testing.T) { + t.Parallel() + client := fake.NewSimpleClientset() + require.NoError(t, resizeVolumes(t.Context(), client, "default", "shop-production", nil)) +} + +func TestResizeVolumes_ParseSizeError(t *testing.T) { + t.Parallel() + client := fake.NewSimpleClientset() + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "db", NewSize: "not-a-quantity", Stateful: false, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse size") +} + +func TestResizeVolumes_NoPVCsFound(t *testing.T) { + t.Parallel() + client := fake.NewSimpleClientset() + err := resizeVolumes(t.Context(), client, "default", "shop-production", []persistenceResize{{ + Component: "web", NewSize: "2Gi", Stateful: false, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no PVCs found") +} + +func TestResolveStorageClassForExpansion_NoClassNoHintNoDefault(t *testing.T) { + t.Parallel() + client := fake.NewSimpleClientset() + pvc := &corev1.PersistentVolumeClaim{Name: "orphan"} + _, err := resolveStorageClassForExpansion(t.Context(), client, pvc, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "no storage class") + assert.Contains(t, err.Error(), "no default storage class") +} + +func TestPatchPVCSize_NilRequests(t *testing.T) { + t.Parallel() + qty := resource.MustParse("10Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "test-pvc", + Namespace: "default", + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: new("standard"), + }, + } + client := fake.NewSimpleClientset(pvc) + require.NoError(t, patchPVCSize(t.Context(), client, "default", pvc, qty)) + assert.Equal(t, qty, pvc.Spec.Resources.Requests[corev1.ResourceStorage]) +} + +func TestPatchPVCSize_AlreadyLargerSkips(t *testing.T) { + t.Parallel() + current := resource.MustParse("20Gi") + pvc := &corev1.PersistentVolumeClaim{ + Name: "test-pvc", + Namespace: "default", + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: current}, + }, + }, + } + client := fake.NewSimpleClientset(pvc) + require.NoError(t, patchPVCSize(t.Context(), client, "default", pvc, resource.MustParse("10Gi"))) + assert.Equal(t, "20Gi", pvc.Spec.Resources.Requests.Storage().String()) +} + +func TestDetectPersistenceResizes_NoChangeSkipped(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "10Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "10Gi", MountPath: "/data"}, + }, + }, + } + resizes := detectPersistenceResizes(manifest, "production", nil, prev) + assert.Empty(t, resizes) +} + +func TestDetectPersistenceResizes_DecreaseSkipped(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("db", map[string]any{ + "workloadKind": "StatefulSet", + "persistenceSize": "20Gi", + }).Chart.Values) + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Persistence: &spec.Persistence{Size: "10Gi", MountPath: "/data"}, + }, + }, + } + resizes := detectPersistenceResizes(manifest, "production", nil, prev) + assert.Empty(t, resizes, "decrease is not a resize; guard owns that error") +} + +func TestDetectPersistenceResizes_NoPersistenceSkipped(t *testing.T) { + t.Parallel() + prev := previousResolvedComponents(releaseWithResolved("peer", map[string]any{ + "workloadKind": "StatefulSet", + }).Chart.Values) + manifest := &spec.Spec{ + Components: map[string]spec.Component{ + "peer": {Kind: spec.ComponentKindStateful}, + }, + } + resizes := detectPersistenceResizes(manifest, "production", nil, prev) + assert.Empty(t, resizes) +} + +func TestOrphanDeleteStatefulSets_MultipleFound(t *testing.T) { + t.Parallel() + sts1 := &appsv1.StatefulSet{ + Name: "shop-production-db", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + } + sts2 := &appsv1.StatefulSet{ + Name: "shop-production-db-old", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "shop-production", + spec.LabelComponent: "db", + }, + } + client := fake.NewSimpleClientset(sts1, sts2) + err := orphanDeleteStatefulSets(t.Context(), client, "default", "shop-production", []string{"db"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected 1 StatefulSet") + assert.Contains(t, err.Error(), "found 2") +} + +func TestOrphanDeleteStatefulSets_EmptyComponents(t *testing.T) { + t.Parallel() + require.NoError(t, orphanDeleteStatefulSets(t.Context(), fake.NewSimpleClientset(), "default", "rel", nil)) +} diff --git a/internal/cmd/initialize/components.go b/internal/cmd/initialize/components.go index 4846e26..17105f3 100644 --- a/internal/cmd/initialize/components.go +++ b/internal/cmd/initialize/components.go @@ -59,7 +59,7 @@ var kindOrder = []spec.ComponentKind{ // kindLabels maps each kind to a select label with a short explanation. var kindLabels = map[spec.ComponentKind]string{ spec.ComponentKindStateless: "stateless - remembers nothing on disk; replicas scale freely", - spec.ComponentKindStateful: "stateful - keeps data on a persistent volume; stable replica identity", + spec.ComponentKindStateful: "stateful - stable replica identity (optional persistent volume)", } // kindFromLabel reverses kindLabels. It reports false when label does not @@ -273,6 +273,18 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo return fmt.Errorf("failed to collect component kind: %w", err) } + if component.Kind == spec.ComponentKindStateful { + if err = collectComponentPersistence(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component persistence: %w", err) + } + if err = collectComponentReplicas(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component replicas: %w", err) + } + if component.Persistence != nil { + c.Printf("Note: set a profile storageClass (or persistence.storageClass) in deployah.platform.yaml for this stateful component.\n") + } + } + if component.Expose != nil { if err = collectComponentExposeOptions(c, component, componentName); err != nil { return fmt.Errorf("failed to collect component expose options: %w", err) @@ -291,8 +303,12 @@ func collectComponentAdvanced(c *nabat.Context, component *spec.Component, compo return fmt.Errorf("failed to collect component config files: %w", err) } - if err = collectComponentAutoscaling(c, component, componentName); err != nil { - return fmt.Errorf("failed to collect component autoscaling: %w", err) + // Autoscaling is skipped for stateful in the init wizard; HPA remains + // available via the written deployah.yaml for advanced users. + if component.Kind != spec.ComponentKindStateful { + if err = collectComponentAutoscaling(c, component, componentName); err != nil { + return fmt.Errorf("failed to collect component autoscaling: %w", err) + } } if err = collectComponentEnvironmentVariables(c, component, componentName); err != nil { @@ -342,7 +358,7 @@ func collectComponentKind(c *nabat.Context, component *spec.Component, component } choice, err := c.Select( - fmt.Sprintf("Kind for %s - does it keep data on disk?", componentName), + fmt.Sprintf("Kind for %s - Deployment or StatefulSet (stable identity)?", componentName), labels, kindLabels[spec.ComponentKindStateless], ) @@ -358,6 +374,77 @@ func collectComponentKind(c *nabat.Context, component *spec.Component, component return nil } +func collectComponentPersistence(c *nabat.Context, component *spec.Component, componentName string) error { + addVolume, err := c.Confirm( + fmt.Sprintf("Add a persistent volume for %s?", componentName), + nabat.WithAffirmative("Yes"), + nabat.WithNegative("No, identity only (no PVC)"), + ) + if err != nil { + return fmt.Errorf("failed to get persistence preference: %w", err) + } + if !addVolume { + return nil + } + + size, err := c.Input( + fmt.Sprintf("Persistence size for %s", componentName), + nabat.WithHint("20Gi"), + nabat.WithDefault("20Gi"), + nabat.WithValidate(func(s string) error { + return validate.ValidateNonEmpty(s, "persistence.size") + }), + ) + if err != nil { + return fmt.Errorf("failed to collect persistence size: %w", err) + } + + mountPath, err := c.Input( + fmt.Sprintf("Persistence mount path for %s", componentName), + nabat.WithHint("/data"), + nabat.WithDefault("/data"), + nabat.WithValidate(func(s string) error { + if s == "" || s[0] != '/' { + return fmt.Errorf("mountPath must be an absolute path starting with /") + } + return nil + }), + ) + if err != nil { + return fmt.Errorf("failed to collect persistence mount path: %w", err) + } + + component.Persistence = &spec.Persistence{ + Size: size, + MountPath: mountPath, + } + return nil +} + +func collectComponentReplicas(c *nabat.Context, component *spec.Component, componentName string) error { + replicasStr, err := c.Input( + fmt.Sprintf("Replicas for %s", componentName), + nabat.WithHint("1"), + nabat.WithDefault("1"), + nabat.WithValidate(func(s string) error { + n, atoiErr := strconv.Atoi(s) + if atoiErr != nil || n < 1 { + return fmt.Errorf("replicas must be an integer >= 1") + } + return nil + }), + ) + if err != nil { + return fmt.Errorf("failed to collect replicas: %w", err) + } + replicas, err := strconv.Atoi(replicasStr) + if err != nil { + return fmt.Errorf("invalid replicas: %w", err) + } + component.Replicas = &replicas + return nil +} + func collectComponentImage(c *nabat.Context, component *spec.Component, componentName string) error { image, err := c.Input( fmt.Sprintf("Image for %s", componentName), diff --git a/internal/cmd/initialize/init_test.go b/internal/cmd/initialize/init_test.go index b8719ab..8075472 100644 --- a/internal/cmd/initialize/init_test.go +++ b/internal/cmd/initialize/init_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "nabat.dev/nabat" "nabat.dev/nabat/nabattest" + + "deployah.dev/deployah/internal/spec" ) // TestCheckOverwrite covers the file-exists x force matrix that guards @@ -17,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.2\n"), 0o600)) + require.NoError(t, os.WriteFile(existing, []byte("apiVersion: v1-alpha.3\n"), 0o600)) missing := filepath.Join(t.TempDir(), "missing.yaml") tests := []struct { @@ -76,3 +78,32 @@ func TestCheckOverwrite(t *testing.T) { }) } } + +// TestStatefulWizardDefaultsMatchValidation ensures the init wizard's +// stateful shapes (with or without persistence) pass validation. +func TestStatefulWizardDefaultsMatchValidation(t *testing.T) { + t.Parallel() + + replicas := 1 + withDisk := spec.Component{ + Kind: spec.ComponentKindStateful, + Image: "postgres:16", + Replicas: &replicas, + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + }, + } + require.NoError(t, spec.ValidateComponentPersistence(withDisk)) + require.NoError(t, spec.ValidateComponentReplicas(withDisk)) + + identityOnly := spec.Component{ + Kind: spec.ComponentKindStateful, + Image: "redis:7-alpine", + Replicas: &replicas, + } + require.NoError(t, spec.ValidateComponentPersistence(identityOnly)) + require.NoError(t, spec.ValidateComponentReplicas(identityOnly)) + assert.Nil(t, identityOnly.Persistence) + assert.Nil(t, identityOnly.Autoscaling, "init wizard skips autoscaling for stateful") +} diff --git a/internal/cmd/initialize/noninteractive_test.go b/internal/cmd/initialize/noninteractive_test.go index c6384ae..814520e 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.2\n"), 0o600)) + require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.3\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.2\n"), 0o600)) + require.NoError(t, os.WriteFile(outputPath, []byte("apiVersion: v1-alpha.3\n"), 0o600)) io, _, _, _ := nabattest.NewIO() app := newInitApp(io) diff --git a/internal/cmd/plan/plan_test.go b/internal/cmd/plan/plan_test.go index c49b4ac..9ff6772 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.2"} + return &spec.Spec{Project: "web", APIVersion: "v1-alpha.3"} } 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.2\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\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.2\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\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.2\nproject: web\n") + writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.3\nproject: web\n") writePlanExtras(t, dir, ".deployah/crds/widget.yaml", ` apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index f259945..88c6e2d 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -35,6 +35,7 @@ import ( "nabat.dev/nabat" "nabat.dev/nabat/nabattest" "sigs.k8s.io/e2e-framework/klient" + "sigs.k8s.io/e2e-framework/klient/k8s" "sigs.k8s.io/e2e-framework/klient/k8s/resources" "sigs.k8s.io/e2e-framework/klient/wait" "sigs.k8s.io/e2e-framework/klient/wait/conditions" @@ -51,10 +52,11 @@ import ( // `deployah cluster up`. type E2ESuite struct { suite.Suite - kcPath string - client klient.Client - scenarios []scenario - created bool // true once the suite attempts cluster up (teardown if partial) + kcPath string + client klient.Client + scenarios []scenario + testdataDir string // absolute; resolved before SetupSuite chdirs to a temp dir + created bool // true once the suite attempts cluster up (teardown if partial) } type scenario struct { @@ -74,11 +76,13 @@ type clusterStatusView struct { } type expectations struct { - Env string `yaml:"env"` - Namespace string `yaml:"namespace"` - Deployments []expectedDeployment `yaml:"deployments"` - Services []expectedService `yaml:"services"` - Pods expectedPods `yaml:"pods"` + Env string `yaml:"env"` + Namespace string `yaml:"namespace"` + Deployments []expectedDeployment `yaml:"deployments"` + StatefulSets []expectedStatefulSet `yaml:"statefulSets"` + Services []expectedService `yaml:"services"` + PVCs []expectedPVC `yaml:"pvcs"` + Pods expectedPods `yaml:"pods"` } type expectedDeployment struct { @@ -89,11 +93,28 @@ type expectedDeployment struct { Labels map[string]string `yaml:"labels"` } +type expectedStatefulSet struct { + Name string `yaml:"name"` + Replicas int32 `yaml:"replicas"` + Image string `yaml:"image"` + PortName string `yaml:"portName"` + Labels map[string]string `yaml:"labels"` +} + type expectedService struct { Name string `yaml:"name"` Port int32 `yaml:"port"` TargetPortName string `yaml:"targetPortName"` Selector map[string]string `yaml:"selector"` + // ClusterIP, when set to "None", asserts a headless Service. + ClusterIP string `yaml:"clusterIP"` +} + +type expectedPVC struct { + NamePrefix string `yaml:"namePrefix"` + MinCount int `yaml:"minCount"` + Phase string `yaml:"phase"` + Storage string `yaml:"storage"` } type expectedPods struct { @@ -116,6 +137,7 @@ func (s *E2ESuite) SetupSuite() { // the chdir below moves the whole suite out of it. testdataDir, err := filepath.Abs("testdata") s.Require().NoError(err) + s.testdataDir = testdataDir s.scenarios = discoverScenarios(t, testdataDir) // cluster up scaffolds deployah.platform.yaml into the cwd, so run from a @@ -153,6 +175,76 @@ func (s *E2ESuite) TearDownSuite() { } } +// TestStatefulScale deploys a stateful component at replicas 1, then upgrades +// to replicas 2 and asserts a second PVC is created. +func (s *E2ESuite) TestStatefulScale() { + t := s.T() + src := filepath.Join(s.testdataDir, "stateful-scale") + require.DirExists(t, src) + + // Work in a temp copy so swapping deployah.yaml never dirties testdata/. + dir := t.TempDir() + for _, name := range []string{"deployah.yaml", "deployah-replicas-2.yaml"} { + data, readErr := os.ReadFile(filepath.Join(src, name)) // #nosec G304 -- fixture under testdata + require.NoError(t, readErr) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600)) + } + + t.Chdir(dir) + t.Cleanup(func() { + if delErr := runErr(t, "delete", "stateful-scale", "dev", + "--yes", "--wait", "--allow-missing-platform", + "--context", "kind-deployah"); delErr != nil { + t.Logf("cleanup delete failed (non-fatal): %v", delErr) + } + }) + + run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") + + res := s.client.Resources("default") + ctx := t.Context() + stsName := "stateful-scale-dev-cache" + + require.NoError(t, wait.For( + conditions.New(res).ResourceMatch(&appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: stsName, Namespace: "default"}, + }, func(obj k8s.Object) bool { + live, ok := obj.(*appsv1.StatefulSet) + return ok && live.Status.ReadyReplicas >= 1 + }), + wait.WithTimeout(5*time.Minute), + wait.WithInterval(2*time.Second), + )) + + replicas2, readErr := os.ReadFile("deployah-replicas-2.yaml") // #nosec G304 -- temp fixture copy + require.NoError(t, readErr) + require.NoError(t, os.WriteFile("deployah.yaml", replicas2, 0o600)) + + run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") + require.NoError(t, wait.For( + conditions.New(res).ResourceMatch(&appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: stsName, Namespace: "default"}, + }, func(obj k8s.Object) bool { + live, ok := obj.(*appsv1.StatefulSet) + return ok && live.Spec.Replicas != nil && + *live.Spec.Replicas == 2 && live.Status.ReadyReplicas >= 2 + }), + wait.WithTimeout(5*time.Minute), + wait.WithInterval(2*time.Second), + )) + + var pvcs corev1.PersistentVolumeClaimList + require.NoError(t, res.List(ctx, &pvcs)) + matched := 0 + for _, pvc := range pvcs.Items { + if strings.HasPrefix(pvc.Name, "data-stateful-scale-dev-cache-") { + matched++ + assert.Equal(t, corev1.ClaimBound, pvc.Status.Phase, pvc.Name) + } + } + assert.GreaterOrEqual(t, matched, 2, "expected per-pod PVCs after scale-up") +} + // TestDeployScenarios deploys each discovered fixture and asserts expect.yaml. func (s *E2ESuite) TestDeployScenarios() { for _, sc := range s.scenarios { @@ -227,6 +319,50 @@ func (s *E2ESuite) assertExpectations(t testing.TB, exp expectations) { } } + for _, sts := range exp.StatefulSets { + target := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: sts.Name, Namespace: exp.Namespace}, + } + err := wait.For( + conditions.New(res).ResourceMatch(target, func(obj k8s.Object) bool { + live, ok := obj.(*appsv1.StatefulSet) + if !ok || live.Spec.Replicas == nil { + return false + } + return live.Status.ReadyReplicas >= *live.Spec.Replicas && + live.Status.ReadyReplicas > 0 + }), + wait.WithTimeout(5*time.Minute), + wait.WithInterval(2*time.Second), + ) + require.NoErrorf(t, err, "statefulset %s/%s never became ready", + exp.Namespace, sts.Name) + + var live appsv1.StatefulSet + require.NoError(t, res.Get(ctx, sts.Name, exp.Namespace, &live)) + dumpActual(t, &live) + + for key, val := range sts.Labels { + assert.Equalf(t, val, live.Labels[key], + "statefulset %s label %s", sts.Name, key) + } + containers := live.Spec.Template.Spec.Containers + require.NotEmptyf(t, containers, "statefulset %s has no containers", sts.Name) + assert.Equalf(t, sts.Image, containers[0].Image, + "statefulset %s image", sts.Name) + if sts.PortName != "" { + require.NotEmptyf(t, containers[0].Ports, + "statefulset %s has no ports", sts.Name) + assert.Equalf(t, sts.PortName, containers[0].Ports[0].Name, + "statefulset %s port name", sts.Name) + } + if sts.Replicas > 0 { + require.NotNil(t, live.Spec.Replicas) + assert.Equalf(t, sts.Replicas, *live.Spec.Replicas, + "statefulset %s replicas", sts.Name) + } + } + for _, svc := range exp.Services { var live corev1.Service require.NoError(t, res.Get(ctx, svc.Name, exp.Namespace, &live)) @@ -240,12 +376,39 @@ func (s *E2ESuite) assertExpectations(t testing.TB, exp expectations) { assert.Equalf(t, svc.TargetPortName, live.Spec.Ports[0].TargetPort.StrVal, "service %s targetPort name", svc.Name) } + if svc.ClusterIP == "None" { + assert.Equalf(t, corev1.ClusterIPNone, live.Spec.ClusterIP, + "service %s should be headless", svc.Name) + } for key, val := range svc.Selector { assert.Equalf(t, val, live.Spec.Selector[key], "service %s selector %s", svc.Name, key) } } + for _, wantPVC := range exp.PVCs { + var pvcs corev1.PersistentVolumeClaimList + require.NoError(t, res.List(ctx, &pvcs)) + matched := 0 + for _, pvc := range pvcs.Items { + if !strings.HasPrefix(pvc.Name, wantPVC.NamePrefix) { + continue + } + matched++ + if wantPVC.Phase != "" { + assert.Equalf(t, wantPVC.Phase, string(pvc.Status.Phase), + "pvc %s phase", pvc.Name) + } + if wantPVC.Storage != "" { + req := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + assert.Equalf(t, wantPVC.Storage, req.String(), + "pvc %s storage", pvc.Name) + } + } + assert.GreaterOrEqualf(t, matched, wantPVC.MinCount, + "pvcs with prefix %s", wantPVC.NamePrefix) + } + if exp.Pods.LabelSelector != "" { var pods corev1.PodList require.NoError(t, res.List(ctx, &pods, diff --git a/internal/e2e/testdata/basic-web-service/deployah.yaml b/internal/e2e/testdata/basic-web-service/deployah.yaml index 5fe5da8..2e21395 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.2 +apiVersion: v1-alpha.3 project: basic-web-service components: web: diff --git a/internal/e2e/testdata/stateful-basic/deployah.yaml b/internal/e2e/testdata/stateful-basic/deployah.yaml new file mode 100644 index 0000000..7f8a833 --- /dev/null +++ b/internal/e2e/testdata/stateful-basic/deployah.yaml @@ -0,0 +1,14 @@ +apiVersion: v1-alpha.3 +project: stateful-basic +components: + cache: + kind: stateful + image: redis:7-alpine + port: 6379 + resourcePreset: nano + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/internal/e2e/testdata/stateful-basic/expect.yaml b/internal/e2e/testdata/stateful-basic/expect.yaml new file mode 100644 index 0000000..255363c --- /dev/null +++ b/internal/e2e/testdata/stateful-basic/expect.yaml @@ -0,0 +1,34 @@ +env: dev +namespace: default +statefulSets: + - name: stateful-basic-dev-cache + replicas: 1 + image: docker.io/library/redis:7-alpine + portName: http + labels: + deployah.dev/project: stateful-basic + deployah.dev/environment: dev + deployah.dev/component: cache +services: + - name: stateful-basic-dev-cache + port: 80 + targetPortName: http + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: cache + - name: stateful-basic-dev-cache-headless + port: 80 + targetPortName: http + clusterIP: None + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: cache +pvcs: + - namePrefix: data-stateful-basic-dev-cache- + minCount: 1 + phase: Bound + storage: 1Gi +pods: + labelSelector: "deployah.dev/project=stateful-basic,deployah.dev/environment=dev" + minCount: 1 + phase: Running diff --git a/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml b/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml new file mode 100644 index 0000000..2c2f97d --- /dev/null +++ b/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml @@ -0,0 +1,15 @@ +apiVersion: v1-alpha.3 +project: stateful-scale +components: + cache: + kind: stateful + image: redis:7-alpine + port: 6379 + replicas: 2 + resourcePreset: nano + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/internal/e2e/testdata/stateful-scale/deployah.yaml b/internal/e2e/testdata/stateful-scale/deployah.yaml new file mode 100644 index 0000000..e542e23 --- /dev/null +++ b/internal/e2e/testdata/stateful-scale/deployah.yaml @@ -0,0 +1,15 @@ +apiVersion: v1-alpha.3 +project: stateful-scale +components: + cache: + kind: stateful + image: redis:7-alpine + port: 6379 + replicas: 1 + resourcePreset: nano + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/internal/extras/load_test.go b/internal/extras/load_test.go index 1d9a5cf..30dca62 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.2\nproject: demo\n") + writeFile(t, specPath, "apiVersion: deployah.dev/v1-alpha.3\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 70f2bc5..9767882 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.2", + APIVersion: "v1-alpha.3", Project: "cache-test", Components: map[string]spec.Component{"web": serviceComponent()}, } - require.NoError(t, spec.FillSpecWithDefaults(manifest, "v1-alpha.2")) + require.NoError(t, spec.FillSpecWithDefaults(manifest, "v1-alpha.3")) 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 bd641f3..d3b3e01 100644 --- a/internal/helm/chart/charts/deployah/templates/app.yaml +++ b/internal/helm/chart/charts/deployah/templates/app.yaml @@ -3,11 +3,16 @@ {{ include "deployah.configmap" . }} {{- end }} -{{- if .Values.image.repository }} +{{- if eq .Values.workloadKind "StatefulSet" }} +{{ include "deployah.statefulset" . }} +{{ include "deployah.headless-service" . }} +{{- else if .Values.image.repository }} {{ include "deployah.deployment" . }} {{- end }} +{{- if ne .Values.workloadKind "StatefulSet" }} {{ include "deployah.persistence" . }} +{{- end }} {{ include "deployah.hpa" . }} {{ include "deployah.ingress" . }} diff --git a/internal/helm/chart/charts/deployah/templates/deployment.yaml b/internal/helm/chart/charts/deployah/templates/deployment.yaml index c83f889..31e71b1 100644 --- a/internal/helm/chart/charts/deployah/templates/deployment.yaml +++ b/internal/helm/chart/charts/deployah/templates/deployment.yaml @@ -19,7 +19,10 @@ spec: {{- $podLabels := include "common.tplvalues.merge" (dict "values" (list .Values.podLabels .Values.commonLabels) "context" .) | fromYaml }} selector: matchLabels: {{- include "common.labels.matchLabels" ( dict "customLabels" $podLabels "context" $ ) | nindent 6 }} - {{- if .Values.updateStrategy }} + {{- if .Values.persistence.enabled }} + strategy: + type: Recreate + {{- else if .Values.updateStrategy }} strategy: {{- toYaml .Values.updateStrategy | nindent 4 }} {{- end }} template: diff --git a/internal/helm/chart/charts/deployah/templates/headless-service.yaml b/internal/helm/chart/charts/deployah/templates/headless-service.yaml new file mode 100644 index 0000000..4d7c10f --- /dev/null +++ b/internal/helm/chart/charts/deployah/templates/headless-service.yaml @@ -0,0 +1,22 @@ +{{- define "deployah.headless-service" -}} +{{- if and (eq .Values.workloadKind "StatefulSet") .Values.ports .Values.service.ports }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-headless" (include "common.names.fullname" .) }} + namespace: {{ include "common.names.namespace" . | quote }} + labels: {{- include "common.labels.standard" ( dict "customLabels" .Values.commonLabels "context" $ ) | nindent 4 }} + {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.service.annotations .Values.commonAnnotations) "context" .) | fromYaml }} + {{- if $annotations }} + annotations: {{- include "common.tplvalues.render" ( dict "value" $annotations "context" $ ) | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + clusterIP: None + publishNotReadyAddresses: true + ports: {{- include "common.tplvalues.render" (dict "value" .Values.service.ports "context" $) | nindent 4 }} + {{- $podLabels := include "common.tplvalues.merge" (dict "values" (list .Values.podLabels .Values.commonLabels) "context" .) | fromYaml }} + selector: {{- include "common.labels.matchLabels" ( dict "customLabels" $podLabels "context" $ ) | nindent 4 }} +{{- end }} +{{- end -}} diff --git a/internal/helm/chart/charts/deployah/templates/hpa.yaml b/internal/helm/chart/charts/deployah/templates/hpa.yaml index db01af2..c42949b 100644 --- a/internal/helm/chart/charts/deployah/templates/hpa.yaml +++ b/internal/helm/chart/charts/deployah/templates/hpa.yaml @@ -12,8 +12,13 @@ metadata: {{- end }} spec: scaleTargetRef: + {{- if eq .Values.workloadKind "StatefulSet" }} + apiVersion: {{ include "common.capabilities.statefulset.apiVersion" . }} + kind: StatefulSet + {{- else }} apiVersion: {{ include "common.capabilities.deployment.apiVersion" . }} kind: Deployment + {{- end }} name: {{ include "common.names.fullname" . }} minReplicas: {{ .Values.autoscaling.minReplicas }} maxReplicas: {{ .Values.autoscaling.maxReplicas }} diff --git a/internal/helm/chart/charts/deployah/templates/statefulset.yaml b/internal/helm/chart/charts/deployah/templates/statefulset.yaml new file mode 100644 index 0000000..7e297f6 --- /dev/null +++ b/internal/helm/chart/charts/deployah/templates/statefulset.yaml @@ -0,0 +1,191 @@ +{{- define "deployah.statefulset" -}} +{{- if eq .Values.workloadKind "StatefulSet" }} +--- +apiVersion: {{ include "common.capabilities.statefulset.apiVersion" . }} +kind: StatefulSet +metadata: + name: {{ include "common.names.fullname" . }} + namespace: {{ include "common.names.namespace" . | quote }} + {{- $labels := include "common.tplvalues.merge" (dict "values" (list .Values.labels .Values.commonLabels) "context" .) | fromYaml }} + labels: {{- include "common.labels.standard" ( dict "customLabels" $labels "context" $ ) | nindent 4 }} + {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.annotations .Values.commonAnnotations) "context" .) | fromYaml }} + {{- if $annotations }} + annotations: {{- include "common.tplvalues.render" ( dict "value" $annotations "context" $ ) | nindent 4 }} + {{- end }} +spec: + serviceName: {{ printf "%s-headless" (include "common.names.fullname" .) }} + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }} + {{- if .Values.statefulSet.updateStrategy }} + updateStrategy: {{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }} + {{- end }} + {{- if and .Values.persistence.enabled .Values.statefulSet.persistentVolumeClaimRetentionPolicy }} + persistentVolumeClaimRetentionPolicy: {{- toYaml .Values.statefulSet.persistentVolumeClaimRetentionPolicy | nindent 4 }} + {{- end }} + {{- $podLabels := include "common.tplvalues.merge" (dict "values" (list .Values.podLabels .Values.commonLabels) "context" .) | fromYaml }} + selector: + matchLabels: {{- include "common.labels.matchLabels" ( dict "customLabels" $podLabels "context" $ ) | nindent 6 }} + template: + metadata: + annotations: + {{- if .Values.podAnnotations }} + {{- include "common.tplvalues.render" (dict "value" .Values.podAnnotations "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.configMap.data }} + checksum/configMap: {{ include "deployah.configmap" . | sha256sum }} + {{- end }} + {{- if or .Values.secret.data .Values.secret.stringData }} + checksum/secret: {{ include "deployah.secret" . | sha256sum }} + {{- end }} + labels: {{- include "common.labels.standard" ( dict "customLabels" $podLabels "context" $ ) | nindent 8 }} + spec: + {{- if .Values.initContainers }} + initContainers: {{- include "common.tplvalues.render" (dict "value" .Values.initContainers "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.podRestartPolicy }} + restartPolicy: {{ .Values.podRestartPolicy }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- if .Values.containerSecurityContext.enabled }} + securityContext: {{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 12 }} + {{- end }} + image: {{ include "common.images.image" (dict "imageRoot" .Values.image "global" .Values.global) }} + imagePullPolicy: {{ default (eq .Values.image.tag "latest" | ternary "Always" "IfNotPresent") .Values.image.pullPolicy }} + {{- if .Values.command }} + command: {{- include "common.tplvalues.render" (dict "value" .Values.command "context" $) | nindent 12 }} + {{- end }} + {{- if .Values.args }} + args: {{- include "common.tplvalues.render" (dict "value" .Values.args "context" $) | nindent 12 }} + {{- end }} + {{- if .Values.envVars }} + env: {{- include "deployah.toEnvArray" (dict "envVars" .Values.envVars "context" $) | indent 12 }} + {{- end }} + {{- if or .Values.envVarsConfigMap .Values.envVarsSecret }} + envFrom: + {{- if .Values.envVarsConfigMap }} + - configMapRef: + name: {{ include "common.tplvalues.render" (dict "value" .Values.envVarsConfigMap "context" $) }} + {{- end }} + {{- if .Values.envVarsSecret }} + - secretRef: + name: {{ include "common.tplvalues.render" (dict "value" .Values.envVarsSecret "context" $) }} + {{- end }} + {{- end }} + {{- if .Values.ports }} + ports: {{- include "common.tplvalues.render" (dict "value" .Values.ports "context" $) | nindent 12 }} + {{- end }} + {{- if and .Values.livenessProbe.enabled (omit .Values.livenessProbe "enabled") }} + livenessProbe: {{- include "common.tplvalues.render" (dict "value" (omit .Values.livenessProbe "enabled") "context" $) | nindent 12 }} + {{- end }} + {{- if and .Values.readinessProbe.enabled (omit .Values.readinessProbe "enabled") }} + readinessProbe: {{- include "common.tplvalues.render" (dict "value" (omit .Values.readinessProbe "enabled") "context" $) | nindent 12 }} + {{- end }} + {{- if and .Values.startupProbe.enabled (omit .Values.startupProbe "enabled") }} + startupProbe: {{- include "common.tplvalues.render" (dict "value" (omit .Values.startupProbe "enabled") "context" $) | nindent 12 }} + {{- end }} + {{- if .Values.lifecycleHooks }} + lifecycle: {{- include "common.tplvalues.render" (dict "value" .Values.lifecycleHooks "context" $) | nindent 12 }} + {{- end }} + {{- if .Values.resources }} + resources: {{- toYaml .Values.resources | nindent 12 }} + {{- end }} + {{- if or .Values.configMap.mounted .Values.extraVolumeMounts .Values.persistence.enabled }} + volumeMounts: + {{- if .Values.configMap.mounted }} + - name: {{ include "common.names.fullname" . }} + mountPath: {{ .Values.configMap.mountPath }} + {{- if .Values.configMap.subPath }} + subPath: {{ .Values.configMap.subPath }} + {{- end }} + {{- end }} + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- if .Values.persistence.subPath }} + subPath: {{ .Values.persistence.subPath }} + {{- end }} + {{- end }} + {{- if .Values.extraVolumeMounts }} + {{- include "common.tplvalues.render" (dict "value" .Values.extraVolumeMounts "context" $) | nindent 12 }} + {{- end }} + {{- end }} + {{- if .Values.sidecars }} + {{- include "common.tplvalues.render" (dict "value" .Values.sidecars "context" $) | nindent 8 }} + {{- end }} + {{- if or .Values.configMap.mounted .Values.extraVolumes }} + volumes: + {{- if .Values.configMap.mounted }} + - name: {{ include "common.names.fullname" . }} + configMap: + name: {{ include "common.names.fullname" . }} + {{- end }} + {{- if .Values.extraVolumes }} + {{- include "common.tplvalues.render" ( dict "value" .Values.extraVolumes "context" $) | nindent 8 }} + {{- end }} + {{- end }} + {{- include "common.images.renderPullSecrets" (dict "images" (list .Values.image) "context" $) | nindent 6 }} + {{- if .Values.hostAliases }} + hostAliases: {{- include "common.tplvalues.render" (dict "value" .Values.hostAliases "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.affinity }} + affinity: {{- include "common.tplvalues.render" ( dict "value" .Values.affinity "context" $) | nindent 8 }} + {{- else if or .Values.podAffinityPreset .Values.podAntiAffinityPreset .Values.nodeAffinityPreset.type }} + affinity: + {{- if not (empty .Values.podAffinityPreset) }} + podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.podAffinityPreset "customLabels" $podLabels "context" $) | nindent 10 }} + {{- end }} + {{- if not (empty .Values.podAntiAffinityPreset) }} + podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.podAntiAffinityPreset "customLabels" $podLabels "context" $) | nindent 10 }} + {{- end }} + {{- if not (empty .Values.nodeAffinityPreset.type) }} + nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.nodeAffinityPreset.type "key" .Values.nodeAffinityPreset.key "values" .Values.nodeAffinityPreset.values) | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.nodeSelector }} + nodeSelector: {{- include "common.tplvalues.render" (dict "value" .Values.nodeSelector "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: {{- include "common.tplvalues.render" (dict "value" .Values.tolerations "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.priorityClassName }} + priorityClassName: {{ .Values.priorityClassName | quote }} + {{- end }} + {{- if .Values.topologySpreadConstraints }} + topologySpreadConstraints: {{- include "common.tplvalues.render" (dict "value" .Values.topologySpreadConstraints "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.schedulerName }} + schedulerName: {{ .Values.schedulerName | quote }} + {{- end }} + {{- if .Values.podSecurityContext.enabled }} + securityContext: {{- omit .Values.podSecurityContext "enabled" | toYaml | nindent 8 }} + {{- end }} + {{- if .Values.terminationGracePeriodSeconds }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- end }} + serviceAccountName: {{ include "deployah.serviceAccountName" . }} + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + {{- $pvcLabels := include "common.tplvalues.merge" (dict "values" (list .Values.commonLabels) "context" .) | fromYaml }} + labels: {{- include "common.labels.standard" ( dict "customLabels" $pvcLabels "context" $ ) | nindent 10 }} + {{- if or .Values.persistence.annotations .Values.commonAnnotations }} + {{- $pvcAnnotations := include "common.tplvalues.merge" (dict "values" (list .Values.persistence.annotations .Values.commonAnnotations) "context" .) }} + annotations: {{- include "common.tplvalues.render" ( dict "value" $pvcAnnotations "context" $ ) | nindent 10 }} + {{- end }} + spec: + accessModes: + {{- range .Values.persistence.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} + {{- include "common.storage.class" (dict "persistence" .Values.persistence "global" .Values.global) | nindent 8 }} + {{- end }} +{{- end }} +{{- end -}} diff --git a/internal/helm/chart/charts/deployah/values.yaml b/internal/helm/chart/charts/deployah/values.yaml index 92a1743..0cca589 100644 --- a/internal/helm/chart/charts/deployah/values.yaml +++ b/internal/helm/chart/charts/deployah/values.yaml @@ -104,6 +104,27 @@ exports: ## replicaCount: 1 + ## @param workloadKind Kubernetes workload kind: Deployment or StatefulSet + ## + workloadKind: Deployment + + ## StatefulSet-specific settings (used when workloadKind is StatefulSet) + ## + statefulSet: + ## @param statefulSet.updateStrategy.type StatefulSet update strategy + ## + updateStrategy: + type: RollingUpdate + ## @param statefulSet.podManagementPolicy OrderedReady or Parallel + ## + podManagementPolicy: OrderedReady + ## @param statefulSet.persistentVolumeClaimRetentionPolicy.whenDeleted Retain or Delete + ## @param statefulSet.persistentVolumeClaimRetentionPolicy.whenScaled Retain or Delete + ## + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + ## @param revisionHistoryLimit The number of old history to retain to allow rollback ## revisionHistoryLimit: 10 @@ -639,9 +660,10 @@ exports: ## annotations: {} ## @param persistence.accessModes Persistent Volume Access Modes + ## Stateful defaults to ReadWriteOncePod; stateless shared PVC uses ReadWriteOnce. ## accessModes: - - ReadWriteOnce + - ReadWriteOncePod ## @param persistence.size Size of data volume ## size: 8Gi diff --git a/internal/helm/generate.go b/internal/helm/generate.go index 169d2e6..a19ebdd 100644 --- a/internal/helm/generate.go +++ b/internal/helm/generate.go @@ -331,11 +331,6 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe // TODO: Implement component configFile -- deep-merge config.yaml < // config..yaml < config..yaml < config...yaml. - if component.Kind == spec.ComponentKindStateful { - // TODO: Add support for stateful components - return nil, fmt.Errorf("stateful components are not supported yet") - } - // TODO: Add support for component env, The user can specify the environment variables for the component e.g. NODE_ENV=roduction image := "" @@ -366,6 +361,67 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe componentValues["resources"] = resources + workloadKind := "Deployment" + if component.Kind == spec.ComponentKindStateful { + workloadKind = "StatefulSet" + } + componentValues["workloadKind"] = workloadKind + + if component.Replicas != nil { + componentValues["replicaCount"] = *component.Replicas + } + + if component.Persistence != nil { + accessModes := []string{"ReadWriteOnce"} + if component.Kind == spec.ComponentKindStateful { + accessModes = []string{"ReadWriteOncePod"} + } + persistence := map[string]any{ + "enabled": true, + "mountPath": component.Persistence.MountPath, + "size": component.Persistence.Size, + "accessModes": accessModes, + } + if resolved != nil { + if rc, ok := resolved.Components[componentName]; ok && rc.StorageClass != "" { + persistence["storageClass"] = rc.StorageClass + } + } + componentValues["persistence"] = persistence + if component.Kind != spec.ComponentKindStateful { + componentValues["updateStrategy"] = map[string]any{"type": "Recreate"} + } + } + + if component.Kind == spec.ComponentKindStateful { + statefulSet := map[string]any{ + "updateStrategy": map[string]any{ + "type": "RollingUpdate", + }, + "podManagementPolicy": "OrderedReady", + } + // Retention only applies when volumeClaimTemplates exist. + if component.Persistence != nil { + retention := map[string]any{ + "whenDeleted": "Retain", + "whenScaled": "Retain", + } + if resolved != nil { + if rc, ok := resolved.Components[componentName]; ok && + rc.MergedProfile != nil && rc.MergedProfile.PVCRetentionPolicy != nil { + if rc.MergedProfile.PVCRetentionPolicy.WhenDeleted != "" { + retention["whenDeleted"] = rc.MergedProfile.PVCRetentionPolicy.WhenDeleted + } + if rc.MergedProfile.PVCRetentionPolicy.WhenScaled != "" { + retention["whenScaled"] = rc.MergedProfile.PVCRetentionPolicy.WhenScaled + } + } + } + statefulSet["persistentVolumeClaimRetentionPolicy"] = retention + } + componentValues["statefulSet"] = statefulSet + } + if component.Expose != nil { ingressVals := map[string]any{"enabled": true} if resolved != nil { @@ -447,21 +503,37 @@ func MapSpecToChartValues(m *spec.Spec, desiredEnvironment string, resolved *spe maps.Copy(componentValues, probes) } + entry, hasEntry := resolvedComponents[componentName].(map[string]any) + if !hasEntry { + entry = map[string]any{} + resolvedComponents[componentName] = entry + } + entry["workloadKind"] = workloadKind + if component.Persistence != nil { + entry["persistenceMountPath"] = component.Persistence.MountPath + entry["persistenceSize"] = component.Persistence.Size + } + if resolved != nil { - if rc, ok := resolved.Components[componentName]; ok && rc.MergedProfile != nil { - if err := applyMergedProfile(componentValues, rc.MergedProfile); err != nil { - return nil, fmt.Errorf("component %s: apply profile values: %w", componentName, err) - } - entry, hasEntry := resolvedComponents[componentName].(map[string]any) - if !hasEntry { - entry = map[string]any{} - resolvedComponents[componentName] = entry + if rc, ok := resolved.Components[componentName]; ok { + if rc.MergedProfile != nil { + if err := applyMergedProfile(componentValues, rc.MergedProfile); err != nil { + return nil, fmt.Errorf("component %s: apply profile values: %w", componentName, err) + } } if len(rc.Profiles) > 0 { entry["profiles"] = rc.Profiles } if rc.StorageClass != "" { entry["storageClass"] = rc.StorageClass + // Profile-only storage class still needs to reach chart + // persistence when the component set persistence without + // an explicit key (component key already applied above). + if persistence, isMap := componentValues["persistence"].(map[string]any); isMap { + if _, set := persistence["storageClass"]; !set { + persistence["storageClass"] = rc.StorageClass + } + } } } } diff --git a/internal/helm/generate_test.go b/internal/helm/generate_test.go index 08d677b..e39f7b4 100644 --- a/internal/helm/generate_test.go +++ b/internal/helm/generate_test.go @@ -287,11 +287,11 @@ func TestMapSpecToChartValues_EnvironmentFilterPrefixMatch(t *testing.T) { comp := serviceComponent() comp.Environments = tt.filter m := &spec.Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", Project: "shop", Components: map[string]spec.Component{"web": comp}, } - require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) 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.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -375,7 +375,7 @@ func TestMapSpecToChartValues_SelfSignedTLS_Unmaterialized(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -418,7 +418,7 @@ func TestMapSpecToChartValues_SecretNameTLS(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -466,7 +466,7 @@ func TestMapSpecToChartValues_CertManagerTLS(t *testing.T) { subdomain := "api" m := &spec.Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -514,7 +514,7 @@ func TestMapSpecToChartValues_Autoscaling(t *testing.T) { t.Parallel() m := &spec.Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) 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.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) resolved := &spec.ResolvedSpec{ Spec: m, @@ -637,6 +637,176 @@ func TestMapSpecToChartValues_Profiles(t *testing.T) { assert.Equal(t, []string{"default", "public-web"}, webResolved["profiles"]) } +// TestMapSpecToChartValues_StatefulComponent maps StatefulSet workload values. +func TestMapSpecToChartValues_StatefulComponent(t *testing.T) { + t.Parallel() + + replicas := 2 + m := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{ + "production": {}, + }, + Components: map[string]spec.Component{ + "db": { + Role: spec.ComponentRoleService, + Kind: spec.ComponentKindStateful, + Image: "postgres:16", + Port: 5432, + Replicas: &replicas, + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/var/lib/postgresql/data", + }, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + + resolved := &spec.ResolvedSpec{ + Spec: m, + Env: spec.NormalizeEnv("production"), + Components: map[string]spec.ResolvedComponent{ + "db": { + StorageClass: "fast-ssd", + MergedProfile: &spec.PlatformProfile{ + PVCRetentionPolicy: &spec.PVCRetentionPolicy{ + WhenDeleted: "Delete", + WhenScaled: "Retain", + }, + }, + }, + }, + } + + vals, err := MapSpecToChartValues(m, "production", resolved) + require.NoError(t, err) + + db := mustNestedMap(t, vals, "db") + assert.Equal(t, "StatefulSet", db["workloadKind"]) + assert.Equal(t, 2, db["replicaCount"]) + + persistence := mustNestedMap(t, db, "persistence") + assert.Equal(t, true, persistence["enabled"]) + assert.Equal(t, "20Gi", persistence["size"]) + assert.Equal(t, "/var/lib/postgresql/data", persistence["mountPath"]) + assert.Equal(t, "fast-ssd", persistence["storageClass"]) + assert.Equal(t, []string{"ReadWriteOncePod"}, persistence["accessModes"]) + + statefulSet := mustNestedMap(t, db, "statefulSet") + retention := mustNestedMap(t, statefulSet, "persistentVolumeClaimRetentionPolicy") + assert.Equal(t, "Delete", retention["whenDeleted"]) + assert.Equal(t, "Retain", retention["whenScaled"]) + + deployah := mustNestedMap(t, vals, "deployah") + resolvedBlock := mustNestedMap(t, deployah, "resolved") + components := mustNestedMap(t, resolvedBlock, "components") + dbResolved := mustNestedMap(t, components, "db") + assert.Equal(t, "StatefulSet", dbResolved["workloadKind"]) + assert.Equal(t, "20Gi", dbResolved["persistenceSize"]) +} + +// TestMapSpecToChartValues_StatefulIdentityOnly maps StatefulSet without PVC. +func TestMapSpecToChartValues_StatefulIdentityOnly(t *testing.T) { + t.Parallel() + + replicas := 2 + m := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{ + "production": {}, + }, + Components: map[string]spec.Component{ + "peer": { + Role: spec.ComponentRoleService, + Kind: spec.ComponentKindStateful, + Image: "redis:7-alpine", + Port: 6379, + Replicas: &replicas, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + + vals, err := MapSpecToChartValues(m, "production", nil) + require.NoError(t, err) + + peer := mustNestedMap(t, vals, "peer") + assert.Equal(t, "StatefulSet", peer["workloadKind"]) + assert.Equal(t, 2, peer["replicaCount"]) + _, hasPersistence := peer["persistence"] + assert.False(t, hasPersistence, "identity-only stateful must not enable persistence") + assert.Contains(t, peer, "statefulSet") +} + +// TestMapSpecToChartValues_StatelessWithPersistence forces Recreate strategy. +func TestMapSpecToChartValues_StatelessWithPersistence(t *testing.T) { + t.Parallel() + + m := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{ + "production": {}, + }, + Components: map[string]spec.Component{ + "web": { + Role: spec.ComponentRoleService, + Kind: spec.ComponentKindStateless, + Image: "nginx:1.0.0", + Port: 80, + Persistence: &spec.Persistence{ + Size: "1Gi", + MountPath: "/data", + }, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + + vals, err := MapSpecToChartValues(m, "production", nil) + require.NoError(t, err) + + web := mustNestedMap(t, vals, "web") + assert.Equal(t, "Deployment", web["workloadKind"]) + persistence := mustNestedMap(t, web, "persistence") + assert.Equal(t, true, persistence["enabled"]) + assert.Equal(t, []string{"ReadWriteOnce"}, persistence["accessModes"]) + strategy := mustNestedMap(t, web, "updateStrategy") + assert.Equal(t, "Recreate", strategy["type"]) +} + +// TestMapSpecToChartValues_Replicas maps replicaCount from the spec. +func TestMapSpecToChartValues_Replicas(t *testing.T) { + t.Parallel() + + replicas := 3 + m := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{ + "production": {}, + }, + Components: map[string]spec.Component{ + "web": { + Role: spec.ComponentRoleService, + Image: "nginx:1.0.0", + Port: 80, + Replicas: &replicas, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, "v1-alpha.3")) + + vals, err := MapSpecToChartValues(m, "production", nil) + require.NoError(t, err) + web := mustNestedMap(t, vals, "web") + assert.Equal(t, 3, web["replicaCount"]) + assert.Equal(t, "Deployment", web["workloadKind"]) +} + // TestParseContainerImage verifies repository/tag/digest extraction across // bare names, tagged references, digest references, and malformed input. func TestParseContainerImage(t *testing.T) { diff --git a/internal/helm/helm.go b/internal/helm/helm.go index 5e9cff3..e3c4669 100644 --- a/internal/helm/helm.go +++ b/internal/helm/helm.go @@ -456,8 +456,8 @@ func (c *Client) wrapHelmError(operation, releaseName string, err error) error { return fmt.Errorf("operation timed out for release '%s': %w", releaseName, err) } - if _, ok := errors.AsType[*net.OpError](err); ok { - return fmt.Errorf("unable to connect to Kubernetes cluster: %w", err) + if opErr, ok := errors.AsType[*net.OpError](err); ok { + return fmt.Errorf("unable to connect to Kubernetes cluster: %w", opErr) } // Helm still surfaces some conditions as plain strings only. diff --git a/internal/k8s/version.go b/internal/k8s/version.go new file mode 100644 index 0000000..d3ee7cc --- /dev/null +++ b/internal/k8s/version.go @@ -0,0 +1,75 @@ +// 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" + "strconv" + "strings" + + "k8s.io/client-go/kubernetes" +) + +// MinStatefulMajor and MinStatefulMinor are the Kubernetes version floor +// for kind: stateful with persistence (RWOP GA at 1.29, PVC retention +// policy GA at 1.32). Identity-only stateful components do not require it. +const ( + MinStatefulMajor = 1 + MinStatefulMinor = 32 +) + +// CheckMinimumVersion probes the cluster server version and returns an +// error when it is below major.minor. reason is included in the error. +func CheckMinimumVersion(client kubernetes.Interface, major, minor int, reason string) error { + info, err := client.Discovery().ServerVersion() + if err != nil { + return fmt.Errorf("discover cluster version: %w", err) + } + + gotMajor, majorErr := parseVersionPart(info.Major) + if majorErr != nil { + return fmt.Errorf("parse cluster major version %q: %w", info.Major, majorErr) + } + gotMinor, minorErr := parseVersionPart(info.Minor) + if minorErr != nil { + return fmt.Errorf("parse cluster minor version %q: %w", info.Minor, minorErr) + } + + if gotMajor < major || (gotMajor == major && gotMinor < minor) { + return fmt.Errorf( + "cluster Kubernetes version %d.%d is below required %d.%d (%s)", + gotMajor, gotMinor, major, minor, reason, + ) + } + return nil +} + +// parseVersionPart accepts "1", "32", "32+", and "32.0" style discovery +// strings and returns the leading integer. +func parseVersionPart(s string) (int, error) { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, "+") + if i := strings.IndexAny(s, ".-"); i >= 0 { + s = s[:i] + } + if s == "" { + return 0, fmt.Errorf("empty version part") + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + return n, nil +} diff --git a/internal/k8s/version_test.go b/internal/k8s/version_test.go new file mode 100644 index 0000000..3d74074 --- /dev/null +++ b/internal/k8s/version_test.go @@ -0,0 +1,101 @@ +// 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" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/kubernetes/fake" + + fakediscovery "k8s.io/client-go/discovery/fake" +) + +func fakeClientWithVersion(major, minor string) *fake.Clientset { + cs := fake.NewSimpleClientset() + fd, ok := cs.Discovery().(*fakediscovery.FakeDiscovery) + if !ok { + panic("expected *fakediscovery.FakeDiscovery") + } + fd.FakedServerVersion = &version.Info{Major: major, Minor: minor} + return cs +} + +func TestCheckMinimumVersion_Pass(t *testing.T) { + t.Parallel() + err := CheckMinimumVersion(fakeClientWithVersion("1", "32"), 1, 32, "kind: stateful") + assert.NoError(t, err) +} + +func TestCheckMinimumVersion_HigherPass(t *testing.T) { + t.Parallel() + err := CheckMinimumVersion(fakeClientWithVersion("1", "33+"), 1, 32, "kind: stateful") + assert.NoError(t, err) +} + +func TestCheckMinimumVersion_Fail(t *testing.T) { + t.Parallel() + err := CheckMinimumVersion(fakeClientWithVersion("1", "31"), 1, 32, "kind: stateful requires Kubernetes 1.32+") + require.Error(t, err) + assert.Contains(t, err.Error(), "below required 1.32") + assert.Contains(t, err.Error(), "kind: stateful") +} + +func TestCheckMinimumVersion_ParseError(t *testing.T) { + t.Parallel() + err := CheckMinimumVersion(fakeClientWithVersion("x", "32"), 1, 32, "reason") + require.Error(t, err) + assert.Contains(t, err.Error(), "parse cluster major version") +} + +func TestParseVersionPart(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want int + }{ + {"1", 1}, + {"32", 32}, + {"32+", 32}, + {"32.0", 32}, + } + for _, tt := range tests { + got, err := parseVersionPart(tt.in) + require.NoError(t, err, tt.in) + assert.Equal(t, tt.want, got, tt.in) + } +} + +func TestParseVersionPart_Empty(t *testing.T) { + t.Parallel() + _, err := parseVersionPart("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty version part") +} + +func TestParseVersionPart_NonNumeric(t *testing.T) { + t.Parallel() + _, err := parseVersionPart("abc") + require.Error(t, err) +} + +func TestCheckMinimumVersion_MinorParseError(t *testing.T) { + t.Parallel() + err := CheckMinimumVersion(fakeClientWithVersion("1", "abc"), 1, 32, "reason") + require.Error(t, err) + assert.Contains(t, err.Error(), "parse cluster minor version") +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 572661b..e8c4e68 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.2 +const minimalSpecYAML = `apiVersion: v1-alpha.3 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.1 + platformYAML := `apiVersion: platform/v1-alpha.2 environments: production: context: prod-eks @@ -375,7 +375,7 @@ environments: platformDir := t.TempDir() platformPath := platformDir + "/deployah.platform.yaml" - platformYAML := `apiVersion: platform/v1-alpha.1 + platformYAML := `apiVersion: platform/v1-alpha.2 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.1 + platformYAML := `apiVersion: platform/v1-alpha.2 environments: production: domains: diff --git a/internal/spec/constants.go b/internal/spec/constants.go index c6afef3..379d2dd 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.2" + CurrentManifestVersion = "v1-alpha.3" // DefaultSpecPath is the default path for the Deployah spec file DefaultSpecPath = "deployah.yaml" diff --git a/internal/spec/defaults.go b/internal/spec/defaults.go index c935850..6b73312 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.2-spec", "v1-alpha.2-environments" +// Example: "v1-alpha.3-spec", "v1-alpha.3-environments" var ( // compiledSchemaCache stores compiled JSON schemas with their raw data - // Key format: "v1-alpha.2-spec" -> schemaInfo{compiled, rawData} + // Key format: "v1-alpha.3-spec" -> schemaInfo{compiled, rawData} compiledSchemaCache = make(map[string]*schemaInfo) // patternCache stores extracted component name patterns from schemas - // Key format: "v1-alpha.2" -> "^[a-zA-Z0-9_-]+$" + // Key format: "v1-alpha.3" -> "^[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.2 layout for components/environments); the + // pattern (the v1-alpha.3 layout for components/environments); the // pattern plays the same role as a patternProperties key. if addProps, exists := schemaMap["additionalProperties"].(map[string]any); exists { pattern := ".*" diff --git a/internal/spec/defaults_test.go b/internal/spec/defaults_test.go index ff2b715..3f216ff 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.2", + version: "v1-alpha.3", schemaType: schema.SchemaTypeManifest, expectErr: false, }, { name: "valid environments schema", - version: "v1-alpha.2", + version: "v1-alpha.3", schemaType: schema.SchemaTypeEnvironments, expectErr: false, }, @@ -155,7 +155,7 @@ func TestGetDefaultValues(t *testing.T) { }, { name: "unsupported schema type", - version: "v1-alpha.2", + version: "v1-alpha.3", 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.2; only the manifest schema must be non-empty. + // v1-alpha.3; 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.2", + APIVersion: "v1-alpha.3", Project: "test-project", Components: map[string]Component{ "web": { @@ -203,36 +203,36 @@ func TestFillSpecWithDefaults(t *testing.T) { }, }, }, - version: "v1-alpha.2", + version: "v1-alpha.3", expectErr: false, }, { name: "manifest with nil components", manifest: &Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", Project: "test-project", Components: nil, }, - version: "v1-alpha.2", + version: "v1-alpha.3", expectErr: false, }, { name: "manifest with environments", manifest: &Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", Project: "test-project", Components: map[string]Component{}, Environments: map[string]Environment{ "production": {}, }, }, - version: "v1-alpha.2", + version: "v1-alpha.3", expectErr: false, }, { name: "invalid version", manifest: &Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2")) + require.NoError(t, applyDefaultsRecursively(tt.obj, tt.defaults, tt.path, "v1-alpha.3")) 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.2")) + require.NoError(t, applyDefaultsToMap(tt.mapVal, tt.defaults, tt.path, "v1-alpha.3")) // 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.2")) + require.NoError(t, applyDefaultsToSlice(tt.sliceVal, tt.defaults, tt.path, "v1-alpha.3")) // 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.2", + version: "v1-alpha.3", 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.2") + manifest, err := CreateSpecWithDefaults("test-project", "v1-alpha.3") 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.2") + err = FillSpecWithDefaults(manifest, "v1-alpha.3") assert.NoError(t, err) webComponent := manifest.Components["web"] @@ -810,7 +810,7 @@ func TestIntegration(t *testing.T) { t.Parallel() manifest := &Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", Project: "test-project", Components: map[string]Component{ "api": { @@ -822,7 +822,7 @@ func TestIntegration(t *testing.T) { }, } - err := FillSpecWithDefaults(manifest, "v1-alpha.2") + err := FillSpecWithDefaults(manifest, "v1-alpha.3") assert.NoError(t, err) apiComponent := manifest.Components["api"] @@ -838,7 +838,7 @@ func TestIntegration(t *testing.T) { t.Parallel() manifest := &Spec{ - APIVersion: "v1-alpha.2", + APIVersion: "v1-alpha.3", 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.2") + err := FillSpecWithDefaults(manifest, "v1-alpha.3") assert.NoError(t, err) - // v1-alpha.2 declares no envFile/configFile defaults: the loader's + // v1-alpha.3 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.2", errContains: "spec cannot be nil"}, + {name: "nil spec returns error", spec: nil, version: "v1-alpha.3", 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.2") + err := applyDefaultsToMap(tt.value, tt.defaults, tt.path, "v1-alpha.3") 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.2") + err := applyDefaultsToSlice(tt.value, tt.defaults, tt.path, "v1-alpha.3") 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.2") + err := processStructField(field, fieldType, tt.defaults, tt.path, "v1-alpha.3") require.NoError(t, err) verify(t) }) diff --git a/internal/spec/example_test.go b/internal/spec/example_test.go index 4b234ff..21a89a7 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.2", + APIVersion: "v1-alpha.3", Project: "demo", Components: map[string]spec.Component{ "web": {Image: "nginx:latest"}, }, } - if err := spec.FillSpecWithDefaults(m, "v1-alpha.2"); err != nil { + if err := spec.FillSpecWithDefaults(m, "v1-alpha.3"); 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.2 + const yamlDoc = `apiVersion: v1-alpha.3 project: demo environments: default: {} diff --git a/internal/spec/field_validation.go b/internal/spec/field_validation.go index c7aa39f..451dfd5 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.2 models "environments" as + // Extract environment name pattern. v1-alpha.3 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 9e9fa26..171a65a 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.2 +// TestValidateEnvName verifies ValidateEnvName rules against the v1-alpha.3 // 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 5a90910..bb7c7fe 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.2 + doc := `apiVersion: v1-alpha.3 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.2 +apiVersion: v1-alpha.3 project: shop components: web: @@ -416,7 +416,7 @@ func TestLoad_OldProfileStringRejected(t *testing.T) { dir := t.TempDir() t.Chdir(dir) content := ` -apiVersion: v1-alpha.2 +apiVersion: v1-alpha.3 project: shop components: web: diff --git a/internal/spec/platform.go b/internal/spec/platform.go index 61902f8..0998d41 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.1". + // APIVersion is the platform schema version, e.g. "platform/v1-alpha.2". 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 @@ -67,6 +67,9 @@ type PlatformProfile struct { // StorageClass is a logical storage class key from the target // environment's storageClasses map. StorageClass string `json:"storageClass,omitempty" yaml:"storageClass,omitempty"` + // PVCRetentionPolicy overrides StatefulSet PVC retention for stateful + // components. Nil means chart defaults (Retain/Retain). + PVCRetentionPolicy *PVCRetentionPolicy `json:"pvcRetentionPolicy,omitempty" yaml:"pvcRetentionPolicy,omitempty"` // AllowedDomains restricts which domain keys a component may expose on. // nil means no constraint. A non-nil empty list means deny-all (no domain // is allowed). Multiple profiles intersect. Neither JSON nor YAML uses @@ -76,6 +79,14 @@ type PlatformProfile struct { MaxResources *ProfileMaxResources `json:"maxResources,omitempty" yaml:"maxResources,omitempty"` } +// PVCRetentionPolicy controls StatefulSet persistentVolumeClaimRetentionPolicy. +type PVCRetentionPolicy struct { + // WhenDeleted is Retain or Delete when the StatefulSet is deleted. + WhenDeleted string `json:"whenDeleted,omitempty" yaml:"whenDeleted,omitempty"` + // WhenScaled is Retain or Delete when the StatefulSet is scaled down. + WhenScaled string `json:"whenScaled,omitempty" yaml:"whenScaled,omitempty"` +} + // ProfileMaxResources caps component resource requests. type ProfileMaxResources struct { // CPU is the maximum CPU request (Kubernetes quantity). diff --git a/internal/spec/platform_loader.go b/internal/spec/platform_loader.go index 8da1e6c..d82e736 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.1"} +var SupportedPlatformVersions = []string{"platform/v1-alpha.2"} // 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.1" +const CurrentPlatformVersion = "platform/v1-alpha.2" // 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.1") is supported by the current version of +// (e.g. "platform/v1-alpha.2") 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 26c6452..89feb18 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.1 +apiVersion: platform/v1-alpha.2 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.1", p.APIVersion) + assert.Equal(t, "platform/v1-alpha.2", 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.1 +apiVersion: platform/v1-alpha.2 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.1 +apiVersion: platform/v1-alpha.2 environments: prod: domains: @@ -180,7 +180,7 @@ func TestNormalizeEnv_Wildcard(t *testing.T) { func minimalPlatform() *spec.PlatformConfig { return &spec.PlatformConfig{ - APIVersion: "platform/v1-alpha.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.1 + doc := `apiVersion: platform/v1-alpha.2 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.1\nenvironments:\n prod:\n context: prod\n"), 0o600)) + require.NoError(t, os.WriteFile(path, []byte("apiVersion: platform/v1-alpha.2\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.1", + APIVersion: "platform/v1-alpha.2", 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.1", + APIVersion: "platform/v1-alpha.2", 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.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.2", + APIVersion: "v1-alpha.3", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.2", + APIVersion: "v1-alpha.3", 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.1", + APIVersion: "platform/v1-alpha.2", 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.1 +apiVersion: platform/v1-alpha.2 profiles: default: nodeSelector: @@ -907,7 +907,7 @@ environments: func TestLoadPlatform_ProfileUnknownDomainRef(t *testing.T) { t.Parallel() yaml := ` -apiVersion: platform/v1-alpha.1 +apiVersion: platform/v1-alpha.2 profiles: public-web: allowedDomains: [missing] @@ -930,7 +930,7 @@ environments: func TestLoadPlatform_ProfileUnknownStorageClassRef(t *testing.T) { t.Parallel() yaml := ` -apiVersion: platform/v1-alpha.1 +apiVersion: platform/v1-alpha.2 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.2", + APIVersion: "v1-alpha.3", 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.2", + APIVersion: "v1-alpha.3", 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.2", + APIVersion: "v1-alpha.3", Project: "shop", Environments: map[string]spec.Environment{"production": {}}, Components: map[string]spec.Component{ @@ -1191,6 +1191,93 @@ func TestResolve_Profiles(t *testing.T) { } } +// TestResolve_ComponentStorageClass verifies persistence.storageClass wins +// over the profile key and fails when the key is unknown. +func TestResolve_ComponentStorageClass(t *testing.T) { + t.Parallel() + + platform := platformWithProfiles() + prod := platform.Environments["production"] + prod.StorageClasses["standard"] = spec.PlatformStorageClass{ClassName: "standard-hdd"} + platform.Environments["production"] = prod + + t.Run("component key wins over profile", func(t *testing.T) { + t.Parallel() + appSpec := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{"production": {}}, + Components: map[string]spec.Component{ + "db": { + Kind: spec.ComponentKindStateful, + Profiles: []string{"gpu"}, // profile storageClass: fast + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + StorageClass: "standard", + }, + }, + }, + } + resolved, report, err := spec.Resolve(appSpec, platform, spec.NormalizeEnv("production"), spec.SubstitutionReport{}) + require.NoError(t, err) + assert.Equal(t, "standard-hdd", resolved.Components["db"].StorageClass) + var source string + for _, f := range report.Fields { + if f.Component == "db" && f.Path == "storageClass" { + source = f.Source + } + } + assert.Contains(t, source, "component persistence.storageClass") + }) + + t.Run("unknown component key errors", func(t *testing.T) { + t.Parallel() + appSpec := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{"production": {}}, + Components: map[string]spec.Component{ + "db": { + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + StorageClass: "missing", + }, + }, + }, + } + _, report, err := spec.Resolve(appSpec, platform, spec.NormalizeEnv("production"), spec.SubstitutionReport{}) + require.Error(t, err) + require.NotNil(t, report) + assert.Equal(t, spec.ErrCodeComponentStorageClassNotFound, report.ErrorCode) + }) + + t.Run("component key without storageClasses map errors", func(t *testing.T) { + t.Parallel() + p := minimalPlatform() + appSpec := &spec.Spec{ + APIVersion: "v1-alpha.3", + Project: "shop", + Environments: map[string]spec.Environment{"local": {}}, + Components: map[string]spec.Component{ + "db": { + Persistence: &spec.Persistence{ + Size: "20Gi", + MountPath: "/data", + StorageClass: "fast", + }, + }, + }, + } + _, report, err := spec.Resolve(appSpec, p, spec.NormalizeEnv("local"), spec.SubstitutionReport{}) + require.Error(t, err) + require.NotNil(t, report) + assert.Equal(t, spec.ErrCodeComponentStorageClassNotFound, report.ErrorCode) + assert.Contains(t, err.Error(), "no storageClasses") + }) +} + // TestResolveForDisplay covers offline partial results and the happy path that // delegates to Resolve. func TestResolveForDisplay(t *testing.T) { diff --git a/internal/spec/profile.go b/internal/spec/profile.go index 2099320..669ce72 100644 --- a/internal/spec/profile.go +++ b/internal/spec/profile.go @@ -87,6 +87,7 @@ func ResolveProfileNames(componentProfiles []string, platformProfiles map[string // false *bool values that mergo would skip // - arrays (tolerations): concatenate and deduplicate identical entries // - scalars (storageClass): last non-empty 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 func MergeProfiles(names []string, profiles map[string]PlatformProfile) (PlatformProfile, error) { @@ -118,6 +119,9 @@ func MergeProfiles(names []string, profiles map[string]PlatformProfile) (Platfor if p.StorageClass != "" { merged.StorageClass = p.StorageClass } + if p.PVCRetentionPolicy != nil { + merged.PVCRetentionPolicy = mergePVCRetentionPolicy(merged.PVCRetentionPolicy, p.PVCRetentionPolicy) + } if p.AllowedDomains != nil { if !allowedDomainsSet { merged.AllowedDomains = slices.Clone(p.AllowedDomains) @@ -132,6 +136,25 @@ func MergeProfiles(names []string, profiles map[string]PlatformProfile) (Platfor return merged, nil } +// mergePVCRetentionPolicy overlays overlay onto base. Non-empty overlay fields +// win; nil overlay returns base unchanged. +func mergePVCRetentionPolicy(base, overlay *PVCRetentionPolicy) *PVCRetentionPolicy { + if overlay == nil { + return base + } + out := &PVCRetentionPolicy{} + if base != nil { + *out = *base + } + if overlay.WhenDeleted != "" { + out.WhenDeleted = overlay.WhenDeleted + } + if overlay.WhenScaled != "" { + out.WhenScaled = overlay.WhenScaled + } + return out +} + // ValidateProfileAgainstComponent checks domain, storage class, and resource // ceiling constraints from the merged profile against the component and // target environment. diff --git a/internal/spec/profile_test.go b/internal/spec/profile_test.go index 6ec78d7..213f20d 100644 --- a/internal/spec/profile_test.go +++ b/internal/spec/profile_test.go @@ -164,6 +164,28 @@ func TestMergeProfiles(t *testing.T) { assert.Equal(t, spec.ErrCodeProfileNotFound, re.Code) }) + t.Run("pvcRetentionPolicy last non-nil wins with field overlay", func(t *testing.T) { + t.Parallel() + withRetention := map[string]spec.PlatformProfile{ + "base": { + PVCRetentionPolicy: &spec.PVCRetentionPolicy{ + WhenDeleted: "Retain", + WhenScaled: "Retain", + }, + }, + "override": { + PVCRetentionPolicy: &spec.PVCRetentionPolicy{ + WhenDeleted: "Delete", + }, + }, + } + merged, err := spec.MergeProfiles([]string{"base", "override"}, withRetention) + require.NoError(t, err) + require.NotNil(t, merged.PVCRetentionPolicy) + assert.Equal(t, "Delete", merged.PVCRetentionPolicy.WhenDeleted) + assert.Equal(t, "Retain", merged.PVCRetentionPolicy.WhenScaled) + }) + t.Run("deep merge maps last wins", func(t *testing.T) { t.Parallel() merged, err := spec.MergeProfiles([]string{"a", "b"}, profiles) diff --git a/internal/spec/resolve.go b/internal/spec/resolve.go index e2f7d73..e1340a9 100644 --- a/internal/spec/resolve.go +++ b/internal/spec/resolve.go @@ -195,7 +195,9 @@ func resolveComponent( if profileErr := ValidateProfileAgainstComponent(name, comp, *rc.MergedProfile, platformEnv, ""); profileErr != nil { return rc, result, profileErr } - applyResolvedStorageClass(&rc, &result, name, env, platformEnv) + } + if scErr := applyResolvedStorageClass(&rc, &result, name, comp, env, platformEnv); scErr != nil { + return rc, result, scErr } return rc, result, nil } @@ -369,38 +371,71 @@ func resolveComponent( if profileErr := ValidateProfileAgainstComponent(name, comp, *rc.MergedProfile, platformEnv, domainKey); profileErr != nil { return rc, result, profileErr } - applyResolvedStorageClass(&rc, &result, name, env, platformEnv) + } + if scErr := applyResolvedStorageClass(&rc, &result, name, comp, env, platformEnv); scErr != nil { + return rc, result, scErr } return rc, result, nil } -// applyResolvedStorageClass copies the merged profile's logical storage class -// key to the Kubernetes className after validation has succeeded. +// 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. +// When no key is set, storageClassName is left empty (cluster default). func applyResolvedStorageClass( rc *ResolvedComponent, result *componentResolveResult, compName string, + comp Component, env EnvIdentity, platformEnv *PlatformEnvironment, -) { - if rc.MergedProfile == nil || rc.MergedProfile.StorageClass == "" || platformEnv == nil { - return +) error { + logicalKey := "" + sourceKind := "" + if comp.Persistence != nil && comp.Persistence.StorageClass != "" { + logicalKey = comp.Persistence.StorageClass + sourceKind = "component persistence.storageClass" + } else if rc.MergedProfile != nil && rc.MergedProfile.StorageClass != "" { + logicalKey = rc.MergedProfile.StorageClass + sourceKind = "platform profiles -> storageClass" + } + if logicalKey == "" { + return nil + } + + if platformEnv == nil || platformEnv.StorageClasses == nil { + return &ResolutionError{ + Code: ErrCodeComponentStorageClassNotFound, + Message: fmt.Sprintf( + "component %q references storageClass %q (%s) but the environment has no storageClasses", + compName, logicalKey, sourceKind, + ), + } } - sc, ok := platformEnv.StorageClasses[rc.MergedProfile.StorageClass] + sc, ok := platformEnv.StorageClasses[logicalKey] if !ok { - return + available := slices.Sorted(maps.Keys(platformEnv.StorageClasses)) + return &ResolutionError{ + Code: ErrCodeComponentStorageClassNotFound, + Message: fmt.Sprintf( + "component %q references storageClass %q (%s) but environment does not define it (available: %s)", + compName, logicalKey, sourceKind, joinStrings(available), + ), + } } + rc.StorageClass = sc.ClassName result.fields = append(result.fields, ResolvedField{ Component: compName, Path: "storageClass", Value: sc.ClassName, Source: fmt.Sprintf( - "platform profiles -> storageClass %q -> environments.%s.storageClasses.%s.className", - rc.MergedProfile.StorageClass, env.Original, rc.MergedProfile.StorageClass, + "%s %q -> environments.%s.storageClasses.%s.className", + sourceKind, logicalKey, env.Original, logicalKey, ), }) + return nil } // defaultDomainKey picks the domain used when a component names none: the diff --git a/internal/spec/resolved_spec.go b/internal/spec/resolved_spec.go index 8c2f333..2e625ee 100644 --- a/internal/spec/resolved_spec.go +++ b/internal/spec/resolved_spec.go @@ -105,19 +105,20 @@ type ResolvedField struct { // Resolution error codes for use in the resolution report and JSON output. const ( - ErrCodePlatformNotFound = "PLATFORM_NOT_FOUND" - ErrCodePlatformEnvNotFound = "PLATFORM_ENV_NOT_FOUND" - ErrCodeDomainGap = "DOMAIN_GAP" - ErrCodeFQDNCollision = "FQDN_COLLISION" - ErrCodeInvalidDNS = "INVALID_DNS" - ErrCodeStaticWildcardSubdomain = "STATIC_WILDCARD_SUBDOMAIN" - ErrCodeContextMismatch = "CONTEXT_MISMATCH" - ErrCodeHostnameChanged = "HOSTNAME_CHANGED" - ErrCodeProfileNotFound = "PROFILE_NOT_FOUND" - ErrCodeProfileDomainNotAllowed = "PROFILE_DOMAIN_NOT_ALLOWED" - ErrCodeProfileStorageClassNotFound = "PROFILE_STORAGE_CLASS_NOT_FOUND" - ErrCodeProfileResourceExceeded = "PROFILE_RESOURCE_EXCEEDED" - ErrCodeProfileOptOutBlocked = "PROFILE_OPT_OUT_BLOCKED" + ErrCodePlatformNotFound = "PLATFORM_NOT_FOUND" + ErrCodePlatformEnvNotFound = "PLATFORM_ENV_NOT_FOUND" + ErrCodeDomainGap = "DOMAIN_GAP" + ErrCodeFQDNCollision = "FQDN_COLLISION" + ErrCodeInvalidDNS = "INVALID_DNS" + ErrCodeStaticWildcardSubdomain = "STATIC_WILDCARD_SUBDOMAIN" + ErrCodeContextMismatch = "CONTEXT_MISMATCH" + ErrCodeHostnameChanged = "HOSTNAME_CHANGED" + ErrCodeProfileNotFound = "PROFILE_NOT_FOUND" + ErrCodeProfileDomainNotAllowed = "PROFILE_DOMAIN_NOT_ALLOWED" + ErrCodeProfileStorageClassNotFound = "PROFILE_STORAGE_CLASS_NOT_FOUND" + ErrCodeComponentStorageClassNotFound = "COMPONENT_STORAGE_CLASS_NOT_FOUND" + ErrCodeProfileResourceExceeded = "PROFILE_RESOURCE_EXCEEDED" + ErrCodeProfileOptOutBlocked = "PROFILE_OPT_OUT_BLOCKED" ) // ResolutionError is a resolution error that carries a machine-readable code. diff --git a/internal/spec/schema/platform/v1-alpha.1/platform.json b/internal/spec/schema/platform/v1-alpha.2/platform.json similarity index 91% rename from internal/spec/schema/platform/v1-alpha.1/platform.json rename to internal/spec/schema/platform/v1-alpha.2/platform.json index 3619d1f..5d583b3 100644 --- a/internal/spec/schema/platform/v1-alpha.1/platform.json +++ b/internal/spec/schema/platform/v1-alpha.2/platform.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/platform/v1-alpha.1/platform.json", + "$id": "https://deployah.dev/schemas/platform/v1-alpha.2/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.", "type": "object", @@ -10,8 +10,8 @@ "apiVersion": { "type": "string", "title": "API Version", - "description": "Platform schema version. Must be 'platform/v1-alpha.1'.", - "const": "platform/v1-alpha.1" + "description": "Platform schema version. Must be 'platform/v1-alpha.2'.", + "const": "platform/v1-alpha.2" }, "profiles": { "type": "object", @@ -263,6 +263,9 @@ "minLength": 1, "examples": ["fast", "standard"] }, + "pvcRetentionPolicy": { + "$ref": "#/$defs/PVCRetentionPolicy" + }, "allowedDomains": { "type": "array", "title": "Allowed Domains", @@ -340,11 +343,37 @@ "examples": ["2Gi", "512Mi"] } } + }, + "PVCRetentionPolicy": { + "type": "object", + "title": "PVC Retention Policy", + "description": "Overrides StatefulSet persistentVolumeClaimRetentionPolicy for stateful components. Defaults are Retain for both fields when omitted.", + "additionalProperties": false, + "properties": { + "whenDeleted": { + "type": "string", + "title": "When Deleted", + "description": "PVC retention when the StatefulSet is deleted.", + "enum": ["Retain", "Delete"], + "examples": ["Retain", "Delete"] + }, + "whenScaled": { + "type": "string", + "title": "When Scaled", + "description": "PVC retention when the StatefulSet is scaled down.", + "enum": ["Retain", "Delete"], + "examples": ["Retain", "Delete"] + } + }, + "examples": [ + {"whenDeleted": "Retain", "whenScaled": "Retain"}, + {"whenDeleted": "Delete", "whenScaled": "Delete"} + ] } }, "examples": [ { - "apiVersion": "platform/v1-alpha.1", + "apiVersion": "platform/v1-alpha.2", "profiles": { "default": { "nodeSelector": {"workload": "general"} diff --git a/internal/spec/schema/schema.go b/internal/spec/schema/schema.go index d20cade..482fb28 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.2", "v1-beta.2", + // versionRegex matches version strings such as "v1-alpha.3", "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.2", "v1-beta.2", etc. +// Version strings should follow the format "v1-alpha.3", "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.2", "v1-beta.2", etc. +// Version strings should follow the format "v1-alpha.3", "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.2", "v1-beta.2", etc. The schema file must be named +// "v1-alpha.3", "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 0c1cc87..b215757 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.2") + schema, err := GetManifestSchema("v1-alpha.3") 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.2") + schema, err := GetEnvironmentsSchema("v1-alpha.3") s.Require().NoError(err) s.Require().NotNil(schema) } diff --git a/internal/spec/schema/v1-alpha.2/environments.json b/internal/spec/schema/v1-alpha.3/environments.json similarity index 94% rename from internal/spec/schema/v1-alpha.2/environments.json rename to internal/spec/schema/v1-alpha.3/environments.json index 4c99393..fe39c17 100644 --- a/internal/spec/schema/v1-alpha.2/environments.json +++ b/internal/spec/schema/v1-alpha.3/environments.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/v1-alpha.2/environments.json", - "title": "Deployah Environments v1-alpha.2", - "description": "Schema for the environments section of the v1-alpha.2 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.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.", "type": "object", "additionalProperties": true, "properties": { diff --git a/internal/spec/schema/v1-alpha.2/manifest.json b/internal/spec/schema/v1-alpha.3/manifest.json similarity index 87% rename from internal/spec/schema/v1-alpha.2/manifest.json rename to internal/spec/schema/v1-alpha.3/manifest.json index 08c4ba4..8678698 100644 --- a/internal/spec/schema/v1-alpha.2/manifest.json +++ b/internal/spec/schema/v1-alpha.3/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deployah.dev/schemas/v1-alpha.2/manifest.json", - "title": "Deployah Spec v1-alpha.2", + "$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.", "type": "object", "additionalProperties": false, @@ -10,8 +10,8 @@ "apiVersion": { "type": "string", "title": "API Version", - "description": "Schema version. Must be 'v1-alpha.2'.", - "const": "v1-alpha.2" + "description": "Schema version. Must be 'v1-alpha.3'.", + "const": "v1-alpha.3" }, "project": { "type": "string", @@ -111,7 +111,7 @@ "kind": { "type": "string", "title": "Component Kind", - "description": "'stateless' for replicas without persistent storage. 'stateful' for replicas requiring a PVC.", + "description": "'stateless' for interchangeable Deployment replicas. 'stateful' for StatefulSet replicas with stable network identity; optional persistence for per-pod PVCs.", "default": "stateless", "enum": ["stateless", "stateful"] }, @@ -156,6 +156,17 @@ "maximum": 65535, "examples": [8181, 9000] }, + "replicas": { + "type": "integer", + "title": "Replicas", + "description": "Desired replica count when autoscaling is disabled. Default is 1. Mutually exclusive with autoscaling.enabled.", + "default": 1, + "minimum": 1, + "examples": [1, 2, 3] + }, + "persistence": { + "$ref": "#/$defs/Persistence" + }, "expose": { "title": "Expose", "description": "Boolean shorthand or object. 'expose: true' exposes with all defaults (the environment's default domain, the component name as subdomain, platform TLS); 'expose: false' equals omitting the block.", @@ -235,6 +246,42 @@ } } }, + "Persistence": { + "type": "object", + "title": "Persistence", + "description": "Durable storage configuration. Optional on kind: stateful (per-pod volumeClaimTemplates when set; omit for identity-only). Allowed on kind: stateless (shared PVC; forces Recreate strategy; rejects replicas > 1 and autoscaling).", + "additionalProperties": false, + "required": ["size", "mountPath"], + "properties": { + "size": { + "type": "string", + "title": "Size", + "description": "Requested volume size as a Kubernetes quantity.", + "pattern": "^[1-9][0-9]*(Ki|Mi|Gi|Ti|Pi|Ei)$", + "examples": ["1Gi", "20Gi", "100Gi"] + }, + "mountPath": { + "type": "string", + "title": "Mount Path", + "description": "Absolute path where the volume is mounted in the container.", + "pattern": "^/", + "minLength": 1, + "examples": ["/data", "/var/lib/postgresql/data"] + }, + "storageClass": { + "type": "string", + "title": "Storage Class", + "description": "Logical storage class key from the platform environment's storageClasses map. Overrides the profile storageClass when set.", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "minLength": 1, + "examples": ["fast", "standard"] + } + }, + "examples": [ + {"size": "20Gi", "mountPath": "/data"}, + {"size": "50Gi", "mountPath": "/var/lib/postgresql/data", "storageClass": "fast"} + ] + }, "Expose": { "type": "object", "title": "Expose", @@ -438,7 +485,7 @@ }, "examples": [ { - "apiVersion": "v1-alpha.2", + "apiVersion": "v1-alpha.3", "project": "shop", "environments": { "production": { diff --git a/internal/spec/types.go b/internal/spec/types.go index 8838bd5..f735eb7 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.2"). + // APIVersion is the schema version of the spec (e.g., "v1-alpha.3"). APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` // Project is the project name. Project string `json:"project" yaml:"project"` @@ -82,6 +82,15 @@ type Component struct { Args []string `json:"args,omitempty" yaml:"args,omitempty"` // Port is the primary container port for services. Port int `json:"port,omitempty" yaml:"port,omitempty"` + // Replicas is the desired replica count when autoscaling is disabled. + // Nil means the chart default (1). Mutually exclusive with + // autoscaling.enabled. + Replicas *int `json:"replicas,omitempty" yaml:"replicas,omitempty"` + // Persistence configures durable storage for the component. Optional on + // stateful components (omit for identity-only StatefulSet). Allowed on + // stateless components (shared PVC with Recreate strategy) subject to + // replica and autoscaling constraints. + Persistence *Persistence `json:"persistence,omitempty" yaml:"persistence,omitempty"` // Autoscaling configures horizontal pod autoscaling. Autoscaling *Autoscaling `json:"autoscaling,omitempty" yaml:"autoscaling,omitempty"` // Resources sets explicit CPU, memory, and storage requests and limits. @@ -101,6 +110,17 @@ type Component struct { Health *Health `json:"health,omitempty" yaml:"health,omitempty"` } +// Persistence configures volume storage for a component. +type Persistence struct { + // Size is the requested volume size (Kubernetes quantity, e.g. "20Gi"). + Size string `json:"size" yaml:"size"` + // MountPath is the path where the volume is mounted in the container. + MountPath string `json:"mountPath" yaml:"mountPath"` + // StorageClass is an optional logical key from the platform environment's + // storageClasses map. When set, it overrides the profile storageClass. + StorageClass string `json:"storageClass,omitempty" yaml:"storageClass,omitempty"` +} + // ListensOnPort reports whether the component has a service role and a // configured port, i.e. whether it should get a container port, an // ingress rule, or a health probe. diff --git a/internal/spec/validate.go b/internal/spec/validate.go index 87d987e..d7b209a 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.2"). +// version should be the version of the schema (e.g., "v1-alpha.3"). // 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.2"). +// version should be the version of the schema (e.g., "v1-alpha.3"). // This is a strict validation: unknown fields are not allowed. func ValidateEnvironments(specObj map[string]any, version string) error { return validateYAMLAgainstSchema( @@ -251,6 +251,53 @@ func ValidateComponentHealth(component Component) error { 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 +// required. +func ValidateComponentPersistence(component Component) error { + kind := component.Kind + if kind == "" { + kind = ComponentKindStateless + } + + if component.Persistence == nil { + return nil + } + + if strings.TrimSpace(component.Persistence.Size) == "" { + return fmt.Errorf("persistence.size is required when persistence is set") + } + if strings.TrimSpace(component.Persistence.MountPath) == "" { + return fmt.Errorf("persistence.mountPath is required when persistence is set") + } + if kind == ComponentKindStateless { + if component.Replicas != nil && *component.Replicas > 1 { + return fmt.Errorf("stateless components with persistence cannot set replicas > 1") + } + if component.Autoscaling != nil && component.Autoscaling.Enabled { + return fmt.Errorf("stateless components with persistence cannot enable autoscaling") + } + } + + return nil +} + +// ValidateComponentReplicas rejects setting both replicas and +// autoscaling.enabled. +func ValidateComponentReplicas(component Component) error { + if component.Replicas == nil { + return nil + } + if *component.Replicas < 1 { + return fmt.Errorf("replicas must be at least 1") + } + if component.Autoscaling != nil && component.Autoscaling.Enabled { + return fmt.Errorf("replicas and autoscaling.enabled are mutually exclusive") + } + return nil +} + // ValidateSpecComponents validates all components in a spec. func ValidateSpecComponents(spec *Spec) error { var errs []error @@ -262,6 +309,12 @@ func ValidateSpecComponents(spec *Spec) error { if err := ValidateComponentAutoscaling(component); err != nil { errs = append(errs, fmt.Errorf("component %s: %w", name, err)) } + if err := ValidateComponentPersistence(component); err != nil { + errs = append(errs, fmt.Errorf("component %s: %w", name, err)) + } + if err := ValidateComponentReplicas(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)) } diff --git a/internal/spec/validate_test.go b/internal/spec/validate_test.go index 09eedc9..acd6642 100644 --- a/internal/spec/validate_test.go +++ b/internal/spec/validate_test.go @@ -287,6 +287,156 @@ func TestValidateComponentAutoscaling(t *testing.T) { } } +// TestValidateComponentPersistence verifies stateful/stateless persistence rules. +func TestValidateComponentPersistence(t *testing.T) { + t.Parallel() + + replicas2 := 2 + tests := []struct { + name string + component Component + wantErr string + }{ + { + name: "stateless without persistence", + component: Component{Kind: ComponentKindStateless}, + }, + { + name: "stateful with persistence", + component: Component{ + Kind: ComponentKindStateful, + Persistence: &Persistence{ + Size: "20Gi", + MountPath: "/data", + }, + }, + }, + { + name: "stateful without persistence (identity only)", + component: Component{Kind: ComponentKindStateful}, + }, + { + name: "stateful missing size", + component: Component{ + Kind: ComponentKindStateful, + Persistence: &Persistence{MountPath: "/data"}, + }, + wantErr: "persistence.size is required when persistence is set", + }, + { + name: "stateful missing mountPath", + component: Component{ + Kind: ComponentKindStateful, + Persistence: &Persistence{Size: "20Gi"}, + }, + wantErr: "persistence.mountPath is required when persistence is set", + }, + { + name: "stateless persistence with replicas > 1", + component: Component{ + Kind: ComponentKindStateless, + Replicas: &replicas2, + Persistence: &Persistence{Size: "1Gi", MountPath: "/data"}, + }, + wantErr: "cannot set replicas > 1", + }, + { + name: "stateless persistence with autoscaling", + component: Component{ + Kind: ComponentKindStateless, + Persistence: &Persistence{ + Size: "1Gi", + MountPath: "/data", + }, + Autoscaling: &Autoscaling{Enabled: true, MinReplicas: 1, MaxReplicas: 3}, + }, + wantErr: "cannot enable autoscaling", + }, + { + name: "stateless persistence allowed with one replica", + component: Component{ + Kind: ComponentKindStateless, + Persistence: &Persistence{ + Size: "1Gi", + MountPath: "/data", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateComponentPersistence(tt.component) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +// TestValidateComponentReplicas verifies replicas vs autoscaling mutual exclusion. +func TestValidateComponentReplicas(t *testing.T) { + t.Parallel() + + replicas1 := 1 + replicas0 := 0 + tests := []struct { + name string + component Component + wantErr string + }{ + { + name: "nil replicas", + component: Component{}, + }, + { + name: "replicas without autoscaling", + component: Component{ + Replicas: &replicas1, + }, + }, + { + name: "replicas with autoscaling enabled", + component: Component{ + Replicas: &replicas1, + Autoscaling: &Autoscaling{Enabled: true, MinReplicas: 1, MaxReplicas: 3}, + }, + wantErr: "mutually exclusive", + }, + { + name: "replicas with autoscaling disabled", + component: Component{ + Replicas: &replicas1, + Autoscaling: &Autoscaling{Enabled: false}, + }, + }, + { + name: "replicas less than 1", + component: Component{ + Replicas: &replicas0, + }, + wantErr: "at least 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateComponentReplicas(tt.component) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + // TestValidateComponentEnvironmentFilter rejects unsupported /* suffixes. func TestValidateComponentEnvironmentFilter(t *testing.T) { t.Parallel() diff --git a/nix/apps/default.nix b/nix/apps/default.nix index b6dec03..b3cc56d 100644 --- a/nix/apps/default.nix +++ b/nix/apps/default.nix @@ -6,10 +6,18 @@ deployah, system, go, + golangci-lint, }: let - quality = import ./quality.nix { inherit pkgs lib go; }; + quality = import ./quality.nix { + inherit + pkgs + lib + go + golangci-lint + ; + }; testing = import ./testing.nix { inherit lib; }; vendor = import ./vendor.nix { inherit pkgs system; }; demo = import ./demo.nix { inherit pkgs lib deployah; }; diff --git a/nix/apps/quality.nix b/nix/apps/quality.nix index 100a7d4..9882305 100644 --- a/nix/apps/quality.nix +++ b/nix/apps/quality.nix @@ -3,29 +3,41 @@ pkgs, lib, go, + golangci-lint, }: +let + withGo = + script: + '' + export GOTOOLCHAIN=local + export GOROOT="${go}/share/go" + export PATH="${go}/bin:$PATH" + '' + + script; +in { - # Skip until golangci-lint supports Go 1.27: - # https://github.com/golangci/golangci-lint/issues/6643 - # nixpkgs' binary is built with Go 1.26 and rejects go.mod 1.27. fmt = lib.mkApp { name = "fmt"; - description = "Format Go files (skipped until Go 1.27 support)"; - script = '' - echo "skipping golangci-lint fmt: Go 1.27 not supported yet" - echo "see https://github.com/golangci/golangci-lint/issues/6643" - exit 0 + description = "Format Go files (gofumpt + gci via golangci-lint)"; + runtimeInputs = [ + go + golangci-lint + ]; + script = withGo '' + exec golangci-lint fmt ./... ''; }; lint = lib.mkApp { name = "lint"; - description = "Run golangci-lint (skipped until Go 1.27 support)"; - script = '' - echo "skipping golangci-lint: Go 1.27 not supported yet" - echo "see https://github.com/golangci/golangci-lint/issues/6643" - exit 0 + description = "Run golangci-lint"; + runtimeInputs = [ + go + golangci-lint + ]; + script = withGo '' + exec golangci-lint run ./... ''; }; @@ -40,16 +52,18 @@ tidy = lib.mkApp { name = "tidy"; description = "Run go mod tidy for the module"; - script = '' - exec ${go}/bin/go mod tidy + runtimeInputs = [ go ]; + script = withGo '' + exec go mod tidy ''; }; gen-docs = lib.mkApp { name = "gen-docs"; description = "Generate the CLI reference under docs/cli from the command tree"; - script = '' - exec ${go}/bin/go run ./internal/tools/gendocs + runtimeInputs = [ go ]; + script = withGo '' + exec go run ./internal/tools/gendocs ''; }; } diff --git a/nix/checks.nix b/nix/checks.nix index 915cb1b..9401263 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -5,6 +5,7 @@ git-hooks, system, src, + golangci-lint, }: let @@ -39,11 +40,10 @@ git-hooks.lib.${system}.run { entry = "${go}/bin/gofmt -l -w"; files = "\\.go$"; }; - # Disabled while go.mod is 1.27: nixpkgs golangci-lint is built with - # Go 1.26 and fails config load. Re-enable when - # https://github.com/golangci/golangci-lint/issues/6643 lands. + # Flake-pinned golangci-lint (Go 1.27 build from upstream PR #6642). golangci-lint = { - enable = false; + enable = true; + package = golangci-lint; extraPackages = [ go ]; }; markdownlint = { diff --git a/nix/devshell.nix b/nix/devshell.nix index d50b2f6..4676201 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -3,14 +3,18 @@ pkgs, go, pre-commit-check, + golangci-lint, }: let - devTools = with pkgs; [ + # Prefer the flake-pinned golangci-lint over a user GOPATH install. + devTools = [ go + golangci-lint + ] + ++ (with pkgs; [ gopls gotools - golangci-lint markdownlint-cli delve git @@ -25,7 +29,7 @@ let bat xclip xvfb-run - ]; + ]); in pkgs.mkShell { name = "deployah"; @@ -38,7 +42,9 @@ pkgs.mkShell { ${pre-commit-check.shellHook} export GOROOT="${go}/share/go" export GOPATH="''${GOPATH:-$HOME/go}" - export PATH="${go}/bin:$GOPATH/bin:$PATH" + # Flake Go + golangci-lint first; GOPATH/bin last so local installs do not + # shadow the pinned toolchain (Go 1.27-capable golangci-lint). + export PATH="${golangci-lint}/bin:${go}/bin:$PATH:$GOPATH/bin" echo "Deployah dev shell — $(go version)" ''; } diff --git a/nix/golangci-lint.nix b/nix/golangci-lint.nix new file mode 100644 index 0000000..6e4849f --- /dev/null +++ b/nix/golangci-lint.nix @@ -0,0 +1,64 @@ +# golangci-lint built with the project Go toolchain. +# +# nixpkgs pins buildGo126Module, so the stock binary rejects go.mod 1.27. +# Pin a commit from the draft Go 1.27 PR until a release lands: +# https://github.com/golangci/golangci-lint/pull/6642 +# https://github.com/golangci/golangci-lint/issues/6643 +# When that ships, drop this file and use pkgs.golangci-lint (or bump rev/hash +# here). A force-push of the PR branch will break the src hash; re-pin then. +{ + buildGoModule, + fetchFromGitHub, + installShellFiles, + lib, + stdenv, + buildPackages, +}: + +buildGoModule (finalAttrs: { + pname = "golangci-lint"; + version = "2.12.2-go1.27-pr6642"; + + src = fetchFromGitHub { + owner = "golangci"; + repo = "golangci-lint"; + rev = "c4815f06852754c8daa088b684d71fd88589b175"; + hash = "sha256-oxhyDh+vvej2hjVOeLimzukE3PXmR72Zo+4Wv2UDDqo="; + }; + + vendorHash = "sha256-NNnrRtdH950rEODVPaGkMbVZ1pSl9XDFNkoSKBTrMfQ="; + + subPackages = [ "cmd/golangci-lint" ]; + + nativeBuildInputs = [ installShellFiles ]; + + ldflags = [ + "-s" + "-w" + "-X main.version=${finalAttrs.version}" + "-X main.commit=${finalAttrs.src.rev}" + "-X main.date=1970-01-01T00:00:00Z" + ]; + + postInstall = + let + golangcilintBin = + if stdenv.buildPlatform.canExecute stdenv.hostPlatform then + "$out" + else + lib.getBin buildPackages.golangci-lint; + in + '' + installShellCompletion --cmd golangci-lint \ + --bash <(${golangcilintBin}/bin/golangci-lint completion bash) \ + --fish <(${golangcilintBin}/bin/golangci-lint completion fish) \ + --zsh <(${golangcilintBin}/bin/golangci-lint completion zsh) + ''; + + meta = { + description = "Fast linters Runner for Go (Go 1.27-capable build)"; + homepage = "https://golangci-lint.run/"; + mainProgram = "golangci-lint"; + license = lib.licenses.gpl3Plus; + }; +}) diff --git a/scenarios/autoscaling-hpa/deployah.yaml b/scenarios/autoscaling-hpa/deployah.yaml index c210ea9..c44f273 100644 --- a/scenarios/autoscaling-hpa/deployah.yaml +++ b/scenarios/autoscaling-hpa/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: autoscaling-hpa components: api: diff --git a/scenarios/basic-web-service/deployah.yaml b/scenarios/basic-web-service/deployah.yaml index c59a9b1..8454ced 100644 --- a/scenarios/basic-web-service/deployah.yaml +++ b/scenarios/basic-web-service/deployah.yaml @@ -1,4 +1,5 @@ -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: basic-web-service components: web: diff --git a/scenarios/command-args-resources/deployah.yaml b/scenarios/command-args-resources/deployah.yaml index 2e2baf1..3ef4198 100644 --- a/scenarios/command-args-resources/deployah.yaml +++ b/scenarios/command-args-resources/deployah.yaml @@ -1,4 +1,5 @@ -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: command-args-resources components: processor: diff --git a/scenarios/env-substitution/deployah.yaml b/scenarios/env-substitution/deployah.yaml index 7b1a183..c55b73a 100644 --- a/scenarios/env-substitution/deployah.yaml +++ b/scenarios/env-substitution/deployah.yaml @@ -1,4 +1,5 @@ -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: env-substitution components: api: diff --git a/scenarios/error-apex-subdomain/deployah.yaml b/scenarios/error-apex-subdomain/deployah.yaml index a93de08..685d185 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-apex-subdomain components: api: diff --git a/scenarios/error-bad-apiversion/deployah.yaml b/scenarios/error-bad-apiversion/deployah.yaml index 11a136c..693d2e8 100644 --- a/scenarios/error-bad-apiversion/deployah.yaml +++ b/scenarios/error-bad-apiversion/deployah.yaml @@ -1,4 +1,4 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json apiVersion: v1-alpha.99 project: error-bad-apiversion components: diff --git a/scenarios/error-empty-resources/deployah.yaml b/scenarios/error-empty-resources/deployah.yaml index d618d42..bce31bb 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-empty-resources components: api: diff --git a/scenarios/error-health-durations/deployah.yaml b/scenarios/error-health-durations/deployah.yaml index 15c7f87..dc897b5 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 fceea67..c2a7626 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-health-on-worker components: worker: diff --git a/scenarios/error-health-path/deployah.yaml b/scenarios/error-health-path/deployah.yaml index f93f21a..e51184c 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-health-path components: api: diff --git a/scenarios/error-metric-type/deployah.yaml b/scenarios/error-metric-type/deployah.yaml index edb3e32..4139be0 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-metric-type components: api: diff --git a/scenarios/error-multiple-issues/deployah.yaml b/scenarios/error-multiple-issues/deployah.yaml index 64920f9..0c26aab 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 9c70a87..3267c42 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 profiles: capped: maxResources: diff --git a/scenarios/error-profile-ceiling/deployah.yaml b/scenarios/error-profile-ceiling/deployah.yaml index 282e490..94ed662 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 75a236d..16797d9 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 profiles: public-only: allowedDomains: [public] diff --git a/scenarios/error-profile-domain/deployah.yaml b/scenarios/error-profile-domain/deployah.yaml index f74f047..3d3956a 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 f9fc2e7..bee7940 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 profiles: public-web: podLabels: diff --git a/scenarios/error-profile-unknown/deployah.yaml b/scenarios/error-profile-unknown/deployah.yaml index 6b5e4aa..427502d 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: error-profile-unknown components: web: diff --git a/scenarios/error-replicas-autoscaling/deployah.yaml b/scenarios/error-replicas-autoscaling/deployah.yaml new file mode 100644 index 0000000..44f4287 --- /dev/null +++ b/scenarios/error-replicas-autoscaling/deployah.yaml @@ -0,0 +1,18 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: error-replicas-autoscaling +components: + web: + image: nginx:latest + port: 8080 + replicas: 2 + environments: [dev] + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: cpu + target: 70 +environments: + dev: {} diff --git a/scenarios/error-replicas-autoscaling/error-config.yaml b/scenarios/error-replicas-autoscaling/error-config.yaml new file mode 100644 index 0000000..d653d79 --- /dev/null +++ b/scenarios/error-replicas-autoscaling/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "mutually exclusive" diff --git a/scenarios/error-stateless-persistence-replicas/deployah.yaml b/scenarios/error-stateless-persistence-replicas/deployah.yaml new file mode 100644 index 0000000..43b8d74 --- /dev/null +++ b/scenarios/error-stateless-persistence-replicas/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: error-stateless-persistence-replicas +components: + web: + kind: stateless + image: nginx:latest + port: 8080 + replicas: 2 + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/scenarios/error-stateless-persistence-replicas/error-config.yaml b/scenarios/error-stateless-persistence-replicas/error-config.yaml new file mode 100644 index 0000000..21dd361 --- /dev/null +++ b/scenarios/error-stateless-persistence-replicas/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "cannot set replicas > 1" diff --git a/scenarios/expose-apex-certmanager/deployah.platform.yaml b/scenarios/expose-apex-certmanager/deployah.platform.yaml index 6080ef8..2fdb87b 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 environments: production: domains: diff --git a/scenarios/expose-apex-certmanager/deployah.yaml b/scenarios/expose-apex-certmanager/deployah.yaml index 3f19898..04650eb 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: expose-apex-certmanager components: api: diff --git a/scenarios/expose-secretname/deployah.platform.yaml b/scenarios/expose-secretname/deployah.platform.yaml index 82e06a5..23744ba 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 environments: production: domains: diff --git a/scenarios/expose-secretname/deployah.yaml b/scenarios/expose-secretname/deployah.yaml index b937f3a..0ad62c3 100644 --- a/scenarios/expose-secretname/deployah.yaml +++ b/scenarios/expose-secretname/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: expose-secretname components: api: diff --git a/scenarios/expose-selfsigned/deployah.platform.yaml b/scenarios/expose-selfsigned/deployah.platform.yaml index b71ad15..bac96a4 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 environments: production: domains: diff --git a/scenarios/expose-selfsigned/deployah.yaml b/scenarios/expose-selfsigned/deployah.yaml index 6d35049..2512418 100644 --- a/scenarios/expose-selfsigned/deployah.yaml +++ b/scenarios/expose-selfsigned/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: expose-selfsigned components: web: diff --git a/scenarios/extras-env-and-crds/deployah.yaml b/scenarios/extras-env-and-crds/deployah.yaml index b818ff4..e440ed6 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: extras-env-and-crds components: web: diff --git a/scenarios/extras-manifest/deployah.yaml b/scenarios/extras-manifest/deployah.yaml index a63c367..529b315 100644 --- a/scenarios/extras-manifest/deployah.yaml +++ b/scenarios/extras-manifest/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: extras-manifest components: web: diff --git a/scenarios/health-check-http/deployah.yaml b/scenarios/health-check-http/deployah.yaml index dd405af..c6014fc 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: health-check-http components: api: diff --git a/scenarios/health-disabled/deployah.yaml b/scenarios/health-disabled/deployah.yaml index 25046ff..f1dc4c7 100644 --- a/scenarios/health-disabled/deployah.yaml +++ b/scenarios/health-disabled/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: health-disabled components: api: diff --git a/scenarios/invalid-manifest/deployah.yaml b/scenarios/invalid-manifest/deployah.yaml index ad12da2..2c10430 100644 --- a/scenarios/invalid-manifest/deployah.yaml +++ b/scenarios/invalid-manifest/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: invalid-manifest components: web: diff --git a/scenarios/multi-component/deployah.yaml b/scenarios/multi-component/deployah.yaml index 98d0000..5197cf9 100644 --- a/scenarios/multi-component/deployah.yaml +++ b/scenarios/multi-component/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: multi-component components: web: diff --git a/scenarios/multi-env/deployah.yaml b/scenarios/multi-env/deployah.yaml index 98594a9..a631fd6 100644 --- a/scenarios/multi-env/deployah.yaml +++ b/scenarios/multi-env/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: multi-env components: web: diff --git a/scenarios/plan-after-failed-upgrade/deployah.yaml b/scenarios/plan-after-failed-upgrade/deployah.yaml index 870a5f6..d721584 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-after-failed-upgrade components: web: diff --git a/scenarios/plan-command-change/before.yaml b/scenarios/plan-command-change/before.yaml index ee10a77..3a6aa09 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-command-change components: api: diff --git a/scenarios/plan-command-change/deployah.yaml b/scenarios/plan-command-change/deployah.yaml index 8753fb1..9c8200f 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 0bc695f..6c690c2 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-extras-fresh-install components: web: diff --git a/scenarios/plan-fresh-install/deployah.yaml b/scenarios/plan-fresh-install/deployah.yaml index 265685b..196eeec 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-fresh-install components: web: diff --git a/scenarios/plan-hpa-change/before.yaml b/scenarios/plan-hpa-change/before.yaml index 05b1f08..5949a16 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-hpa-change components: api: diff --git a/scenarios/plan-hpa-change/deployah.yaml b/scenarios/plan-hpa-change/deployah.yaml index 5d0b1fe..f9b2e87 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-hpa-change components: api: diff --git a/scenarios/plan-image-bump/before.yaml b/scenarios/plan-image-bump/before.yaml index 56d6de6..b92a7c8 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-image-bump components: web: diff --git a/scenarios/plan-image-bump/deployah.yaml b/scenarios/plan-image-bump/deployah.yaml index 68b26ff..cab2eb9 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-image-bump components: web: diff --git a/scenarios/plan-ingress-added/before.yaml b/scenarios/plan-ingress-added/before.yaml index ba86a2f..d72501a 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 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 82e06a5..23744ba 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 environments: production: domains: diff --git a/scenarios/plan-ingress-added/deployah.yaml b/scenarios/plan-ingress-added/deployah.yaml index e2c8b65..352dc5b 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-ingress-added components: api: diff --git a/scenarios/plan-mixed-changes/before.yaml b/scenarios/plan-mixed-changes/before.yaml index f8955c1..17fff2d 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-mixed-changes components: web: diff --git a/scenarios/plan-mixed-changes/deployah.yaml b/scenarios/plan-mixed-changes/deployah.yaml index 5c3fe23..2f87df5 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-mixed-changes components: web: diff --git a/scenarios/plan-no-changes/before.yaml b/scenarios/plan-no-changes/before.yaml index 9bfa515..c82d1a4 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-no-changes components: web: diff --git a/scenarios/plan-no-changes/deployah.yaml b/scenarios/plan-no-changes/deployah.yaml index 9bfa515..c82d1a4 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-no-changes components: web: diff --git a/scenarios/plan-resource-added/before.yaml b/scenarios/plan-resource-added/before.yaml index 3d73173..165eaa7 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-resource-added components: web: diff --git a/scenarios/plan-resource-added/deployah.yaml b/scenarios/plan-resource-added/deployah.yaml index 1582db1..851634a 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-resource-added components: web: diff --git a/scenarios/plan-resource-removed/before.yaml b/scenarios/plan-resource-removed/before.yaml index 95494f1..59ad4e0 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-resource-removed components: web: diff --git a/scenarios/plan-resource-removed/deployah.yaml b/scenarios/plan-resource-removed/deployah.yaml index 17bd433..8dc3cf5 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.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: plan-resource-removed components: web: diff --git a/scenarios/profile-basic/deployah.platform.yaml b/scenarios/profile-basic/deployah.platform.yaml index b663221..977b905 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 profiles: public-web: nodeSelector: diff --git a/scenarios/profile-basic/deployah.yaml b/scenarios/profile-basic/deployah.yaml index be5d8d2..6b8c4b4 100644 --- a/scenarios/profile-basic/deployah.yaml +++ b/scenarios/profile-basic/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: profile-basic components: web: diff --git a/scenarios/profile-merge/deployah.platform.yaml b/scenarios/profile-merge/deployah.platform.yaml index 5f17aea..e5f1485 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.1/platform.json -apiVersion: platform/v1-alpha.1 +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 profiles: default: nodeSelector: diff --git a/scenarios/profile-merge/deployah.yaml b/scenarios/profile-merge/deployah.yaml index ac35957..cceb9f7 100644 --- a/scenarios/profile-merge/deployah.yaml +++ b/scenarios/profile-merge/deployah.yaml @@ -1,5 +1,5 @@ -# $schema: ../../internal/spec/schema/v1-alpha.2/manifest.json -apiVersion: v1-alpha.2 +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 project: profile-merge components: api: diff --git a/scenarios/stateful-basic/deployah.yaml b/scenarios/stateful-basic/deployah.yaml new file mode 100644 index 0000000..5e66a34 --- /dev/null +++ b/scenarios/stateful-basic/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: stateful-basic +components: + db: + kind: stateful + image: postgres:16 + port: 5432 + resourcePreset: small + environments: [dev] + persistence: + size: 20Gi + mountPath: /var/lib/postgresql/data +environments: + dev: {} diff --git a/scenarios/stateful-basic/expected/service-stateful-basic-dev-db-headless.yaml b/scenarios/stateful-basic/expected/service-stateful-basic-dev-db-headless.yaml new file mode 100644 index 0000000..07075a4 --- /dev/null +++ b/scenarios/stateful-basic/expected/service-stateful-basic-dev-db-headless.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: dev + deployah.dev/project: stateful-basic + helm.sh/chart: db-0.1.0 + name: stateful-basic-dev-db-headless + namespace: default +spec: + clusterIP: None + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + publishNotReadyAddresses: true + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + type: ClusterIP diff --git a/scenarios/stateful-basic/expected/service-stateful-basic-dev-db.yaml b/scenarios/stateful-basic/expected/service-stateful-basic-dev-db.yaml new file mode 100644 index 0000000..71b2933 --- /dev/null +++ b/scenarios/stateful-basic/expected/service-stateful-basic-dev-db.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: dev + deployah.dev/project: stateful-basic + helm.sh/chart: db-0.1.0 + name: stateful-basic-dev-db + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml new file mode 100644 index 0000000..0559307 --- /dev/null +++ b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml @@ -0,0 +1,109 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + deployah.dev/project: stateful-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: dev + deployah.dev/project: stateful-basic + helm.sh/chart: db-0.1.0 + name: stateful-basic-dev-db + namespace: default +spec: + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + podManagementPolicy: OrderedReady + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + serviceName: stateful-basic-dev-db-headless + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: dev + deployah.dev/project: stateful-basic + helm.sh/chart: db-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/postgres:16 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: db + ports: + - containerPort: 5432 + 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 + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: data + restartPolicy: Always + serviceAccountName: default + updateStrategy: + type: RollingUpdate + volumeClaimTemplates: + - metadata: + annotations: + deployah.dev/project: stateful-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: dev + deployah.dev/project: stateful-basic + helm.sh/chart: db-0.1.0 + name: data + spec: + accessModes: + - ReadWriteOncePod + resources: + requests: + storage: 20Gi diff --git a/scenarios/stateful-hpa/deployah.yaml b/scenarios/stateful-hpa/deployah.yaml new file mode 100644 index 0000000..e9020c8 --- /dev/null +++ b/scenarios/stateful-hpa/deployah.yaml @@ -0,0 +1,22 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: stateful-hpa +components: + cache: + kind: stateful + image: redis:7 + port: 6379 + resourcePreset: small + environments: [dev] + persistence: + size: 5Gi + mountPath: /data + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 3 + metrics: + - type: cpu + target: 70 +environments: + dev: {} diff --git a/scenarios/stateful-hpa/expected/horizontalpodautoscaler-stateful-hpa-dev-cache.yaml b/scenarios/stateful-hpa/expected/horizontalpodautoscaler-stateful-hpa-dev-cache.yaml new file mode 100644 index 0000000..d679de7 --- /dev/null +++ b/scenarios/stateful-hpa/expected/horizontalpodautoscaler-stateful-hpa-dev-cache.yaml @@ -0,0 +1,36 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + annotations: + deployah.dev/project: stateful-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + name: stateful-hpa-dev-cache + namespace: default +spec: + maxReplicas: 3 + metrics: + - resource: + name: cpu + target: + averageUtilization: 70 + type: Utilization + type: Resource + - resource: + name: memory + target: + averageUtilization: 80 + type: Utilization + type: Resource + minReplicas: 1 + scaleTargetRef: + apiVersion: apps/v1 + kind: StatefulSet + name: stateful-hpa-dev-cache diff --git a/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache-headless.yaml b/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache-headless.yaml new file mode 100644 index 0000000..f410f77 --- /dev/null +++ b/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache-headless.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + name: stateful-hpa-dev-cache-headless + namespace: default +spec: + clusterIP: None + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + publishNotReadyAddresses: true + selector: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/name: cache + type: ClusterIP diff --git a/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache.yaml b/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache.yaml new file mode 100644 index 0000000..2adafde --- /dev/null +++ b/scenarios/stateful-hpa/expected/service-stateful-hpa-dev-cache.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + name: stateful-hpa-dev-cache + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/name: cache + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml b/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml new file mode 100644 index 0000000..1eef0b6 --- /dev/null +++ b/scenarios/stateful-hpa/expected/statefulset-stateful-hpa-dev-cache.yaml @@ -0,0 +1,108 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + deployah.dev/project: stateful-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + name: stateful-hpa-dev-cache + namespace: default +spec: + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + podManagementPolicy: OrderedReady + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/name: cache + serviceName: stateful-hpa-dev-cache-headless + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/name: cache + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/redis:7 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: cache + ports: + - containerPort: 6379 + 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 + volumeMounts: + - mountPath: /data + name: data + restartPolicy: Always + serviceAccountName: default + updateStrategy: + type: RollingUpdate + volumeClaimTemplates: + - metadata: + annotations: + deployah.dev/project: stateful-hpa + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-hpa-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cache + deployah.dev/component: cache + deployah.dev/environment: dev + deployah.dev/project: stateful-hpa + helm.sh/chart: cache-0.1.0 + name: data + spec: + accessModes: + - ReadWriteOncePod + resources: + requests: + storage: 5Gi diff --git a/scenarios/stateful-identity/deployah.yaml b/scenarios/stateful-identity/deployah.yaml new file mode 100644 index 0000000..37df046 --- /dev/null +++ b/scenarios/stateful-identity/deployah.yaml @@ -0,0 +1,13 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: stateful-identity +components: + peer: + kind: stateful + image: redis:7-alpine + port: 6379 + resourcePreset: nano + environments: [dev] + replicas: 2 +environments: + dev: {} diff --git a/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer-headless.yaml b/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer-headless.yaml new file mode 100644 index 0000000..982cec1 --- /dev/null +++ b/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer-headless.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-identity + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: peer + deployah.dev/component: peer + deployah.dev/environment: dev + deployah.dev/project: stateful-identity + helm.sh/chart: peer-0.1.0 + name: stateful-identity-dev-peer-headless + namespace: default +spec: + clusterIP: None + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + publishNotReadyAddresses: true + selector: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/name: peer + type: ClusterIP diff --git a/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer.yaml b/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer.yaml new file mode 100644 index 0000000..cf996f2 --- /dev/null +++ b/scenarios/stateful-identity/expected/service-stateful-identity-dev-peer.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-identity + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: peer + deployah.dev/component: peer + deployah.dev/environment: dev + deployah.dev/project: stateful-identity + helm.sh/chart: peer-0.1.0 + name: stateful-identity-dev-peer + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/name: peer + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml b/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml new file mode 100644 index 0000000..98fe88f --- /dev/null +++ b/scenarios/stateful-identity/expected/statefulset-stateful-identity-dev-peer.yaml @@ -0,0 +1,83 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + deployah.dev/project: stateful-identity + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: peer + deployah.dev/component: peer + deployah.dev/environment: dev + deployah.dev/project: stateful-identity + helm.sh/chart: peer-0.1.0 + name: stateful-identity-dev-peer + namespace: default +spec: + podManagementPolicy: OrderedReady + replicas: 2 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/name: peer + serviceName: stateful-identity-dev-peer-headless + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: peer + deployah.dev/component: peer + deployah.dev/environment: dev + deployah.dev/project: stateful-identity + helm.sh/chart: peer-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: stateful-identity-dev + app.kubernetes.io/name: peer + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/redis:7-alpine + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: peer + ports: + - containerPort: 6379 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 100m + ephemeral-storage: 50Mi + memory: 128Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + updateStrategy: + type: RollingUpdate diff --git a/scenarios/stateful-profile-retention/deployah.platform.yaml b/scenarios/stateful-profile-retention/deployah.platform.yaml new file mode 100644 index 0000000..a2d61f7 --- /dev/null +++ b/scenarios/stateful-profile-retention/deployah.platform.yaml @@ -0,0 +1,14 @@ +# $schema: ../../internal/spec/schema/platform/v1-alpha.2/platform.json +apiVersion: platform/v1-alpha.2 +profiles: + stateful-store: + storageClass: fast + pvcRetentionPolicy: + whenDeleted: Delete + whenScaled: Retain +environments: + production: + context: prod-eks + storageClasses: + fast: + className: fast-ssd diff --git a/scenarios/stateful-profile-retention/deployah.yaml b/scenarios/stateful-profile-retention/deployah.yaml new file mode 100644 index 0000000..3994388 --- /dev/null +++ b/scenarios/stateful-profile-retention/deployah.yaml @@ -0,0 +1,16 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: stateful-profile-retention +components: + db: + kind: stateful + image: postgres:16 + port: 5432 + resourcePreset: small + environments: [production] + profiles: [stateful-store] + persistence: + size: 20Gi + mountPath: /var/lib/postgresql/data +environments: + production: {} diff --git a/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db-headless.yaml b/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db-headless.yaml new file mode 100644 index 0000000..c00afda --- /dev/null +++ b/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db-headless.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-profile-retention + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: production + deployah.dev/project: stateful-profile-retention + helm.sh/chart: db-0.1.0 + name: stateful-profile-retention-production-db-headless + namespace: default +spec: + clusterIP: None + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + publishNotReadyAddresses: true + selector: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/name: db + type: ClusterIP diff --git a/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db.yaml b/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db.yaml new file mode 100644 index 0000000..fb93905 --- /dev/null +++ b/scenarios/stateful-profile-retention/expected/service-stateful-profile-retention-production-db.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateful-profile-retention + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: production + deployah.dev/project: stateful-profile-retention + helm.sh/chart: db-0.1.0 + name: stateful-profile-retention-production-db + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/name: db + sessionAffinity: None + type: ClusterIP 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 new file mode 100644 index 0000000..0ddbdb0 --- /dev/null +++ b/scenarios/stateful-profile-retention/expected/statefulset-stateful-profile-retention-production-db.yaml @@ -0,0 +1,110 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: + deployah.dev/project: stateful-profile-retention + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: production + deployah.dev/project: stateful-profile-retention + helm.sh/chart: db-0.1.0 + name: stateful-profile-retention-production-db + namespace: default +spec: + persistentVolumeClaimRetentionPolicy: + whenDeleted: Delete + whenScaled: Retain + podManagementPolicy: OrderedReady + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/name: db + serviceName: stateful-profile-retention-production-db-headless + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: production + deployah.dev/project: stateful-profile-retention + helm.sh/chart: db-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/name: db + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/postgres:16 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: db + ports: + - containerPort: 5432 + 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 + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: data + restartPolicy: Always + serviceAccountName: default + updateStrategy: + type: RollingUpdate + volumeClaimTemplates: + - metadata: + annotations: + deployah.dev/project: stateful-profile-retention + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateful-profile-retention-production + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: db + deployah.dev/component: db + deployah.dev/environment: production + deployah.dev/project: stateful-profile-retention + helm.sh/chart: db-0.1.0 + name: data + spec: + accessModes: + - ReadWriteOncePod + resources: + requests: + storage: 20Gi + storageClassName: fast-ssd diff --git a/scenarios/stateless-persistence/deployah.yaml b/scenarios/stateless-persistence/deployah.yaml new file mode 100644 index 0000000..a460002 --- /dev/null +++ b/scenarios/stateless-persistence/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.3/manifest.json +apiVersion: v1-alpha.3 +project: stateless-persistence +components: + web: + kind: stateless + image: nginx:latest + port: 8080 + resourcePreset: small + environments: [dev] + persistence: + size: 1Gi + mountPath: /data +environments: + dev: {} diff --git a/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml b/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml new file mode 100644 index 0000000..7196083 --- /dev/null +++ b/scenarios/stateless-persistence/expected/deployment-stateless-persistence-dev-web.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: stateless-persistence + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: web + deployah.dev/component: web + deployah.dev/environment: dev + deployah.dev/project: stateless-persistence + helm.sh/chart: web-0.1.0 + name: stateless-persistence-dev-web + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/name: web + strategy: + type: Recreate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: web + deployah.dev/component: web + deployah.dev/environment: dev + deployah.dev/project: stateless-persistence + helm.sh/chart: web-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/name: web + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/nginx:latest + imagePullPolicy: Always + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: web + 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 + volumeMounts: + - mountPath: /data + name: data + restartPolicy: Always + serviceAccountName: default + volumes: + - name: data + persistentVolumeClaim: + claimName: stateless-persistence-dev-web diff --git a/scenarios/stateless-persistence/expected/persistentvolumeclaim-stateless-persistence-dev-web.yaml b/scenarios/stateless-persistence/expected/persistentvolumeclaim-stateless-persistence-dev-web.yaml new file mode 100644 index 0000000..1f4961e --- /dev/null +++ b/scenarios/stateless-persistence/expected/persistentvolumeclaim-stateless-persistence-dev-web.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + annotations: + deployah.dev/project: stateless-persistence + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: web + deployah.dev/component: web + deployah.dev/environment: dev + deployah.dev/project: stateless-persistence + helm.sh/chart: web-0.1.0 + name: stateless-persistence-dev-web + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/scenarios/stateless-persistence/expected/service-stateless-persistence-dev-web.yaml b/scenarios/stateless-persistence/expected/service-stateless-persistence-dev-web.yaml new file mode 100644 index 0000000..24a17a7 --- /dev/null +++ b/scenarios/stateless-persistence/expected/service-stateless-persistence-dev-web.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: stateless-persistence + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: web + deployah.dev/component: web + deployah.dev/environment: dev + deployah.dev/project: stateless-persistence + helm.sh/chart: web-0.1.0 + name: stateless-persistence-dev-web + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: stateless-persistence-dev + app.kubernetes.io/name: web + sessionAffinity: None + type: ClusterIP