diff --git a/.changes/unreleased/Added-20260807-120000.yaml b/.changes/unreleased/Added-20260807-120000.yaml new file mode 100644 index 0000000..2b6b5ba --- /dev/null +++ b/.changes/unreleased/Added-20260807-120000.yaml @@ -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 diff --git a/.changes/unreleased/Changed-20260807-150000.yaml b/.changes/unreleased/Changed-20260807-150000.yaml new file mode 100644 index 0000000..de8cf6d --- /dev/null +++ b/.changes/unreleased/Changed-20260807-150000.yaml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f87bf34..8f5c973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`, `stores//`). `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//`, `stores//`, and `charts//` 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` → @@ -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 | @@ -121,6 +124,16 @@ 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` @@ -128,6 +141,17 @@ resolve before `values`, and each resolved artifact is registered as a gomplate datasource (`stores/`, `values/`) 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. diff --git a/README.md b/README.md index c2606a4..a1c0116 100644 --- a/README.md +++ b/README.md @@ -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. @@ -571,6 +576,7 @@ releases: planfile.yml resolved plan: releases, dependency edges, artifacts values//... values files, in merge order stores//... companion files from stores: + charts//... the charts themselves, with --download-charts ``` Values and store artifacts are rebuilt from scratch on every run, so sources @@ -578,6 +584,57 @@ 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 @@ -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. @@ -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: @@ -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) diff --git a/internal/build/artifacts.go b/internal/build/artifacts.go index a1eb45a..ab73df4 100644 --- a/internal/build/artifacts.go +++ b/internal/build/artifacts.go @@ -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] @@ -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 } @@ -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// and registering it in -// sources under "/". 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 /subDir// and registering it +// in sources under "/". 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 { @@ -135,24 +139,24 @@ 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 @@ -160,11 +164,11 @@ func lazyEmptyPlaceholder(outDir string) func() (string, 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 } @@ -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 @@ -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 } diff --git a/internal/build/artifacts_test.go b/internal/build/artifacts_test.go index ed9860c..7cd132c 100644 --- a/internal/build/artifacts_test.go +++ b/internal/build/artifacts_test.go @@ -240,6 +240,41 @@ func TestIndexedBasename_DropsProcessingSuffixes(t *testing.T) { } } +func TestWriteFile_StaysInsideTheBuildDirectory(t *testing.T) { + // safeRelPath rejects the obvious escapes before a name gets this far. This + // pins the backstop underneath it: the write itself is resolved inside the + // build directory, so neither a traversal nor a symlink already sitting in + // the directory can put a file elsewhere. + outDir := filepath.Join(t.TempDir(), "out") + outside := t.TempDir() + + out, err := openOut(outDir) + if err != nil { + t.Fatalf("openOut: %v", err) + } + defer func() { _ = out.Close() }() + + if err := os.Symlink(outside, filepath.Join(outDir, "escape")); err != nil { + t.Fatalf("symlink: %v", err) + } + + for _, name := range []string{ + filepath.Join("..", "escaped.yml"), + filepath.Join("escape", "escaped.yml"), + filepath.Join("values", "..", "..", "escaped.yml"), + } { + if err := writeFile(out, name, []byte("owned: true\n")); err == nil { + t.Errorf("writeFile(%q) succeeded, want an error", name) + } + } + + for _, dir := range []string{filepath.Dir(outDir), outside} { + if _, err := os.Stat(filepath.Join(dir, "escaped.yml")); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("escaped.yml turned up in %q: %v", dir, err) + } + } +} + func TestArtifacts_WarnsWhenSecretsWereDecrypted(t *testing.T) { base := t.TempDir() mustWrite(t, base, "plain.yml", "a: 1\n") diff --git a/internal/build/charts.go b/internal/build/charts.go new file mode 100644 index 0000000..30da90b --- /dev/null +++ b/internal/build/charts.go @@ -0,0 +1,263 @@ +package build + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/helmwave/nelmwave/internal/chart" + "github.com/helmwave/nelmwave/internal/config" + "github.com/helmwave/nelmwave/internal/plan" + "github.com/helmwave/nelmwave/internal/repo" +) + +// Charts puts every chart the plan names into outDir/charts/ and records it in +// the release's ChartFile. Once that field is set, up/down/diff load the chart +// from the build directory and reach for nothing else — which is the point: +// build where the charts are, apply where they are not. +// +// Remote charts are downloaded; a chart already given as a local path is copied +// in as it is. Both end up in the same place, so the build directory is the +// whole story either way and there is no second rule to remember. Local paths +// are resolved relative to the manifest, like values and stores are. +// +// Charts are keyed by chart, not by release, so releases sharing one share a +// single copy. The directory is rebuilt from scratch on every run — a chart +// dropped from the manifest leaves nothing behind, an edited local chart is +// picked up, and a floating version constraint is re-resolved rather than +// pinned by an old build. +func Charts(p *plan.Plan, baseDir, outDir string, logger *zap.Logger) error { + out, err := openOut(outDir) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if err := out.RemoveAll(plan.ChartsDir); err != nil { + return fmt.Errorf("clean %q: %w", filepath.Join(outDir, plan.ChartsDir), err) + } + + // OCI credentials reach the helm getter through a Docker config.json, the + // same way they do at apply time. + registryConfig, cleanup, err := repo.DockerConfig(p.Repositories) + if err != nil { + return err + } + defer cleanup() + + // Sequential. Downloads are network-bound and would parallelise cleanly, but + // a build already talks to every datasource one at a time, and the dedup + // cache below is worth more than the wall clock here. + cache := make(map[string]string) + dirs := make(map[string]string) + + for _, key := range p.ReleaseNames() { + rel := p.Releases[key] + log := logger.With(zap.String("release", key), zap.String("chart", rel.Chart.Name)) + + remote := chart.IsRemote(rel.Chart.Name) + + // A local chart is identified by where it comes from, a remote one by + // the repository and reference it resolves to. Either way the directory + // belongs to the chart and the cache entry to one version of it, so two + // versions of the same chart sit side by side in one directory. + var ( + res repo.ChartResolution + src string + dirKey string + label = rel.Chart.Name + ) + if remote { + res = repo.Resolve(rel.Chart.Name, p.Repositories) + dirKey = res.RepoURL + "|" + res.Ref + } else { + src = localSource(baseDir, rel.Chart.Name) + dirKey = src + // "../charts/api" and "./charts/api.tgz" both read as "api" here; + // where they collide, chartDir keeps them apart. + label = strings.TrimSuffix(filepath.Base(src), ".tgz") + } + cacheKey := dirKey + "|" + rel.Chart.Version + + path, done := cache[cacheKey] + if !done { + // Relative to the build directory: what the plan records, and what + // the os.Root writes below resolve against. + dir := filepath.Join(plan.ChartsDir, chartDir(dirs, label, dirKey)) + if remote { + path, err = download(res, rel.Chart.Version, registryConfig, filepath.Join(outDir, dir), outDir) + } else { + path, err = copyLocal(src, out, dir) + } + if err != nil { + return fmt.Errorf("release %q: %w", key, err) + } + cache[cacheKey] = path + log.Info("chart added to the build directory", zap.String("path", path), zap.Bool("downloaded", remote)) + } else { + log.Debug("chart already in the build directory", zap.String("path", path)) + } + + rel.ChartFile = path + p.Releases[key] = rel + } + return nil +} + +// download fetches one resolved chart into dir and returns the archive's path +// relative to outDir, which is what the plan records. +func download(res repo.ChartResolution, version, registryConfig, dir, outDir string) (string, error) { + // Validated by config.Validate before the plan was projected. + var timeout time.Duration + if res.RequestTimeout != "" { + var err error + if timeout, err = time.ParseDuration(res.RequestTimeout); err != nil { + return "", fmt.Errorf("invalid repository requestTimeout %q: %w", res.RequestTimeout, err) + } + } + + path, err := chart.Download(chart.Options{ + Ref: res.Ref, + Version: version, + RepoURL: res.RepoURL, + Username: res.Username, + Password: res.Password, + PassCredentials: res.PassCredentials, + SkipTLSVerify: res.SkipTLSVerify, + CAFile: res.CAFile, + CertFile: res.CertFile, + KeyFile: res.KeyFile, + PlainHTTP: res.OCIPlainHTTP, + RequestTimeout: timeout, + ProvenanceStrategy: res.ProvenanceStrategy, + ProvenanceKeyring: res.ProvenanceKeyring, + RegistryConfigPath: registryConfig, + }, dir) + if err != nil { + return "", err + } + return planPath(path, outDir) +} + +// localSource turns a local chart reference into a path on disk. Relative +// references are read from the manifest's directory, exactly as values and +// stores are — the manifest is the project, not whatever directory a command +// happened to run in. +func localSource(baseDir, ref string) string { + path := filepath.FromSlash(ref) + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(baseDir, path)) +} + +// copyLocal copies a chart that is already on disk into dir — a path relative to +// the build directory — and returns that path the way the plan records it. A +// packaged chart lands beside its directory like a downloaded one; an unpacked +// chart becomes the directory itself, so what the plan points at is the chart as +// the manifest pointed at it. +func copyLocal(src string, out *os.Root, dir string) (string, error) { + info, err := os.Stat(src) + if err != nil { + return "", fmt.Errorf("local chart %q (a relative path is resolved from the manifest's directory): %w", src, err) + } + + if !info.IsDir() { + data, err := os.ReadFile(src) + if err != nil { + return "", fmt.Errorf("read local chart %q: %w", src, err) + } + dest := filepath.Join(dir, filepath.Base(src)) + if err := writeFile(out, dest, data); err != nil { + return "", err + } + return filepath.ToSlash(dest), nil + } + + // A directory with no Chart.yaml is not a chart, and saying so here beats + // letting nelm discover it once the cluster is already involved. + if _, err := os.Stat(filepath.Join(src, "Chart.yaml")); err != nil { + return "", fmt.Errorf("local chart %q has no Chart.yaml", src) + } + if err := copyTree(src, out, dir); err != nil { + return "", fmt.Errorf("copy local chart %q: %w", src, err) + } + return filepath.ToSlash(dir), nil +} + +// copyTree copies a directory recursively into dest/dir, keeping regular files +// and the directories holding them. Anything else — sockets, devices, dangling +// symlinks — is not part of a chart and is skipped rather than reproduced. +// +// The source is walked through its own os.Root, so every read is resolved by the +// kernel inside the chart directory. Walking and reading are two steps, and a +// symlink appearing between them cannot redirect the read outside the chart. +func copyTree(src string, out *os.Root, dir string) error { + srcRoot, err := os.OpenRoot(src) + if err != nil { + return err + } + defer func() { _ = srcRoot.Close() }() + + return fs.WalkDir(srcRoot.FS(), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + target := filepath.Join(dir, filepath.FromSlash(path)) + if d.IsDir() { + return out.MkdirAll(target, 0o755) + } + info, err := d.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + data, err := srcRoot.ReadFile(path) + if err != nil { + return err + } + return writeFile(out, target, data) + }) +} + +// planPath expresses a written artifact the way the planfile records it: +// relative to the build directory, with forward slashes. +func planPath(path, outDir string) (string, error) { + rel, err := filepath.Rel(outDir, path) + if err != nil { + return "", fmt.Errorf("locate chart %q under %q: %w", path, outDir, err) + } + return filepath.ToSlash(rel), nil +} + +// chartDir picks the directory a chart's archives live in: the reference made +// safe as one path segment, so charts/ stays readable next to a planfile that +// names the same chart. Two references that sanitize alike get a numeric suffix +// rather than sharing a directory; dirs remembers which chart owns what. +func chartDir(dirs map[string]string, ref, dirKey string) string { + base := sanitizeSegment(strings.TrimPrefix(strings.TrimPrefix(ref, config.OCIPlainHTTPScheme), config.OCIScheme)) + if base == "" { + base = "chart" + } + for i := 1; ; i++ { + name := base + if i > 1 { + name = fmt.Sprintf("%s-%d", base, i) + } + owner, taken := dirs[name] + if !taken { + dirs[name] = dirKey + return name + } + if owner == dirKey { + return name + } + } +} diff --git a/internal/build/charts_test.go b/internal/build/charts_test.go new file mode 100644 index 0000000..16e3cb1 --- /dev/null +++ b/internal/build/charts_test.go @@ -0,0 +1,284 @@ +package build + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/helmwave/nelmwave/internal/config" + "github.com/helmwave/nelmwave/internal/plan" +) + +// chartRepo serves a one-chart helm repository over HTTP: an index.yaml and the +// archive it points at. It counts archive fetches, so a test can tell a shared +// download from two of them. +type chartRepo struct { + *httptest.Server + archiveHits atomic.Int32 +} + +func newChartRepo(t *testing.T, name, version string) *chartRepo { + t.Helper() + archive := chartArchive(t, name, version) + digest := sha256.Sum256(archive) + + repo := &chartRepo{} + mux := http.NewServeMux() + file := fmt.Sprintf("/%s-%s.tgz", name, version) + mux.HandleFunc(file, func(w http.ResponseWriter, _ *http.Request) { + repo.archiveHits.Add(1) + _, _ = w.Write(archive) + }) + repo.Server = httptest.NewServer(mux) + t.Cleanup(repo.Close) + + index := fmt.Sprintf(`apiVersion: v1 +generated: "2020-01-01T00:00:00Z" +entries: + %s: + - apiVersion: v2 + name: %s + version: %s + digest: %s + urls: + - %s%s +`, name, name, version, hex.EncodeToString(digest[:]), repo.URL, file) + mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(index)) + }) + return repo +} + +// chartArchive packages the smallest thing helm still calls a chart. +func chartArchive(t *testing.T, name, version string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + body := fmt.Sprintf("apiVersion: v2\nname: %s\nversion: %s\n", name, version) + hdr := &tar.Header{ + Name: name + "/Chart.yaml", + Mode: 0o644, + Size: int64(len(body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// isolateHelmHome keeps the download away from the developer's own helm +// directories, which the downloader otherwise reads and writes. +func isolateHelmHome(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HELM_REPOSITORY_CACHE", filepath.Join(dir, "cache")) + t.Setenv("HELM_REPOSITORY_CONFIG", filepath.Join(dir, "repositories.yaml")) +} + +func TestCharts_DownloadsOncePerChartAndRewritesThePlan(t *testing.T) { + isolateHelmHome(t) + srv := newChartRepo(t, "demo", "1.2.3") + out := t.TempDir() + + // Two releases of the same chart, so one download has to serve both. + p := &plan.Plan{ + Repositories: map[string]config.Repository{"acme": {URL: srv.URL}}, + Releases: map[string]plan.Release{ + "a@ns": {Chart: config.Chart{Name: "acme/demo", Version: "1.2.3"}}, + "b@ns": {Chart: config.Chart{Name: "acme/demo", Version: "1.2.3"}}, + }, + } + + if err := Charts(p, t.TempDir(), out, zap.NewNop()); err != nil { + t.Fatalf("Charts: %v", err) + } + + want := filepath.ToSlash(filepath.Join(plan.ChartsDir, "acme_demo", "demo-1.2.3.tgz")) + for _, key := range []string{"a@ns", "b@ns"} { + if got := p.Releases[key].ChartFile; got != want { + t.Errorf("%s: chartFile = %q, want %q", key, got, want) + } + } + if got := srv.archiveHits.Load(); got != 1 { + t.Errorf("archive fetched %d times, want 1 — releases sharing a chart share the download", got) + } + + // The recorded path is plan-relative, and there really is an archive there. + if _, err := os.Stat(filepath.Join(out, filepath.FromSlash(want))); err != nil { + t.Errorf("chart archive not written: %v", err) + } +} + +func TestCharts_RebuildsTheDirectoryFromScratch(t *testing.T) { + isolateHelmHome(t) + srv := newChartRepo(t, "demo", "1.2.3") + out := t.TempDir() + + // A leftover from an earlier build, for a chart no longer in the manifest. + stale := filepath.Join(out, plan.ChartsDir, "gone", "gone-0.1.0.tgz") + if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + p := &plan.Plan{ + Repositories: map[string]config.Repository{"acme": {URL: srv.URL}}, + Releases: map[string]plan.Release{"a@ns": {Chart: config.Chart{Name: "acme/demo", Version: "1.2.3"}}}, + } + if err := Charts(p, t.TempDir(), out, zap.NewNop()); err != nil { + t.Fatalf("Charts: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale chart survived the rebuild (stat err = %v)", err) + } +} + +// A local chart is copied in rather than left behind, so the build directory is +// the whole story regardless of where a chart came from. +func TestCharts_CopiesLocalChartsIn(t *testing.T) { + isolateHelmHome(t) + base := t.TempDir() + out := t.TempDir() + + // An unpacked chart, referenced relative to the manifest... + unpacked := filepath.Join(base, "charts", "mine") + writeChartDir(t, unpacked, "mine", "0.1.0") + // ...and a packaged one, referenced by absolute path. + packaged := filepath.Join(base, "vendor", "other-2.0.0.tgz") + if err := os.MkdirAll(filepath.Dir(packaged), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(packaged, chartArchive(t, "other", "2.0.0"), 0o644); err != nil { + t.Fatal(err) + } + + p := &plan.Plan{ + Releases: map[string]plan.Release{ + "a@ns": {Chart: config.Chart{Name: "./charts/mine"}}, + "b@ns": {Chart: config.Chart{Name: "./charts/mine"}}, + "c@ns": {Chart: config.Chart{Name: packaged}}, + }, + } + if err := Charts(p, base, out, zap.NewNop()); err != nil { + t.Fatalf("Charts: %v", err) + } + + // The unpacked chart becomes the directory itself; the packaged one lands + // inside one, next to where a downloaded archive would. + wantDir := filepath.ToSlash(filepath.Join(plan.ChartsDir, "mine")) + wantFile := filepath.ToSlash(filepath.Join(plan.ChartsDir, "other-2.0.0", "other-2.0.0.tgz")) + for key, want := range map[string]string{"a@ns": wantDir, "b@ns": wantDir, "c@ns": wantFile} { + if got := p.Releases[key].ChartFile; got != want { + t.Errorf("%s: chartFile = %q, want %q", key, got, want) + } + } + + // The whole tree travels, not just Chart.yaml. + for _, rel := range []string{"Chart.yaml", "values.yaml", "templates/cm.yaml"} { + if _, err := os.Stat(filepath.Join(out, plan.ChartsDir, "mine", filepath.FromSlash(rel))); err != nil { + t.Errorf("%s not copied: %v", rel, err) + } + } + if _, err := os.Stat(filepath.Join(out, filepath.FromSlash(wantFile))); err != nil { + t.Errorf("packaged chart not copied: %v", err) + } +} + +func TestCharts_ReportsALocalChartThatIsNotThere(t *testing.T) { + isolateHelmHome(t) + base := t.TempDir() + + // A directory that exists but holds no chart is the confusing case: without + // this check it would fail much later, with a cluster already involved. + empty := filepath.Join(base, "charts", "empty") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + + for name, ref := range map[string]string{"missing": "./charts/nope", "no Chart.yaml": "./charts/empty"} { + p := &plan.Plan{Releases: map[string]plan.Release{"a@ns": {Chart: config.Chart{Name: ref}}}} + err := Charts(p, base, t.TempDir(), zap.NewNop()) + if err == nil { + t.Errorf("%s: expected an error for %q", name, ref) + continue + } + if !strings.Contains(err.Error(), `release "a@ns"`) { + t.Errorf("%s: the error should name the release, got %q", name, err) + } + } +} + +// writeChartDir lays out the smallest unpacked chart, plus a template, so a +// copy can be checked for more than its Chart.yaml. +func writeChartDir(t *testing.T, dir, name, version string) { + t.Helper() + files := map[string]string{ + "Chart.yaml": fmt.Sprintf("apiVersion: v2\nname: %s\nversion: %s\n", name, version), + "values.yaml": "message: hello\n", + "templates/cm.yaml": "kind: ConfigMap\n", + } + for rel, body := range files { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestCharts_ReportsAnUnreachableRepository(t *testing.T) { + isolateHelmHome(t) + p := &plan.Plan{ + Repositories: map[string]config.Repository{"acme": {URL: "http://127.0.0.1:1/charts"}}, + Releases: map[string]plan.Release{"a@ns": {Chart: config.Chart{Name: "acme/demo", Version: "1.2.3"}}}, + } + err := Charts(p, t.TempDir(), t.TempDir(), zap.NewNop()) + if err == nil { + t.Fatal("expected a download failure") + } + if got := err.Error(); !strings.Contains(got, `release "a@ns"`) { + t.Errorf("the error should name the release, got %q", got) + } +} + +func TestChartDir_SeparatesReferencesThatSanitizeAlike(t *testing.T) { + dirs := map[string]string{} + first := chartDir(dirs, "oci://ghcr.io/acme/demo", "|oci://ghcr.io/acme/demo") + if first != "ghcr.io_acme_demo" { + t.Errorf("dir = %q, want the scheme dropped and separators flattened", first) + } + // Same directory for another version of the same chart... + if again := chartDir(dirs, "oci://ghcr.io/acme/demo", "|oci://ghcr.io/acme/demo"); again != first { + t.Errorf("second version of the same chart got %q, want %q", again, first) + } + // ...but not for a different chart that happens to sanitize the same way. + if other := chartDir(dirs, "ghcr.io/acme/demo", "https://repo|demo"); other == first { + t.Errorf("distinct charts share the directory %q", other) + } +} diff --git a/internal/chart/download.go b/internal/chart/download.go new file mode 100644 index 0000000..f2410fd --- /dev/null +++ b/internal/chart/download.go @@ -0,0 +1,185 @@ +// Package chart fetches a remote chart into the build artifact, so that a plan +// built where the registries are reachable can be applied where they are not. +// +// It is `helm pull` done through the very getters nelm uses at apply time (the +// helm SDK vendored inside nelm), so a chart that resolves during build resolves +// the same way here. The published archive is written as-is: whatever it carries +// in charts/ travels with it, but a chart that expects its dependencies to be +// fetched while rendering still needs its repositories reachable. +package chart + +import ( + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/cli" + helmdownloader "github.com/werf/nelm/pkg/helm/pkg/downloader" + helmgetter "github.com/werf/nelm/pkg/helm/pkg/getter" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" + helmregistry "github.com/werf/nelm/pkg/helm/pkg/registry" + helmrepo "github.com/werf/nelm/pkg/helm/pkg/repo" +) + +// Options describe one chart to fetch and how to reach the repository it comes +// from. They mirror repo.ChartResolution — the same settings nelm would be +// handed at apply time — plus the version and the OCI credentials file. +type Options struct { + // Ref is the chart as nelm would receive it: a bare chart name when RepoURL + // is set, an oci:// URL for a registry, a plain name otherwise. + Ref string + // Version is a version or constraint ("15.x"); empty means latest. + Version string + + // RepoURL is the helm chart-repository URL; empty for OCI. + RepoURL string + Username string + Password string + // PassCredentials forwards basic auth beyond the repository host. + PassCredentials bool + + SkipTLSVerify bool + CAFile string + CertFile string + KeyFile string + PlainHTTP bool + RequestTimeout time.Duration + + // ProvenanceStrategy / ProvenanceKeyring verify the chart's PGP signature + // while it is fetched. Empty strategy means "never", as in nelm. + ProvenanceStrategy string + ProvenanceKeyring string + + // RegistryConfigPath is a Docker config.json with OCI registry credentials; + // empty falls back to helm's default (~/.docker/config.json). + RegistryConfigPath string + + // Out receives the downloader's warnings; nil discards them. + Out io.Writer +} + +// Download fetches the chart into dir, creating dir if needed, and returns the +// path of the file it wrote (dir/-.tgz). With a provenance +// strategy set, the .prov file lands next to it. +func Download(o Options, dir string) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create chart dir %q: %w", dir, err) + } + // helm's index cache is written to unconditionally while a repository index + // is fetched, and it is not created on demand. + cacheDir := cli.EnvOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")) + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("create repository cache %q: %w", cacheDir, err) + } + + dl, ref, err := downloader(o, cacheDir) + if err != nil { + return "", err + } + path, _, err := dl.DownloadTo(ref, o.Version, dir) + if err != nil { + return "", fmt.Errorf("download chart %q: %w", ref, err) + } + return path, nil +} + +// downloader assembles helm's ChartDownloader for o and returns it together +// with the reference to hand DownloadTo. It mirrors nelm's own chart downloader +// (nelm/pkg/chart/chart_download.go), which is unexported. +func downloader(o Options, cacheDir string) (*helmdownloader.ChartDownloader, string, error) { + out := o.Out + if out == nil { + out = io.Discard + } + + regOpts := []helmregistry.ClientOption{ + helmregistry.ClientOptWriter(out), + helmregistry.ClientOptCredentialsFile(o.RegistryConfigPath), + } + if o.PlainHTTP { + regOpts = append(regOpts, helmregistry.ClientOptPlainHTTP()) + } + registryClient, err := helmregistry.NewClient(regOpts...) + if err != nil { + return nil, "", fmt.Errorf("construct registry client: %w", err) + } + + getters := helmgetter.Providers{helmgetter.HttpProvider, helmgetter.OCIProvider} + dl := &helmdownloader.ChartDownloader{ + Out: out, + // ToVerificationStrategy panics on anything it does not know, empty + // string included; nelm defaults it the same way. + Verify: verificationStrategy(o.ProvenanceStrategy), + Keyring: o.ProvenanceKeyring, + Getters: getters, + Options: []helmgetter.Option{ + helmgetter.WithPassCredentialsAll(o.PassCredentials), + helmgetter.WithTLSClientConfig(o.CertFile, o.KeyFile, o.CAFile), + helmgetter.WithInsecureSkipVerifyTLS(o.SkipTLSVerify), + helmgetter.WithPlainHTTP(o.PlainHTTP), + helmgetter.WithRegistryClient(registryClient), + helmgetter.WithTimeout(o.RequestTimeout), + }, + RegistryClient: registryClient, + RepositoryConfig: cli.EnvOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), + RepositoryCache: cacheDir, + } + + if o.RepoURL == "" { + dl.Options = append(dl.Options, helmgetter.WithBasicAuth(o.Username, o.Password)) + return dl, o.Ref, nil + } + + // A helm repository is addressed by name plus URL, so the chart's own URL + // has to come out of the repository index first. + chartURL, err := helmrepo.FindChartInAuthAndTLSAndPassRepoURL(o.RepoURL, o.Username, o.Password, + o.Ref, o.Version, o.CertFile, o.KeyFile, o.CAFile, o.SkipTLSVerify, o.PassCredentials, getters) + if err != nil { + return nil, "", fmt.Errorf("find chart %q in repository %q: %w", o.Ref, o.RepoURL, err) + } + + // Credentials follow the chart only while it stays on the repository's own + // host, unless passCredentials says otherwise: an index may well point at a + // third-party download URL. + if o.PassCredentials || sameHost(o.RepoURL, chartURL) { + dl.Options = append(dl.Options, helmgetter.WithBasicAuth(o.Username, o.Password)) + } else { + dl.Options = append(dl.Options, helmgetter.WithBasicAuth("", "")) + } + return dl, chartURL, nil +} + +// verificationStrategy maps a manifest provenance strategy onto helm's, turning +// the empty default into "never" rather than letting helm panic on it. +func verificationStrategy(strategy string) helmdownloader.VerificationStrategy { + if strategy == "" { + strategy = string(helmdownloader.VerificationStrategyStringNever) + } + return helmdownloader.VerificationStrategyString(strategy).ToVerificationStrategy() +} + +// sameHost reports whether two URLs share scheme and host. Unparsable input +// counts as "not the same host", the safe answer: credentials stay put. +func sameHost(a, b string) bool { + ua, err := url.Parse(a) + if err != nil { + return false + } + ub, err := url.Parse(b) + if err != nil { + return false + } + return ua.Scheme == ub.Scheme && ua.Host == ub.Host +} + +// IsRemote reports whether a chart reference has to be fetched, i.e. whether it +// is anything other than a filesystem path. It follows nelm's own rule +// (nelm/pkg/chart: isLocalChart): only an absolute path or one spelled +// "./"/"../" is local — "repo/chart" and "oci://..." are not. +func IsRemote(ref string) bool { + return !filepath.IsAbs(ref) && !strings.HasPrefix(ref, ".") +} diff --git a/internal/chart/download_test.go b/internal/chart/download_test.go new file mode 100644 index 0000000..5389438 --- /dev/null +++ b/internal/chart/download_test.go @@ -0,0 +1,55 @@ +package chart + +import ( + "testing" + + helmdownloader "github.com/werf/nelm/pkg/helm/pkg/downloader" +) + +func TestIsRemote(t *testing.T) { + // The rule is nelm's own: only a path spelled as one is local. A bare name + // and an alias/chart pair are repository references, not directories. + cases := map[string]bool{ + "bitnami/redis": true, + "redis": true, + "oci://ghcr.io/acme/redis": true, + "oci+http://reg:5000/x": true, + "./charts/mine": false, + "../mine": false, + ".": false, + "/srv/charts/mine": false, + } + for ref, want := range cases { + if got := IsRemote(ref); got != want { + t.Errorf("IsRemote(%q) = %v, want %v", ref, got, want) + } + } +} + +func TestVerificationStrategy_EmptyMeansNever(t *testing.T) { + // helm's own mapping panics on an unknown value, empty string included, and + // an unset provenanceStrategy is the common case. + if got := verificationStrategy(""); got != helmdownloader.VerifyNever { + t.Errorf("empty strategy = %v, want VerifyNever", got) + } + if got := verificationStrategy("always"); got != helmdownloader.VerifyAlways { + t.Errorf("always = %v, want VerifyAlways", got) + } +} + +func TestSameHost(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"https://charts.example.com", "https://charts.example.com/redis-1.0.0.tgz", true}, + {"https://charts.example.com", "https://cdn.example.com/redis-1.0.0.tgz", false}, + {"https://charts.example.com", "http://charts.example.com/redis-1.0.0.tgz", false}, + {"://nonsense", "https://charts.example.com", false}, + } + for _, c := range cases { + if got := sameHost(c.a, c.b); got != c.want { + t.Errorf("sameHost(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/cli/build.go b/internal/cli/build.go index c38e98d..478c676 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -20,8 +20,9 @@ import ( ) type buildOptions struct { - file string - output string + file string + output string + downloadCharts bool } func newBuildCommand(_ *globalOptions) *cobra.Command { @@ -37,6 +38,7 @@ It produces, under --output (default .nelmwave/): planfile.yml the resolved plan: releases, dependency edges, artifacts values//... values files, in merge order stores//... companion files declared in stores: + charts//... 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. Within a release, stores resolve @@ -44,6 +46,11 @@ first, then values; each resolved artifact is registered as a gomplate datasource ("stores/", "values/") that later *.tpl artifacts of the same release can pull in via ds/include. +--download-charts puts every chart into the build directory and points the plan +at it: remote charts are downloaded, local ones copied in, so up/down/diff need +nothing but .nelmwave/. Build where the registries are reachable, copy the +directory over, apply in isolation. + With no --file, build looks for nelmwave.yml.tpl and falls back to nelmwave.yml.`, Example: ` # Build from nelmwave.yml.tpl in the current directory nelmwave build @@ -52,13 +59,18 @@ With no --file, build looks for nelmwave.yml.tpl and falls back to nelmwave.yml. ENV=stg nelmwave build # Build a specific manifest into a specific directory - nelmwave build --file manifests/prod.yml.tpl --output .nelmwave-prod`, + nelmwave build --file manifests/prod.yml.tpl --output .nelmwave-prod + + # Self-contained plan: charts included, nothing to fetch at apply time + nelmwave build --download-charts`, RunE: func(cmd *cobra.Command, _ []string) error { return runBuild(cmd, o) }, } cmd.Flags().StringVar(&o.file, "file", "nelmwave.yml.tpl", "path to the nelmwave manifest (.tpl or plain yml)") cmd.Flags().StringVar(&o.output, "output", plan.DefaultDir, "directory for the built plan and artifacts") + cmd.Flags().BoolVar(&o.downloadCharts, "download-charts", false, + "put every chart into the build directory so up/down/diff need nothing else") return cmd } @@ -70,12 +82,14 @@ func runBuild(cmd *cobra.Command, o *buildOptions) error { if err != nil { return err } - return buildPlan(ctx, manifest, o.output, logger) + return buildPlan(ctx, manifest, o.output, o.downloadCharts, logger) } // buildPlan renders and validates manifest, resolves its datasources, and // writes the plan and artifacts to output. Shared by `build` and `up --build`. -func buildPlan(ctx context.Context, manifest, output string, logger *zap.Logger) error { +// With downloadCharts it also pulls every remote chart into output, making the +// plan applicable without network access. +func buildPlan(ctx context.Context, manifest, output string, downloadCharts bool, logger *zap.Logger) error { logger.Info("building", zap.String("file", manifest), zap.String("output", output)) src, err := os.ReadFile(manifest) @@ -111,6 +125,14 @@ func buildPlan(ctx context.Context, manifest, output string, logger *zap.Logger) return err } + // After the artifacts, before the planfile: downloading rewrites each + // release's chart reference, and the planfile has to record the result. + if downloadCharts { + if err := build.Charts(p, filepath.Dir(manifest), output, logger); err != nil { + return err + } + } + if err := p.Write(output); err != nil { return err } diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 7be5af6..2e39736 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path/filepath" "sort" "strings" @@ -411,6 +412,21 @@ func buildSpec(key string, rel plan.Release, repos map[string]config.Repository, } chart := repo.Resolve(rel.Chart.Name, repos) + chartRef, chartVersion := chart.Ref, rel.Chart.Version + + // A chart `build --download-charts` already fetched replaces the reference + // wholesale: the archive is the resolved chart, so nothing about the + // repository — URL, credentials, version constraint — applies any more, and + // nelm loads it straight off disk without touching a registry. + if rel.ChartFile != "" { + chartRef = filepath.Join(absOut, filepath.FromSlash(rel.ChartFile)) + if _, err := os.Stat(chartRef); err != nil { + return release.Spec{}, fmt.Errorf("release %q: chart archive %q from the plan is missing "+ + "(copy the whole build directory, or rebuild without --download-charts): %w", + key, rel.ChartFile, err) + } + chart, chartVersion = repo.ChartResolution{}, "" + } // Validated at build time, so a parse error here means someone hand-edited // the planfile. @@ -438,8 +454,8 @@ func buildSpec(key string, rel plan.Release, repos map[string]config.Repository, Namespace: namespace, KubeContext: kubeContext, Kube: o.kube, - Chart: chart.Ref, - ChartVersion: rel.Chart.Version, + Chart: chartRef, + ChartVersion: chartVersion, ValuesFiles: valuesFiles, SetJSON: setJSON, Timeout: timeout, diff --git a/internal/cli/deploy_test.go b/internal/cli/deploy_test.go index ed85f6f..276656c 100644 --- a/internal/cli/deploy_test.go +++ b/internal/cli/deploy_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "slices" "sync" "testing" @@ -344,6 +346,66 @@ func TestDeploy_ResourcePoliciesReachTheApplier(t *testing.T) { } } +// A chart downloaded by `build --download-charts` replaces the repository +// reference entirely: nelm gets an absolute path to the archive and nothing +// that would send it to a registry. +func TestDeploy_DownloadedChartIsAppliedFromTheBuildDirectory(t *testing.T) { + out := t.TempDir() + archive := filepath.Join(out, "charts", "acme_demo", "demo-1.2.3.tgz") + if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archive, []byte("tgz"), 0o644); err != nil { + t.Fatal(err) + } + + p := &plan.Plan{ + Repositories: map[string]config.Repository{"acme": {URL: "https://charts.example.com", Username: "u", Password: "p"}}, + Releases: map[string]plan.Release{ + "a@ns": { + Chart: config.Chart{Name: "acme/demo", Version: "1.2.3"}, + ChartFile: "charts/acme_demo/demo-1.2.3.tgz", + }, + }, + } + + f := &fakeApplier{} + o := opts("", false) + o.output = out + if err := deploy(context.Background(), zap.NewNop(), p, o, f, opInstall); err != nil { + t.Fatalf("deploy: %v", err) + } + got := f.specs["a"] + if got.Chart != archive { + t.Errorf("chart = %q, want the archive at %q", got.Chart, archive) + } + // The archive is already one version of one chart; a constraint or a repo + // URL alongside it could only send nelm looking for something else. + if got.ChartVersion != "" || got.RepoURL != "" || got.RepoUsername != "" { + t.Errorf("repository settings survived the local chart: %+v", got) + } +} + +// The planfile can travel without the rest of the build directory; say so +// rather than letting nelm report a missing chart named by a relative path. +func TestDeploy_MissingDownloadedChartIsReported(t *testing.T) { + p := &plan.Plan{ + Releases: map[string]plan.Release{ + "a@ns": {Chart: config.Chart{Name: "acme/demo"}, ChartFile: "charts/acme_demo/demo-1.2.3.tgz"}, + }, + } + f := &fakeApplier{} + o := opts("", false) + o.output = t.TempDir() + err := deploy(context.Background(), zap.NewNop(), p, o, f, opInstall) + if err == nil { + t.Fatal("expected a missing-archive error") + } + if len(f.installed) != 0 { + t.Errorf("nothing should have been installed, got %v", f.installed) + } +} + func TestDiff_RenderingOptionsReachTheApplier(t *testing.T) { o := opts("", false) o.diff = release.DiffOptions{ diff --git a/internal/cli/up.go b/internal/cli/up.go index 17df14c..fd72575 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -11,13 +11,14 @@ import ( ) type upOptions struct { - file string - output string - selector string - concurrency int - build bool - includeNeeds bool - dryRun bool + file string + output string + selector string + concurrency int + build bool + downloadCharts bool + includeNeeds bool + dryRun bool } func newUpCommand(g *globalOptions) *cobra.Command { @@ -45,6 +46,9 @@ back into the run.`, # Rebuild the plan and apply it, two releases at a time nelmwave up --build --concurrency 2 + # Rebuild with the charts included, then apply without reaching outside + nelmwave up --build --download-charts + # Preview instead of applying (same as nelmwave diff) nelmwave up --dry-run`, RunE: func(cmd *cobra.Command, _ []string) error { @@ -55,6 +59,7 @@ back into the run.`, f.StringVarP(&o.selector, "selector", "l", "", "k8s-style label selector to filter releases") f.IntVar(&o.concurrency, "concurrency", 0, "max releases to deploy in parallel (0 = unlimited)") f.BoolVar(&o.build, "build", false, "run build before up") + f.BoolVar(&o.downloadCharts, "download-charts", false, "with --build, put every chart into the build directory") f.BoolVar(&o.includeNeeds, "include-needs", false, "pull in needed releases even if filtered out") f.BoolVar(&o.dryRun, "dry-run", false, "plan instead of applying") f.StringVar(&o.output, "output", plan.DefaultDir, "directory of the built plan") @@ -71,7 +76,7 @@ func runUp(cmd *cobra.Command, g *globalOptions, o *upOptions) error { if err != nil { return err } - if err := buildPlan(ctx, manifest, o.output, logger); err != nil { + if err := buildPlan(ctx, manifest, o.output, o.downloadCharts, logger); err != nil { return err } } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 6a1c18f..16f12e0 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -28,6 +28,10 @@ const ( // namespace, so a path under .nelmwave/ and a `ds "stores/x"` reference read // the same way. StoreDir = "stores" + // ChartsDir holds chart archives downloaded by `build --download-charts`. + // Unlike values and stores it is keyed by chart, not by release: two + // releases of the same chart share one archive. + ChartsDir = "charts" ) // Plan is the flat, fully-resolved deployment plan persisted to disk. @@ -77,6 +81,12 @@ type Release struct { // precedence order (lowest first: global values, then per-release). They are // passed to nelm as-is; nelm performs the ordered Helm-style merge. ValuesFiles []string `yaml:"valuesFiles,omitempty"` + + // ChartFile is the plan-relative path to the chart archive `build + // --download-charts` fetched for this release. When set it replaces + // Chart.Name at apply time, so up/down/diff need no registry at all. Empty + // means the chart is still resolved against Repositories, as before. + ChartFile string `yaml:"chartFile,omitempty"` } // FromConfig projects a validated Config into a Plan. diff --git a/test/e2e/chartrepo_test.go b/test/e2e/chartrepo_test.go new file mode 100644 index 0000000..61020b1 --- /dev/null +++ b/test/e2e/chartrepo_test.go @@ -0,0 +1,114 @@ +//go:build e2e + +package e2e + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// serveChartRepo packages the local testdata chart and serves it as a helm +// repository, returning the repository URL and a func that stops the server. +// +// The suite still downloads nothing from the internet: this is the same chart +// TestLifecycle installs by path, just published. Stopping the server is the +// point — it is how a test proves that an apply reached for no repository. +func serveChartRepo(t *testing.T, chartDir, name, version string) (url string, stop func()) { + t.Helper() + archive := packageChart(t, chartDir, name, version) + digest := sha256.Sum256(archive) + + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + file := fmt.Sprintf("/%s-%s.tgz", name, version) + + mux.HandleFunc(file, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + }) + index := fmt.Sprintf(`apiVersion: v1 +generated: "2020-01-01T00:00:00Z" +entries: + %s: + - apiVersion: v2 + name: %s + version: %s + digest: %s + urls: + - %s%s +`, name, name, version, hex.EncodeToString(digest[:]), srv.URL, file) + mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(index)) + }) + + var stopped bool + return srv.URL, func() { + if !stopped { + stopped = true + srv.Close() + } + } +} + +// packageChart tars and gzips a chart directory the way `helm package` does: +// every file under a single top-level directory named after the chart. +func packageChart(t *testing.T, dir, name, version string) []byte { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "chart-*.tgz") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + hdr := &tar.Header{ + Name: filepath.ToSlash(filepath.Join(name, rel)), + Mode: 0o644, + Size: int64(len(data)), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err = tw.Write(data) + return err + }) + if err != nil { + t.Fatalf("package chart %q: %v", dir, err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 286607e..5a8fc85 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -4,7 +4,9 @@ // // The cluster is a k3s container owned by docker-compose (see // docker-compose.yml); the chart is a local one from testdata, so the suite -// downloads nothing and every assertion is about nelmwave's own behaviour. +// downloads nothing from the internet and every assertion is about nelmwave's +// own behaviour. The --download-charts test publishes that same chart through a +// repository it starts itself, and stops it before applying. // // docker-compose -f test/e2e/docker-compose.yml up -d --wait // KUBECONFIG=test/e2e/.kube/kubeconfig.yaml go test -tags e2e ./test/e2e/... @@ -142,6 +144,59 @@ func TestLifecycle(t *testing.T) { }) } +// TestChartsInTheBuildDirectory is the whole point of `--download-charts`: +// build while the chart sources are reachable, then take them away and still +// deploy. The repository is stopped between build and up, so a release that +// reached for it would fail rather than quietly succeed from some cache. The +// second release covers the other half — a local chart, copied in — and both +// come out of the same directory. +func TestChartsInTheBuildDirectory(t *testing.T) { + kubeconfig := requireKubeconfig(t) + clients := connect(t, kubeconfig) + ctx := context.Background() + + cleanNamespace(ctx, t, clients) + t.Cleanup(func() { cleanNamespace(context.Background(), t, clients) }) + + repoURL, stopRepo := serveChartRepo(t, abs(t, "testdata/chart"), "nelmwave-e2e", "0.1.0") + t.Cleanup(stopRepo) + + manifest := abs(t, "testdata/downloaded-chart.yml.tpl") + out := filepath.Join(t.TempDir(), ".nelmwave") + setEnv(t, map[string]string{ + "E2E_NS": namespace, + "E2E_CHART": abs(t, "testdata/chart"), + "E2E_MESSAGE": "from-the-build-directory", + "E2E_REPO_URL": repoURL, + }) + + run(t, "build", "--file", manifest, "--output", out, "--download-charts") + // The downloaded chart is an archive; the local one is the copied tree. + mustExist(t, filepath.Join(out, "charts", "e2e_nelmwave-e2e", "nelmwave-e2e-0.1.0.tgz")) + mustExist(t, filepath.Join(out, "charts", "chart", "Chart.yaml")) + mustExist(t, filepath.Join(out, "charts", "chart", "templates", "configmap.yaml")) + + // From here on there is no chart repository anywhere. + stopRepo() + + run(t, "up", "--output", out, "--kube-config", kubeconfig) + for _, name := range []string{"downloaded", "copied"} { + assertConfigMap(ctx, t, clients, name, "from-the-build-directory") + waitDeploymentReady(ctx, t, clients, name) + } + + // diff reads the same charts, so a plan is clean without the repository too. + err := execute(t, "diff", "--output", out, "--kube-config", kubeconfig, "--detailed-exitcode") + if code := cli.ExitCode(err); code != 0 { + t.Fatalf("diff right after up: exit %d, err %v; want a clean 0", code, err) + } + + run(t, "down", "--output", out, "--kube-config", kubeconfig) + for _, name := range []string{"downloaded", "copied"} { + waitGone(ctx, t, clients, name) + } +} + // TestRequiredNeedOutsideSelection covers the needs policy against a live // cluster: selecting only the dependent release must fail before anything is // applied, because its dependency is required and filtered out. diff --git a/test/e2e/testdata/downloaded-chart.yml.tpl b/test/e2e/testdata/downloaded-chart.yml.tpl new file mode 100644 index 0000000..a86ef84 --- /dev/null +++ b/test/e2e/testdata/downloaded-chart.yml.tpl @@ -0,0 +1,32 @@ +# Manifest for the --download-charts path, with one release per kind of chart: +# +# downloaded — a *remote* chart from a helm repository the test starts itself +# (E2E_REPO_URL) and then stops before `up`, so the release can +# only come from what build put in the build directory; +# copied — the same chart as a local path, which build copies in. +# +# Both must deploy from .nelmwave/ alone. +project: nelmwave-e2e-charts + +releases: + downloaded@[[ .Env.E2E_NS ]]: + labels: + app: downloaded + timeout: 3m + chart: + name: e2e/nelmwave-e2e + version: 0.1.0 + sets: + message: [[ .Env.E2E_MESSAGE ]] + + copied@[[ .Env.E2E_NS ]]: + labels: + app: copied + timeout: 3m + chart: + name: [[ .Env.E2E_CHART ]] + sets: + message: [[ .Env.E2E_MESSAGE ]] + +repositories: + e2e: [[ .Env.E2E_REPO_URL ]]