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
16 changes: 16 additions & 0 deletions .changes/unreleased/Added-20260807-120000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
kind: Added
body: '`build --download-charts` puts every chart into `.nelmwave/charts/` and
points the plan at it, so `up`, `down` and `diff` need nothing but the build
directory — build where the charts are reachable, copy the directory over,
apply in isolation. Remote charts are downloaded through the same helm getters
nelm uses at apply time, so repository credentials, TLS material, plain-HTTP
OCI and `provenanceStrategy` apply unchanged and a version constraint is
resolved once; local charts are copied in as they are, with a relative path
resolved from the manifest''s directory like `values` and `stores`. Releases
sharing a chart share one copy, the directory is rebuilt on every run, and a
missing local chart fails the build rather than the apply. `up --build
--download-charts` does both in one step'
time: 2026-08-07T12:00:00.000000000+03:00
custom:
Issue: ""
Author: zhilyaev
10 changes: 10 additions & 0 deletions .changes/unreleased/Changed-20260807-150000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
kind: Changed
body: '`build` writes every artifact through an `os.Root` scoped to the build
directory, so a `name:` from the manifest — or a symlink sitting in the
directory — cannot land a file outside `.nelmwave/`, and a local chart is
read through a root scoped to the chart, closing the symlink race between
walking a directory and reading what the walk found'
time: 2026-08-07T15:00:00.000000000+03:00
custom:
Issue: ""
Author: zhilyaev
34 changes: 29 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ dependency order. Full user-facing schema and flag reference lives in

`build` is the **only** phase that renders templates or touches datasources. It
writes a self-contained artifact directory (`.nelmwave/`: `planfile.yml`,
`values/<uniqname>/`, `stores/<uniqname>/`). `up`, `down` and `diff` read that
plan and never re-render, so what was reviewed is what gets applied. Never add
template rendering or datasource resolution to a runtime command path.
`values/<uniqname>/`, `stores/<uniqname>/`, and `charts/<chart>/` with
`--download-charts`, which is also the only phase that fetches charts). `up`, `down` and `diff` read that plan and never re-render,
so what was reviewed is what gets applied. Never add template rendering or
datasource resolution to a runtime command path.

Pipeline: `cli/build.go: buildPlan()` → `tpl.Render` (gomplate, `[[ ]]`) →
`config.Parse` → `config.Validate` → `plan.FromConfig` → `build.Artifacts`
(resolves values/stores into `.nelmwave/`) → `plan.Write`.
(resolves values/stores into `.nelmwave/`) → `build.Charts` (only with
`--download-charts`; sets each release's `ChartFile`) → `plan.Write`.

Runtime path: `plan.Read` → `cli/deploy.go: deploy()` → label selection →
`--include-needs` expansion / required-need check → `graph.Reverse` for `down` →
Expand All @@ -88,7 +90,8 @@ Runtime path: `plan.Read` → `cli/deploy.go: deploy()` → label selection →
| `internal/config` | manifest schema, confijer loading, normalization, validation, uniqnames, selectors |
| `internal/tpl` | gomplate v5 rendering with `[[ ]]` delimiters (Helm's `{{ }}` stays untouched) |
| `internal/datasource` | turn one `FileRef.Src` into bytes; behaviour chosen by extension |
| `internal/build` | drive the resolver over every release, write artifacts, register cross-reference datasources |
| `internal/build` | drive the resolver over every release, write artifacts, register cross-reference datasources, download charts |
| `internal/chart` | `helm pull` through nelm's vendored helm getters, for `build --download-charts` |
| `internal/plan` | the on-disk plan: `FromConfig`, `Write`, `Read` |
| `internal/graph` | concurrent DAG executor |
| `internal/release` | `Spec`/`Applier` abstraction and the nelm implementation; namespace metadata; kube connection |
Expand Down Expand Up @@ -121,13 +124,34 @@ package-level `Metrics` struct without synchronization, so concurrent `Render`
calls crash with "concurrent map writes" (see the comment on
`build.Artifacts`). Do not parallelize it until gomplate is safe.

**Every build write goes through an `os.Root`.** `build.openOut` opens
`.nelmwave/` as a root and `writeFile` takes it plus a path *relative to the
build directory* — not an absolute one. The kernel resolves each write inside
that root, so a `name:` out of the manifest cannot escape even if the string
checks (`safeRelPath`) miss something, and a symlink planted in the directory
cannot redirect a write. `copyTree` does the same on the reading side with a
root scoped to the source chart. Do not reintroduce a bare
`os.WriteFile`/`os.MkdirAll` here — gosec's G703/G122 will catch it in CI, and
the paths really are attacker-shaped.

**Datasource behaviour comes from the extension, not the scheme**: `.yml` copied
verbatim, `.yml.tpl` rendered, `.yml.sops` decrypted in-process (no `sops`
binary), `.yml.tpl.sops` decrypted then rendered. Within a release, `stores`
resolve before `values`, and each resolved artifact is registered as a gomplate
datasource (`stores/<name>`, `values/<name>`) visible to *later* artifacts of the
same release only.

**`ChartFile` overrides the chart reference, it does not supplement it.**
`build.Charts` (only under `--download-charts`) puts *every* chart into
`.nelmwave/charts/` — remote ones downloaded, local ones copied — so there is a
single rule at apply time. Once `plan.Release.ChartFile` is set, `buildSpec`
swaps in the absolute path and clears the version *and* the whole
`repo.ChartResolution`: it is already one version of one chart, so a leftover
constraint or repo URL could only send nelm looking elsewhere. nelm treats an
absolute path as a local chart (`isLocalChart`), which is what keeps the apply
offline. Local chart paths resolve against the *manifest* directory here, like
values and stores — not the process CWD, which is what nelm would use.

**`graph.Run` is fail-fast per branch, not globally.** A failed node skips its
dependents and returns `Result{Skipped: true}` for them; unrelated branches keep
running. `Run` never returns early — the caller (`summarize`) aggregates.
Expand Down
70 changes: 66 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ chart:
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`](#applying-without-a-registry) to settle all of that
during the build instead.

#### `values` and `stores`

Both take a list of file references resolved through the datasource layer.
Expand Down Expand Up @@ -571,13 +576,65 @@ releases:
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.

### Applying without a registry

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:

```sh
# 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.

---

## Commands
Expand All @@ -596,7 +653,8 @@ Global flags: `--log-level` (debug/info/warn/error), `--log-format`

Common command flags: `-l/--selector`, `--concurrency`, `--output`,
`--include-needs` (up, down, diff), `--file` (build, `up --build`),
`--dry-run` (up), `--detailed-exitcode` (diff).
`--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.
Expand Down Expand Up @@ -786,9 +844,12 @@ make e2e # end-to-end: start a cluster, run the suite, tear it down

The end-to-end suite ([`test/e2e`](./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, and
the needs policy. It installs a local chart from `testdata`, so nothing
is downloaded and every assertion is about nelmwave's own behaviour.
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:
Expand Down Expand Up @@ -829,6 +890,7 @@ internal/
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)
Expand Down
99 changes: 64 additions & 35 deletions internal/build/artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,24 @@ import (
// this once gomplate is safe for concurrent use.
func Artifacts(ctx context.Context, cfg *config.Config, p *plan.Plan, baseDir, outDir string, logger *zap.Logger) error {
res := datasource.NewResolver(baseDir)
valuesDir := filepath.Join(outDir, plan.ValuesDir)
storeDir := filepath.Join(outDir, plan.StoreDir)

out, err := openOut(outDir)
if err != nil {
return err
}
defer func() { _ = out.Close() }()

// Start clean so removed releases/sources don't leave stale artifacts.
for _, dir := range []string{valuesDir, storeDir} {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("clean %q: %w", dir, err)
for _, dir := range []string{plan.ValuesDir, plan.StoreDir} {
if err := out.RemoveAll(dir); err != nil {
return fmt.Errorf("clean %q: %w", filepath.Join(outDir, dir), err)
}
}

// A shared empty file backs datasources for skipped optional artifacts, so a
// reference to one renders empty instead of erroring. It is written on first
// use, so a manifest without optional sources leaves no stray file behind.
emptyURL := lazyEmptyPlaceholder(outDir)
emptyURL := lazyEmptyPlaceholder(out)

for _, key := range p.ReleaseNames() {
rc := cfg.Releases[key]
Expand All @@ -61,10 +65,10 @@ func Artifacts(ctx context.Context, cfg *config.Config, p *plan.Plan, baseDir, o
// sources accumulates this release's resolved artifact datasources.
sources := map[string]string{}

if _, err := resolveList(ctx, res, rc.Stores, key, outDir, plan.StoreDir, "stores", sources, emptyURL, log); err != nil {
if _, err := resolveList(ctx, res, rc.Stores, key, out, plan.StoreDir, "stores", sources, emptyURL, log); err != nil {
return err
}
files, err := resolveList(ctx, res, rc.Values, key, outDir, plan.ValuesDir, "values", sources, emptyURL, log)
files, err := resolveList(ctx, res, rc.Values, key, out, plan.ValuesDir, "values", sources, emptyURL, log)
if err != nil {
return err
}
Expand Down Expand Up @@ -102,11 +106,11 @@ func warnAboutDecryptedSecrets(cfg *config.Config, outDir string, logger *zap.Lo
}

// resolveList resolves one ordered list of refs (values or store) for a release,
// writing each artifact under outDir/subDir/<uniqname>/ and registering it in
// sources under "<ns>/<name>". It returns the plan-relative paths of the written
// files (used for values). A skipped optional is registered to emptyURL.
func resolveList(ctx context.Context, res *datasource.Resolver, refs []config.FileRef, key, outDir, subDir, ns string, sources map[string]string, emptyURL func() (string, error), log *zap.Logger) ([]string, error) {
relDir := filepath.Join(outDir, subDir, sanitize(key))
// writing each artifact under <build dir>/subDir/<uniqname>/ and registering it
// in sources under "<ns>/<name>". It returns the plan-relative paths of the
// written files (used for values). A skipped optional is registered to emptyURL.
func resolveList(ctx context.Context, res *datasource.Resolver, refs []config.FileRef, key string, out *os.Root, subDir, ns string, sources map[string]string, emptyURL func() (string, error), log *zap.Logger) ([]string, error) {
relDir := filepath.Join(subDir, sanitize(key))
seen := make(map[string]struct{})
var files []string
for i, ref := range refs {
Expand Down Expand Up @@ -135,36 +139,36 @@ func resolveList(ctx context.Context, res *datasource.Resolver, refs []config.Fi
}

path := filepath.Join(relDir, filepath.FromSlash(name))
if err := writeFile(path, data); err != nil {
if err := writeFile(out, path, data); err != nil {
return nil, err
}
sources[dsKey], err = fileURL(path)
sources[dsKey], err = fileURL(filepath.Join(out.Name(), path))
if err != nil {
return nil, err
}
files = append(files, filepath.ToSlash(filepath.Join(subDir, sanitize(key), name)))
files = append(files, filepath.ToSlash(path))
log.Debug("artifact resolved", zap.String("datasource", dsKey))
}
return files, nil
}

// lazyEmptyPlaceholder returns a func that creates the shared empty placeholder
// under outDir on first call and returns its file:// URL, memoizing the result.
// The sync.Once keeps it to a single write even though resolution is currently
// sequential, so this stays correct if that ever changes.
func lazyEmptyPlaceholder(outDir string) func() (string, error) {
// in the build directory on first call and returns its file:// URL, memoizing
// the result. The sync.Once keeps it to a single write even though resolution is
// currently sequential, so this stays correct if that ever changes.
func lazyEmptyPlaceholder(out *os.Root) func() (string, error) {
var (
once sync.Once
url string
err error
)
return func() (string, error) {
once.Do(func() {
path := filepath.Join(outDir, ".empty")
if err = writeFile(path, nil); err != nil {
const name = ".empty"
if err = writeFile(out, name, nil); err != nil {
return
}
url, err = fileURL(path)
url, err = fileURL(filepath.Join(out.Name(), name))
})
return url, err
}
Expand Down Expand Up @@ -205,19 +209,24 @@ func indexedBasename(index int, src string) string {
// .sops or .tpl would claim otherwise.
label = strings.TrimSuffix(label, ".sops")
label = strings.TrimSuffix(strings.TrimSuffix(label, ".tpl"), ".tmpl")
label = pathBase(label)
label = strings.Map(func(r rune) rune {
label = sanitizeSegment(pathBase(label))
if label == "" {
label = "values"
}
return fmt.Sprintf("%02d-%s", index, label)
}

// sanitizeSegment makes an arbitrary string usable as one path segment, mapping
// everything outside [A-Za-z0-9._-] to '_'.
func sanitizeSegment(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
return r
default:
return '_'
}
}, label)
if label == "" {
label = "values"
}
return fmt.Sprintf("%02d-%s", index, label)
}, s)
}

// pathBase returns the last '/'-separated segment of s (URLs and manifest paths
Expand Down Expand Up @@ -249,12 +258,32 @@ func sanitize(key string) string {
return strings.NewReplacer("/", "_", string(filepath.Separator), "_").Replace(key)
}

func writeFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create dir for %q: %w", path, err)
// openOut opens the build directory as an os.Root, creating it if needed. Every
// artifact below is written through that root, so the kernel resolves each path
// inside the build directory: a name that came from the manifest cannot escape
// it, and neither can a symlink planted between two writes. safeRelPath still
// rejects the obvious escapes up front — this is the backstop that does not
// depend on getting the string handling right.
func openOut(outDir string) (*os.Root, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return nil, fmt.Errorf("create build dir %q: %w", outDir, err)
}
root, err := os.OpenRoot(outDir)
if err != nil {
return nil, fmt.Errorf("open build dir %q: %w", outDir, err)
}
return root, nil
}

// writeFile writes one artifact at name, a path relative to the build directory.
func writeFile(out *os.Root, name string, data []byte) error {
if dir := filepath.Dir(name); dir != "." {
if err := out.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create dir for %q: %w", name, err)
}
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write %q: %w", path, err)
if err := out.WriteFile(name, data, 0o644); err != nil {
return fmt.Errorf("write %q: %w", name, err)
}
return nil
}
Loading
Loading