Declarative release orchestrator on top of nelm (the werf team's Helm replacement). Spiritually a sibling of helmwave, but with a new schema and a new engine.
nelmwave manages many releases from a single declarative nelmwave.yml
manifest: it renders the manifest through gomplate, resolves values and
companion files from arbitrary datasources, builds a dependency graph between
releases, and applies everything through nelm β in parallel, respecting order.
Status: MVP.
build,up,downanddiffare implemented, along with datasource resolution, the dependency DAG, label selection, and chart resolution against helm repositories and OCI registries.
# macOS and Linux
brew install helmwave/tap/nelmwave
# any platform: grab an archive from the releases page
# https://github.com/helmwave/nelmwave/releases/latest
# container β also :latest-scratch (no shell) and :latest-debug (bash, jq, kubectl)
docker run --rm -v "$PWD:/workspace" ghcr.io/helmwave/nelmwave:latest build
# from source
go install github.com/helmwave/nelmwave/cmd/nelmwave@latestgo build ./cmd/nelmwaveRequires Go 1.26+. Use make build when the version stamped into the binary
matters β plain go build leaves it at its dev fallback.
See CONTRIBUTING.md for the development and release flow (trunk-based, changie fragments, goreleaser).
cd examples/quickstart
ENV=stg nelmwave build
cat .nelmwave/planfile.ymlbuild needs no cluster and downloads no charts β it renders, validates and
resolves everything locally into .nelmwave/. up, down and diff need a
Kubernetes cluster.
demo/ holds an asciicast of the full loop against a throwaway
cluster β asciinema play demo/nelmwave.cast.
examples/ has a runnable project per feature area β dependencies,
chart sources, namespaces, resource policies, release storage, sops-encrypted
values, datasource cross-references, and running in CI. make examples builds
them all.
The manifest is nelmwave.yml.tpl (or a plain nelmwave.yml), and looks like
this:
project: my-platform
repositories:
bitnami: https://charts.bitnami.com/bitnami
private:
url: oci://registry.example.com
username: [[ .Env.REGISTRY_USER ]]
password: [[ .Env.REGISTRY_PASS ]]
Release: # defaults applied to every release
labels:
common: true
releases:
postgres@data:
labels: { app: postgres, tier: db }
chart:
name: bitnami/postgresql
version: 15.x
values:
- values/pg.yml.tpl
api@app:
labels: { app: api, tier: backend }
needs:
releases:
postgres@data: {}
chart:
name: oci://registry.example.com/charts/api
version: 1.4.2
values:
- src: values/api.yml.tpl
sets:
replicaCount: 3
image.tag: [[ getenv "API_TAG" "1.4.2" ]]
stores:
- { src: extra/netpol.yml, name: netpol.yml }A .tpl manifest is rendered by gomplate v5 using
[[ ]] action delimiters (so Helm's own {{ }} stays untouched). The render
context exposes .Env / getenv and gomplate's standard function namespaces
(strings, datasource, conv, ...). A plain nelmwave.yml is loaded
verbatim, without rendering.
Only build renders. up, down and diff read the plan it wrote, so the
manifest cannot change between review and apply.
A free-form name for the whole manifest. Carried into the plan; not sent to the cluster.
A map keyed by alias (helm repos) or host (OCI registries). The URL scheme says
what it is and how to reach it: https:// (or http://) is a classic helm
repository, oci:// an OCI registry over TLS, oci+http:// one without. A
value is either a bare URL string or an object:
| Field | Meaning |
|---|---|
url |
Repository index URL or OCI registry URL. Required. |
username, password |
Basic-auth credentials. |
insecureSkipTLSVerify |
Disable TLS verification for this repo. |
passCredentials |
Forward credentials to all domains, not just the repo host. |
caFile |
Path to a CA bundle for this repo. |
certFile, keyFile |
Client TLS certificate and key (mTLS to the repository). |
| β | Plain-HTTP OCI has no field: write the registry as oci+http:// (see below). |
skipUpdate |
Don't refresh the chart's declared dependencies: before pulling them. No effect on charts without subcharts. |
requestTimeout |
Bound a single request to the repository, e.g. 30s. The release timeout still applies on top. |
provenanceStrategy |
Verify the chart's PGP signature: never (default), if-possible, always, later. |
provenanceKeyring |
Keyring with the public keys to check the signature against. Defaults to helm's ~/.gnupg/pubring.gpg. |
There is no repositories.yaml step: a helm-repo chart is fetched helm
--repo style (chart name plus repo URL), and OCI credentials are handed to nelm
through a generated, temporary config.json.
Registries without TLS. oci:// names an artifact but not a transport, so
the client defaults to HTTPS. Write oci+http:// to say otherwise:
repositories:
dev: oci+http://registry:5000 # local registry, no TLSnelm accepts only oci://, so nelmwave rewrites the reference and passes the
choice along separately β you never write the scheme twice. The chart may be
addressed either way: oci://registry:5000/api resolves against the
oci+http:// registry declared above, because the scheme is transport, not
identity. Spelling the chart itself oci+http://β¦ works too, and is the only
option for a registry that is not declared at all.
This is unrelated to insecureSkipTLSVerify, which keeps TLS and only stops
verifying the certificate.
Chart signatures. A signed chart is published with a .prov file next to the
archive, holding a hash of it plus a PGP signature. provenanceStrategy: always
refuses to deploy a chart whose signature is missing or does not verify;
if-possible checks it only when a .prov exists. Most public repositories
publish no signatures at all, so always on bitnami/* will simply fail β
it is meant for internal repositories whose charts you pack and sign yourself.
The setting belongs to the repository, and it applies to OCI registries too:
an OCI chart is matched to its registry by address prefix, longest first.
A map keyed by uniqname β name[@namespace[@kubecontext]]:
releases:
api: # current context, its default namespace
api@app: # namespace app
api@app@staging: # namespace app, kube-context stagingNamespace and kube-context are optional; when omitted the current kube-context
and its default namespace are used, resolved at apply time. The identity lives
entirely in the key β a release body has no name/namespace fields.
Free-form key/value labels used for selection (-l) and for label-based
needs. Values are coerced to strings, so common: true and replicas: 3 are
accepted. Keys and values must be valid Kubernetes labels.
They are also written onto the release's storage object (the Secret or ConfigMap holding release state), so the same labels find the release in the cluster:
kubectl get secret -n app -l app=api,owner=helmThere is no second field for this. Helm's own name, owner, status and
version are applied after yours and win, so a label called name still selects
in the manifest but does not reach the storage object.
Stored with each revision of the release and read back with nelm release get β the natural place for where a rollout came from:
Release: # once, for every release
annotations:
ci/pipeline: [[ getenv "CI_PIPELINE_URL" ]]
ci/commit: [[ getenv "CI_COMMIT_SHA" ]]Unlike labels these are not selectable: they live inside the serialized
release, not in the storage object's metadata, so kubectl get -l cannot see
them. In exchange they take values a label cannot β URLs, e-mail addresses,
commit messages. Values are coerced to strings, and being a map they deep-merge
with the Release: block, so per-release annotations add to the common ones
rather than replacing them.
These describe the release itself. Annotations on every rendered resource are a different thing and not implemented yet.
chart:
name: bitnami/postgresql # <repo-alias>/<chart>
version: 15.xname is required and may be:
alias/chartβ resolved against a declared helm repository;oci://host/path/chartβ an OCI reference (oci+http://for a registry without TLS);- anything else β a local chart path.
nelmwave orchestrates external charts only; it ships no chart templates of its own.
A chart is normally fetched by nelm while the release is applied, and a local
one is read from wherever the path points at that moment. Pass
build --download-charts to settle all of that
during the build instead.
Both take a list of file references resolved through the datasource layer.
values become the release's values files (merged by nelm in order, Helm-style);
stores are companion files copied into the plan for anything else you need
alongside it.
Four equivalent spellings are accepted:
values:
- src: file://values/pg.yml.tpl # mapping, with scheme
- file://values/pg.yml.tpl # bare string, with scheme
- src: values/pg.yml.tpl # mapping, no scheme (local file)
- values/pg.yml.tpl # bare string, no scheme (local file)| Field | Meaning |
|---|---|
src |
Local path, or a URL with any gomplate datasource scheme (env:, http(s)://, s3://, git://, vault://, ...). |
name |
Names the resolved artifact under .nelmwave/. Default: an index-prefixed basename (00-pg.yml). |
optional |
A source that does not exist is skipped instead of failing the build. A source that exists but errors still fails. |
Behaviour is chosen by extension, not by scheme:
| Extension | Behaviour |
|---|---|
.yml / .yaml |
copied verbatim |
.yml.tpl |
rendered through gomplate ([[ ]]) |
.yml.sops |
decrypted with sops |
.yml.tpl.sops |
decrypted, then rendered |
.sops sources are decrypted in-process β the sops binary is not required.
Keys come from the ambient environment, exactly as they do for the sops CLI:
SOPS_AGE_KEY_FILE / SOPS_AGE_KEY, GnuPG, or cloud KMS credentials. nelmwave
neither stores nor configures key material.
values:
- values/db-credentials.yml.sops
stores:
- { src: secrets/tls.yml.sops, name: tls.yml }The format handed to sops comes from the extension under .sops: .yml/.yaml
β yaml, .json β json, .env β dotenv, anything else β binary.
Encrypt templates as binary. gomplate's [[ ... ]] is a valid YAML flow
sequence, so encrypting a template with --input-type yaml lets sops reshape
the actions into nested lists and silently destroy them:
sops --encrypt --input-type binary --output-type binary \
--age "$AGE_RECIPIENT" secrets.yml.tpl > secrets.yml.tpl.sopsPlain (non-template) documents encrypt normally, and keep sops' per-value encryption, which diffs and merges far better than an opaque blob:
sops --encrypt --age "$AGE_RECIPIENT" db-credentials.yml > db-credentials.yml.sopsDecrypted content is written to .nelmwave/ in cleartext β it is a build
artifact directory, already .gitignored, and should be treated as sensitive.
build says so out loud whenever a run decrypted anything:
WARN decrypted secrets written in cleartext {"sources": 1, "dir": ".nelmwave",
"hint": "treat this directory as sensitive: do not publish it as a build artifact"}
Cross-references. Within a release, stores resolve first, then values.
Each resolved artifact is registered as a gomplate datasource named
stores/<artifact> or values/<artifact>, where <artifact> is the resolved
file name β the name you gave it, or the generated 00-base.yml otherwise. A
later .tpl can then pull in an earlier artifact:
values:
- { src: values/base.yml, name: base.yml }
- { src: values/app.yml.tpl, name: app.yml } # can read base.yml[[ (ds "values/base.yml").image.registry ]]
[[ include "stores/netpol.yml" ]]
Naming artifacts explicitly is worth it here: it keeps the datasource key stable when you reorder the list.
Resolution is backward-only: an entry sees only artifacts resolved before it,
within the same release. A skipped optional source resolves to an empty
placeholder rather than an error. See
examples/datasources.
Inline value overrides, applied on top of values (highest precedence):
sets:
replicaCount: 3
image.tag: "1.4.2"
ingress.enabled: falseKeys are dotted paths, as with helm --set, but values keep their YAML type β
they reach nelm as type-preserving JSON, so 3 stays a number and false stays
a boolean.
Dependency edges. All parts combine: a release waits for every release named in
needs.releases plus every release matched by the inlined label selector.
needs:
releases:
postgres@data: {} # required (the default)
metrics@obs: { optional: true } # nice to have
matchLabels:
tier: db
matchLabelsExpressions:
- { key: env, operator: In, values: [prod, stg] }optional decides what happens when the dependency is filtered out of the
current selection. A declared dependency is required by default: selecting
the dependent release without it is an error. Marking it optional: true drops
the edge with a warning instead. up --include-needs pulls
filtered-out dependencies back into the run either way.
Label-matched dependencies are always optional β a selector casts a wide net, and failing because it happened to catch a filtered-out release would be surprising. When a release is named explicitly and matched by the selector, the explicit entry decides.
An empty label selector adds no dependencies β it does not match everything. Cycles are rejected at build time, with the cycle printed.
Settings for the release's namespace. Not which namespace β that is part of
the release key (api@production) β but whether nelmwave creates it and what
metadata it carries:
releases:
api@production:
chart: { name: repo/api }
namespace:
create: true
labels:
pod-security.kubernetes.io/enforce: restricted
istio-injection: enabled
annotations:
owner: platform-team| Field | Default | Meaning |
|---|---|---|
create |
true |
Ensure the namespace exists before applying. |
delete |
false |
Delete the namespace after down removes the release. |
labels |
none | Labels merged onto the namespace object. |
annotations |
none | Annotations merged onto the namespace object. |
Labels and annotations merge: keys nelmwave does not declare are left alone, so it coexists with whatever else manages that namespace.
delete is not the mirror of create, which is why it defaults to false
while create defaults to true. The namespace is not owned by the release:
deleting it removes everything else living there β other releases, secrets,
PVCs β not just what nelmwave put in. down logs a warning for every release
that carries it, before uninstalling.
They are applied before the release, not after β a policy label such as
istio-injection or pod-security.kubernetes.io/enforce only affects workloads
created once it is in place. nelm's own API creates namespaces with nothing but
a name, so nelmwave writes this metadata itself.
With create: false and metadata declared, the namespace must already exist;
nelmwave patches it rather than creating one behind your back.
Writing
namespace: production(a string) is an error, not a silent no-op: the name belongs in the release key.
| Field | Default | Meaning |
|---|---|---|
timeout |
none | Bounds the operation, e.g. 5m. |
autoRollback |
false |
Roll back to the last deployed revision on failure (Helm's --atomic). |
How the release treats the resources it owns:
| Field | Default | Meaning |
|---|---|---|
forceAdoption |
false |
Take over a resource that another Helm release claims through meta.helm.sh/release-name. Without it nelm refuses to touch it. |
removeManualChanges |
true |
Reclaim fields added to a resource by hand (kubectl edit) that the manifest does not mention. Set to false to leave them alone. |
installCRDs |
true |
Install the CRDs from the chart's crds/ directory. Turn off where a separate pipeline owns CRDs. |
deletePropagation |
Foreground |
Default deletion strategy: Foreground, Background or Orphan. Case-sensitive, and validated at build time. A single resource can override it with werf.io/delete-propagation. |
historyLimit |
10 |
How many revisions of the release to keep in storage. |
releases:
legacy@prod:
chart: { name: repo/legacy }
forceAdoption: true # adopting resources from a previous tool
removeManualChanges: false # ... whose manual tweaks must survive
historyLimit: 3forceAdoption is for migrations and release renames β a rename makes the
release a new owner of existing resources. Leaving it on permanently means the
next name collision silently steals someone else's resources instead of failing.
deletePropagation and historyLimit apply to down as well as up;
removeManualChanges applies to both and to diff, so the preview matches what
the apply will do.
Where the release keeps its state β the history of revisions, their manifests and values. A URL, so one field carries both the choice and its parameters:
| URL | State lives in |
|---|---|
kubernetes://secrets |
A Secret per revision in the release namespace. The default. |
kubernetes://configmaps |
A ConfigMap per revision. |
psql://user@host:5432/db |
PostgreSQL. postgres:// and postgresql:// work too. |
Release: # once, for the whole manifest
driverURL: psql://nelm@db.internal/nelmSet it in the Release: block, not per release: a manifest whose releases keep
state in different places is a good way to lose one.
Changing it later is not a migration. The old revisions stay where they
were, so nelmwave finds no history, treats the release as new, and refuses to
touch resources that still name the previous release β or, with
forceAdoption, adopts them and drops the history on the floor. Move the state
yourself before switching.
kubernetes://configmaps exists for compatibility with what Helm 2 did. Mind
the permissions: release state includes rendered values, and a ConfigMap is
readable by anyone who can get configmaps β including the values you took the
trouble to encrypt with sops.
PostgreSQL is worth it when releases outgrow the ~1 MB an object can hold (large
CRDs do this), when history should outlive the namespace, or to keep the churn
out of etcd. nelm creates its own schema on first connect. Do not put the
password in the URL: build copies the manifest into
.nelmwave/planfile.yml, so it would sit in cleartext on disk and in CI
artifacts. Leave it out and let libpq read PGPASSWORD β build warns if it
finds one embedded.
nelm also has a memory driver. nelmwave does not offer it: state that dies
with the process means the next up sees no history and tries to adopt the
resources it installed itself.
The top-level Release: block is a confijer type default: it applies to every
value of the Go type Release, i.e. to every entry under releases:.
Release:
labels:
common: true
autoRollback: true
namespace:
labels:
managed-by: nelmwaveThe same works per type: a top-level Namespace: block applies its creation
policy and metadata to every release's namespace.
Maps deep-merge, and a release's own key wins:
Release:
labels: { team: platform, env: prod }
releases:
api@app:
labels: { team: data } # -> {team: data, env: prod}Lists are replaced, not merged. A release that declares its own values:
does not inherit Release: { values: [...] } β YAML semantics, deliberately kept.
To share a base values file, list it explicitly:
releases:
api@app:
values:
- values/common.yml
- values/api.yml.tpl.nelmwave/
planfile.yml resolved plan: releases, dependency edges, artifacts
values/<uniqname>/... values files, in merge order
stores/<uniqname>/... companion files from stores:
charts/<chart>/... the charts themselves, with --download-charts
Values and store artifacts are rebuilt from scratch on every run, so sources removed from the manifest leave nothing behind. The planfile is deterministic (map keys are sorted), so it diffs cleanly between builds and is worth reading in review.
By default the plan records which chart to install and nelm gets it while
applying, so up needs the chart repository β or the local chart directory β
as much as build does. --download-charts closes that gap:
# where the charts are reachable
nelmwave build --download-charts
# anywhere else β no repository, no registry credentials, no network
tar czf plan.tgz .nelmwave/ && scp plan.tgz isolated:
ssh isolated 'tar xzf plan.tgz && nelmwave up'Every chart goes into .nelmwave/charts/ and is recorded in the release's
chartFile. There is one rule for all of them: whatever chart.name said, the
build directory now holds the chart, and up, down and diff load it from
there and look nowhere else.
.nelmwave/charts/
bitnami_postgresql/postgresql-15.5.38.tgz # from a helm repository
ghcr.io_acme_api/api-1.4.0.tgz # from an OCI registry
api/Chart.yaml, templates/, ... # chart: {name: ./charts/api}
Remote charts are downloaded through the very getters nelm uses at apply time,
so everything a repository declares applies unchanged β credentials,
caFile/certFile, insecureSkipTLSVerify, oci+http:// and
provenanceStrategy, which verifies the signature here instead of at apply
time. A version constraint like 15.x is therefore resolved once, during the
build, instead of again per command.
Local charts are copied in as they are: a directory whole, a packaged .tgz as
the one file it is. A relative path is resolved from the manifest's
directory, exactly as values and stores are β not from wherever the
command was run. A path that does not exist, or a directory without a
Chart.yaml, fails the build instead of the apply.
Releases sharing a chart share one copy, and the directory is rebuilt from scratch on every build, so an edited local chart is picked up and a chart dropped from the manifest leaves nothing behind.
One thing to know: an archive travels with whatever it ships in charts/, so a
chart that expects its dependencies: to be fetched while rendering still needs
its repositories. That is rare β published charts package their subcharts.
The whole build directory has to travel, not just the planfile β up fails with
a clear message if a chart it names is missing.
| Command | Purpose |
|---|---|
nelmwave build |
Render the manifest and write the plan to .nelmwave/ |
nelmwave up |
Deploy the selected releases in dependency order |
nelmwave down |
Uninstall the selected releases in reverse order |
nelmwave diff |
Show the changes that would be applied (alias: plan) |
nelmwave completion |
Print a shell completion script (bash/zsh/fish/powershell) |
Global flags: --log-level (debug/info/warn/error), --log-format
(auto/console/json), --version, and the cluster-connection flags below
(--kube-context, --kube-config, ...).
Common command flags: -l/--selector, --concurrency, --output,
--include-needs (up, down, diff), --file (build, up --build),
--download-charts (build, up --build), --dry-run (up),
--detailed-exitcode (diff).
--log-format auto picks console output on a TTY and JSON everywhere else, so
CI logs stay machine-readable without a flag.
--log-level also sets nelm's own verbosity, so --log-level debug gets you
the engine's debug output too, and --log-level error silences its progress.
Every flag can be set through an environment variable instead: uppercase the
name, swap - for _, prefix with NELMWAVE_.
| Flag | Variable |
|---|---|
--log-level |
NELMWAVE_LOG_LEVEL |
--output |
NELMWAVE_OUTPUT |
--selector |
NELMWAVE_SELECTOR |
--kube-context |
NELMWAVE_KUBE_CONTEXT |
--kube-request-timeout |
NELMWAVE_KUBE_REQUEST_TIMEOUT |
The rule has no exceptions β nelmwave <command> --help prints the variable
next to each flag, so there is nothing to look up.
# One pipeline-wide setting instead of repeating it on every command
export NELMWAVE_LOG_FORMAT=json
export NELMWAVE_KUBE_CONTEXT=prod
nelmwave build && nelmwave diff --detailed-exitcode && nelmwave upPrecedence is flag, then variable, then default: a flag on the command line always wins, so a CI-wide variable stays overridable per invocation. An empty variable counts as unset. A value the flag cannot parse fails the command and names the variable:
Error: NELMWAVE_CONCURRENCY="abc": invalid argument "abc" for "--concurrency" flag
Note that the manifest sees the whole environment through gomplate
([[ env.Getenv "ENV" ]]), which is a separate mechanism with no prefix rule β
NELMWAVE_* is only about flags.
# bash β for the current shell
source <(nelmwave completion bash)
# bash β permanently (requires bash-completion)
nelmwave completion bash > /usr/local/etc/bash_completion.d/nelmwave # macOS, brew
nelmwave completion bash > /etc/bash_completion.d/nelmwave # Linux
# zsh β permanently, into a directory on your $fpath
nelmwave completion zsh > "${fpath[1]}/_nelmwave"zsh needs compinit enabled; if it is not, add autoload -U compinit; compinit
to ~/.zshrc before the line above. fish and powershell scripts are
available from the same command.
Beyond command and flag names, completion fills in values:
| Typing | Suggests |
|---|---|
-l <TAB> |
label keys from the built plan (app=, tier=, ...) |
-l app=<TAB> |
the values that key has across your releases |
-l app=api,<TAB> |
keys again, keeping what you already typed |
--kube-context <TAB> |
contexts from your kubeconfig |
--log-level <TAB>, --log-format <TAB> |
the accepted values |
--file <TAB>, --output <TAB> |
manifests (.yml/.yaml/.tpl) and directories |
Label completion reads .nelmwave/planfile.yml (or --output), so it starts
working after the first build and reflects the plan you are about to apply. The
commands take no positional arguments, so nelmwave up <TAB> offers nothing
instead of listing the current directory.
By default nelmwave reads a kubeconfig, exactly as kubectl would: $KUBECONFIG
when it is set, ~/.kube/config otherwise. --kube-config overrides that and is
repeatable, behaving like KUBECONFIG=a:b β files merge, and where they disagree
the earlier one wins. A release's uniqname picks the context
(api@app@staging), and --kube-context sets it for releases that name none.
Every command resolves the connection the same way, down included.
Where there is no kubeconfig β CI with a ServiceAccount token β the connection can be given directly:
nelmwave up \
--kube-api-server https://k8s.example.com:6443 \
--kube-token-path /var/run/secrets/kubernetes.io/serviceaccount/token \
--kube-ca /var/run/secrets/kubernetes.io/serviceaccount/ca.crt| Flags | For |
|---|---|
--kube-config, --kube-config-base64, --kube-context, --kube-context-cluster, --kube-context-user |
Choosing what to use from a kubeconfig |
--kube-api-server, --kube-token, --kube-token-path, --kube-ca, --kube-ca-data, --no-verify-kube-tls, --kube-api-server-tls-name, --kube-proxy-url |
Connecting without one |
--kube-cert, --kube-cert-data, --kube-key, --kube-key-data, --kube-auth-username, --kube-auth-password |
Client certificates and basic auth |
--kube-impersonate-user, --kube-impersonate-group, --kube-impersonate-uid |
Acting as someone else (kubectl --as) |
--kube-qps-limit, --kube-burst-limit, --kube-request-timeout |
Client-side throttling |
These are flags and not manifest fields on purpose: a token or a key written into
nelmwave.yml would be copied into .nelmwave/planfile.yml and travel with the
build artifacts. Pass secrets on the command line or point at a file.
The same connection is used for nelmwave's own calls β the namespace metadata it applies before handing over to nelm β so labels and workloads cannot end up in different clusters.
diff hides parts of a change by default, the same way nelm's CLI does. Each
kind of omission has its own switch:
| Flag | Effect |
|---|---|
--no-verbose-diffs |
Replace the manifest of a resource created or deleted outright with <hidden verbose changes>. On by default β this flag turns it off. |
--show-verbose-crd-diffs |
Print full CRD manifests too. Off by default: a CRD schema is long enough to bury everything else. |
--show-insignificant-diffs |
Keep helm.sh/* and werf.io/* annotations and managedFields in the comparison. Reach for this when a release reports changes you cannot see β the difference is in what was stripped, shown as <hidden insignificant changes>. |
--show-sensitive-diffs |
Print Secrets and werf.io/sensitive resources in the clear instead of <hidden sensitive changes>. Local debugging only: in CI this writes secrets to the job log. |
--diff-context-lines |
Unified-diff context size (default 3). |
up --dry-run takes no such flags and always plans with these defaults; use
nelmwave diff when you need to change the view.
Selection uses Kubernetes-style label selectors:
nelmwave up -l 'app=api,env in (prod,stg),tier!=db'Independent releases are applied in parallel; --concurrency bounds how many run
at once. A failure stops that branch of the graph β dependents are skipped,
unrelated branches keep going. down reverses the edges, so dependents are
removed before what they depend on.
Widens the selection along the dependency graph β in the direction the command travels, which is not the same direction for every command:
| Command | Pulls in | Example |
|---|---|---|
up |
what the selection depends on | up -l 'app=api' --include-needs also installs postgres |
diff |
same as up, so the preview matches the apply |
diff -l 'app=api' --include-needs also plans postgres |
down |
what depends on the selection | down -l 'app=postgres' --include-needs also removes api |
The inversion for down is deliberate: a teardown that pulled in dependencies
would delete more than you selected and leave the survivors broken, while
pulling in dependents removes exactly the things that would otherwise be left
pointing at something gone.
Without the flag, up refuses to run when a required dependency is filtered out
(see needs), while down and diff simply act on what you selected.
Every run logs the final selection before touching anything, and anything the selector did not name is called out as a warning:
INFO uninstall selection {"count": 3, "releases": ["api@app", "cache@app", "postgres@data"]}
WARN pulled in by --include-needs {"count": 2, "releases": ["api@app", "cache@app"]}
| Code | Meaning |
|---|---|
0 |
Success (and, with diff --detailed-exitcode, no pending changes) |
1 |
Something failed |
2 |
diff --detailed-exitcode only: changes are planned |
make test # unit tests, no cluster needed
make lint # golangci-lint, config pinned in .golangci.yml
make e2e # end-to-end: start a cluster, run the suite, tear it downThe end-to-end suite (test/e2e) drives the real command tree
against a real Kubernetes API: build, up, a clean diff, a drifting diff with
exit code 2, the upgrade that resolves it, a selective down, a full down, the
needs policy, and --download-charts applying with the repository switched off
mid-test. It installs a local chart from testdata β published through a
repository the suite starts itself where a remote one is needed β so nothing is
downloaded from the internet and every assertion is about nelmwave's own
behaviour.
The cluster is a k3s container owned by docker-compose, which keeps the fixture in one file. To iterate without restarting it:
make e2e-up # start k3s, wait for its healthcheck
make e2e-test # run the suite (repeatable)
make e2e-down # remove the container and its volumesThe suite is behind the e2e build tag, so go test ./... never reaches for a
cluster.
Using podman? Point compose at its socket first:
export DOCKER_HOST="unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}')". Rootless podman additionally needs thecpusetcontroller delegated β without it k3s exits withfailed to find cpuset cgroup (v2):podman machine ssh 'sudo sh -c "printf \"[Service]\nDelegate=memory pids cpu io cpuset\n\" \ > /etc/systemd/system/user@.service.d/delegate.conf"' podman machine stop && podman machine startThe remaining rootless workarounds (masking
/dev/kmsg, nesting the cgroup,KubeletInUserNamespace) are already part ofdocker-compose.ymland are no-ops under a rootful runtime.
cmd/nelmwave/ # main(): CLI entry point
internal/
cli/ # cobra commands: build, up, down, diff
config/ # nelmwave.yml schema, confijer load, validation, selectors
tpl/ # gomplate v5 rendering ([[ ]] delimiters)
datasource/ # resolve values/store refs (gomplate v5)
build/ # resolve a config's datasources into .nelmwave/ artifacts
chart/ # pull a remote chart into the build directory (helm getters)
plan/ # .nelmwave/ plan build/read/write
graph/ # concurrent dependency-DAG executor
release/ # Applier over nelm (install/uninstall/plan)
repo/ # resolve chart refs against repositories; OCI docker config
log/ # zap setup (auto console/json)
version/ # build-time version info
Note on confijer: the manifest loader binds keys via
jsonstruct tags (case-insensitively), notyamltags. Config structs therefore carry bothjson(for loading) andyaml(for plan serialization) tags in sync.