Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/DISCUSSION_TEMPLATE/q-a.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 9 additions & 7 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).
Expand Down
220 changes: 187 additions & 33 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/cli/deployah_deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ deployah deploy <environment> [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
```

Expand Down
7 changes: 4 additions & 3 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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★ |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/nginx/deployah.platform.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
apiVersion: platform/v1-alpha.1
apiVersion: platform/v1-alpha.2
environments:
local:
context: kind-deployah
Expand Down
2 changes: 1 addition & 1 deletion examples/nginx/deployah.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
apiVersion: v1-alpha.2
apiVersion: v1-alpha.3
project: nginx
components:
web:
Expand Down
21 changes: 20 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -44,6 +55,7 @@
go
git-hooks
system
golangci-lint
;
src = ./.;
};
Expand All @@ -54,6 +66,7 @@
packages = {
default = deployah;
deployah = deployah;
golangci-lint = golangci-lint;
};

checks = {
Expand All @@ -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
;
};
}
);
Expand Down
2 changes: 1 addition & 1 deletion internal/action/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
44 changes: 41 additions & 3 deletions internal/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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")),
Expand Down Expand Up @@ -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)
Expand All @@ -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?"
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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 != "" {
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/deploy/deploy_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading