diff --git a/README.md b/README.md index 5b9ab47..a4212e7 100644 --- a/README.md +++ b/README.md @@ -275,15 +275,18 @@ The spec is a smaller surface than the Kubernetes API on purpose, so step 2 adds fields you did not write. You can read the result before anything is applied: ```sh -deployah plan --raw --yaml +deployah plan ``` -`deployah plan` renders the chart and compares it with the release on your -cluster, so it needs cluster access. `--raw` prints raw Kubernetes field paths -instead of the compact Deployah vocabulary, and `--yaml` shows changed fields -as YAML blocks. For the resolved hostname, TLS mode, context, and runtime -environment without a cluster, use `deployah resolve ` -(`--output json` for machine-readable output). +`deployah plan` renders the chart and compares it with the previous release +baseline selected by Helm, so it needs cluster access. Human output shows +resource changes as YAML, hook tasks, and a Chart CRDs section for Helm's +chart-CRD lifecycle. For an existing release it also compares that baseline +with live cluster state (drift). Secret values stay hidden unless you pass +`--show-secrets`, which reveals them in human output and in JSON. Use +`-o json` for machine-readable output. For the resolved hostname, TLS mode, +context, and runtime environment without a cluster, use +`deployah resolve ` (`--output json` for machine-readable output). For how Deployah compares to similar tools (DevSpace, Werf, Score, Epinio, Kubero), see [docs/comparison.md](docs/comparison.md). @@ -291,7 +294,7 @@ Kubero), see [docs/comparison.md](docs/comparison.md). ## What Deployah decides for you These are the defaults step 2 fills in. Most are overridable. -`deployah plan --raw` shows the rendered resources; `deployah resolve` shows +`deployah plan` shows the rendered resources; `deployah resolve` shows hostname, TLS, and resolved runtime environment. | Decision | Default | Set it with | @@ -423,7 +426,7 @@ These work with every command: | `deployah validate ` | Also load the platform file and check the resolved configuration for that environment. | | `deployah resolve ` | Preview the fully resolved hostname, TLS mode, context, and runtime environment, offline. Prints FileValues and ExplicitValues; do not treat the output as secret-safe CI output. Use `--output json` for machine-readable output. | | `deployah resolve --environments` | List every environment from both files: where it is registered, its context (or the kubeconfig fallback), domains, and overrides. | -| `deployah plan ` | Inspect changes for an environment, without applying anything. It needs cluster access. Extra manifests from `.deployah/manifests/` appear in the diff; CRD files from `.deployah/crds/` are listed with Helm's install-only lifecycle, not applied. Use `--raw` for raw Kubernetes field paths instead of the compact Deployah vocabulary, `--yaml` to show changed fields as YAML blocks, `--drift` to also compare against live cluster state, `--detailed-exitcode` to exit 2 when changes are pending, or `--output json` for CI. | +| `deployah plan ` | Inspect changes for an environment, without applying anything. It needs cluster access. It compares the chart with the previous release baseline selected by Helm. Extra manifests from `.deployah/manifests/` appear as resource changes. CRD files from `.deployah/crds/` appear in a Chart CRDs section with Helm's lifecycle, not as resource changes. For an existing release, drift against live cluster state is included. `--detailed-exitcode` exits 2 when the plan has effects and 0 when it does not (drift alone exits 0). `--show-secrets` reveals Secret values in human or JSON output. `-o json` writes JSON for CI. | | `deployah deploy ` | Deploy your project. Deployah validates the spec, runs deploy guards, then always runs a Helm install for a new release or a Helm upgrade for an existing one. Use `--skip-crds` to skip installing [chart CRDs](docs/custom-manifests-and-crds.md#helm-crd-lifecycle) on a fresh Helm install (a CRD added after that first install is not installed by a later deploy), `--explain` to print the resolution report first, `--force-hostname-change` to bypass the hostname guard, or `--resize-volumes` to grow [persistence](docs/workloads.md#growing-volumes) sizes. | | `deployah run ` | Run a spec task as a one-off Job. Wait is the default; `--detach` returns after create. `--count` / `--parallelism` override fanout for that run. | | `deployah status ` | Show the status of a deployed project. Use `--detailed` for pod details, `-e` for an environment. | diff --git a/docs/cli/deployah_plan.md b/docs/cli/deployah_plan.md index 9b8251f..eaa83df 100644 --- a/docs/cli/deployah_plan.md +++ b/docs/cli/deployah_plan.md @@ -4,7 +4,7 @@ Inspect changes for an environment ### Synopsis -Render the chart for an environment and compare it with the last successful Helm release. With --drift, also compare the rendered manifests with live cluster state. Plan is read-only and never applies anything. +Render the chart for an environment and compare it with the previous release baseline selected by Helm. For an existing release, also compare that baseline with live cluster state. Plan is read-only and never applies anything. ```text deployah plan [flags] @@ -13,12 +13,9 @@ deployah plan [flags] ### Options ```text - --detailed-exitcode Exit 2 when the plan has pending changes, 0 when it does not, 1 on error (for CI) - --drift Detect drift between the rendered manifests and the live cluster state - --output string Output format (default "text") - --raw Show raw Kubernetes field paths instead of the compact Deployah vocabulary - --show-secrets Reveal masked secret values in text output (requires an interactive terminal; refused with --output json) - --yaml Show changed fields as YAML blocks instead of a single line + --detailed-exitcode Exit 2 when the plan has effects (resource changes, tasks that change or run, chart CRDs Helm will process), 0 when it has none, 1 on error; drift alone exits 0 + -o, --output string Output format: human or json (default "human") + --show-secrets Reveal Kubernetes Secret data and stringData values in the selected output format (human or json); values are redacted by default ``` ### Options inherited from parent commands diff --git a/docs/custom-manifests-and-crds.md b/docs/custom-manifests-and-crds.md index f4ea344..8dd697c 100644 --- a/docs/custom-manifests-and-crds.md +++ b/docs/custom-manifests-and-crds.md @@ -138,12 +138,15 @@ later. ## Plan vs deploy -- `deployah plan` includes extra manifests in the rendered diff. It does - not apply CRDs. Chart CRDs appear as lifecycle entries with `kind` and - `metadata.name`. On a fresh install the plan shows each CRD document - Helm will process. It does not claim Kubernetes will create versus - apply the object. On upgrade, CRDs are listed as present in the chart - but not processed. +- `deployah plan` includes extra manifests as resource changes. It does + not apply CRDs. Chart CRDs appear in their own "Chart CRDs" section, + and in JSON under `chartCRDs`, with lifecycle `process` on a fresh + install or `upgrade` when Helm will not process them. The section does + not use resource actions (`+`, `~`, `-`). A chart CRD that Helm will + process counts as a pending effect for `--detailed-exitcode`. On a + fresh install the plan shows each CRD document Helm will process. It + does not claim Kubernetes will create versus apply the object. On + upgrade, CRDs are listed as present in the chart but not processed. - `deployah deploy` copies those CRD files into the generated chart, then runs Helm. On a fresh install Helm processes `crds/` before ordinary resources. On upgrade Helm leaves chart CRDs alone, including CRDs added diff --git a/docs/tasks.md b/docs/tasks.md index 9f72c64..8770fe2 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -166,7 +166,7 @@ count and cannot exceed 100000 (the Kubernetes Indexed Job limit). On a first install, `preDeploy` runs **before** Deployments and Services. Anything the task talks to (Postgres, RabbitMQ, another API) must already be reachable: another release, a managed service, or a job you ran first. -`deployah plan` prints this reminder on a fresh install. +`deployah plan` lists `preDeploy` tasks under Tasks. It does not check whether their dependencies are reachable. ## Logs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dab9a39..3def15d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -45,8 +45,9 @@ Hook timeout defaults to `5m` and must be less than the `--timeout` used for that deploy (default `10m`). Increase `--timeout` so it stays above every hook timeout. A spec may set a hook timeout longer than the default `10m`; deploy then needs a matching `--timeout`. Deployah does not raise the flag for you. -Serial hooks can add up to more than `--timeout`; plan shows each hook timeout -so you can see the budget. +Serial hooks can add up to more than `--timeout`. Plan shows a new or changed +hook Job, including its `activeDeadlineSeconds`. It does not list every hook +timeout. **A task did not run on deploy.** diff --git a/internal/cmd/deploy/deploy_test.go b/internal/cmd/deploy/deploy_test.go index e40b803..54bb90a 100644 --- a/internal/cmd/deploy/deploy_test.go +++ b/internal/cmd/deploy/deploy_test.go @@ -92,6 +92,10 @@ func (s *stubHelmClient) RenderManifests(context.Context, *spec.ResolvedSpec, po return s.renderResult, cleanup, nil } +func (s *stubHelmClient) RenderManifestsWithPrep(context.Context, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile) (*render.RenderResult, helm.ReleasePrep, func(), error) { + panic("unexpected RenderManifestsWithPrep call") +} + func (s *stubHelmClient) DeleteRelease(context.Context, string, string, bool) error { panic("unexpected DeleteRelease call") } diff --git a/internal/cmd/plan/doc.go b/internal/cmd/plan/doc.go index 7b2c1eb..7707ed2 100644 --- a/internal/cmd/plan/doc.go +++ b/internal/cmd/plan/doc.go @@ -14,12 +14,6 @@ // Package plan implements the deployah plan command. // -// It renders the chart for an environment and diffs it against the last -// successful release using [deployah.dev/deployah/internal/plan] as the diff -// engine. --detailed-exitcode returns -// [deployah.dev/deployah/internal/plan.ErrChangesPresent] on pending -// changes, so callers can tell "no changes" from "changes pending" from -// "error"; see [deployah.dev/deployah/internal/cmd.Execute]. -// -// Register the command with [Register] on a [nabat.dev/nabat.App] instance. +// [Register] adds the command to a [nabat.dev/nabat.App]. It writes a +// semantic plan for the environment and does not apply anything. package plan diff --git a/internal/cmd/plan/plan.go b/internal/cmd/plan/plan.go index fbeee11..e78b3d9 100644 --- a/internal/cmd/plan/plan.go +++ b/internal/cmd/plan/plan.go @@ -15,16 +15,20 @@ package plan import ( - "bytes" "errors" "fmt" - "strings" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" "nabat.dev/nabat" "deployah.dev/deployah/internal/cmd/cmdopts" - "deployah.dev/deployah/internal/drift" "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/plan/view" "deployah.dev/deployah/internal/session" "deployah.dev/deployah/internal/spec" @@ -32,70 +36,56 @@ import ( ) const ( - outputFormatText = "text" - outputFormatJSON = "json" + outputFormatHuman = "human" + outputFormatJSON = "json" ) // outputFormats lists the choices for --output, in help-text order. -var outputFormats = []string{outputFormatText, outputFormatJSON} +var outputFormats = []string{outputFormatHuman, outputFormatJSON} + +// ErrChangesPresent means --detailed-exitcode found effects. +// The root command maps it to exit code 2 and prints no banner. +var ErrChangesPresent = errors.New("plan has pending effects") // Options holds command-line flags for plan. type Options struct { Environment string `nabat:"environment"` - Drift bool `nabat:"drift"` ShowSecrets bool `nabat:"show-secrets"` - Raw bool `nabat:"raw"` - YAML bool `nabat:"yaml"` OutputFormat string `nabat:"output"` DetailedExitCode bool `nabat:"detailed-exitcode"` } +// clusterReadersFunc builds a REST mapper and a live reader from cfg. +// It does not list or get objects. +type clusterReadersFunc func( + cfg *rest.Config, +) (planengine.RESTMapper, planengine.LiveReader, error) + +// The Helm client from the session must be usable by the semantic builder. +var _ planengine.SemanticBuildClient = session.HelmClient(nil) + // Register adds the plan command to app. func Register(app *nabat.App) { app.MustCommand("plan", nabat.WithDescription("Inspect changes for an environment"), - nabat.WithLongDescription("Render the chart for an environment and compare it with the last successful Helm release. With --drift, also compare the rendered manifests with live cluster state. Plan is read-only and never applies anything."), + nabat.WithLongDescription("Render the chart for an environment and compare it with the previous release baseline selected by Helm. For an existing release, also compare that baseline with live cluster state. Plan is read-only and never applies anything."), nabat.WithArg("environment", "", nabat.WithRequired(), nabat.WithUsage("Environment to plan for"), nabat.WithPrompt("Environment", "", nabat.WithHint("e.g. prod, staging"))), - nabat.WithFlag("drift", false, nabat.WithUsage("Detect drift between the rendered manifests and the live cluster state")), - nabat.WithFlag("show-secrets", false, nabat.WithUsage("Reveal masked secret values in text output (requires an interactive terminal; refused with --output json)")), - nabat.WithFlag("raw", false, nabat.WithUsage("Show raw Kubernetes field paths instead of the compact Deployah vocabulary")), - nabat.WithFlag("yaml", false, nabat.WithUsage("Show changed fields as YAML blocks instead of a single line")), - nabat.WithSelectFlag("output", outputFormatText, outputFormats, nabat.WithUsage("Output format")), - nabat.WithFlag("detailed-exitcode", false, nabat.WithUsage("Exit 2 when the plan has pending changes, 0 when it does not, 1 on error (for CI)")), - nabat.WithValidation(validateOptions), + nabat.WithSelectFlag("output", outputFormatHuman, outputFormats, nabat.WithShort('o'), nabat.WithUsage("Output format: human or json")), + nabat.WithFlag("show-secrets", false, nabat.WithUsage("Reveal Kubernetes Secret data and stringData values in the selected output format (human or json); values are redacted by default")), + nabat.WithFlag("detailed-exitcode", false, nabat.WithUsage("Exit 2 when the plan has effects (resource changes, tasks that change or run, chart CRDs Helm will process), 0 when it has none, 1 on error; drift alone exits 0")), nabat.WithExample(` # Inspect changes for production deployah plan production # Machine-readable output for CI -deployah plan production --output json +deployah plan production -o json -# Gate a CI job on exit code 2 (pending changes) vs. 0 (no changes) +# Gate a CI job on exit code 2 (pending effects) vs. 0 (no effects) deployah plan production --detailed-exitcode`), nabat.WithRun(runPlan), ) } -// validateOptions rejects flag combinations that cannot both take effect, -// before runPlan does any work. -func validateOptions(c *nabat.Context) error { - opts := &Options{} - if err := c.Bind(opts); err != nil { - return fmt.Errorf("binding options: %w", err) - } - - if opts.Raw && opts.YAML { - return errors.New("--raw and --yaml cannot be used together") - } - if opts.ShowSecrets && opts.OutputFormat == outputFormatJSON { - return errors.New("--show-secrets cannot be used with --output json: JSON output always masks secrets") - } - if opts.ShowSecrets && !c.IsInteractive() { - return errors.New("--show-secrets requires an interactive terminal") - } - return nil -} - func runPlan(c *nabat.Context) error { opts := &Options{} if err := c.Bind(opts); err != nil { @@ -131,12 +121,11 @@ func runPlan(c *nabat.Context) error { return fmt.Errorf("resolution failed: %w", err) } - return executePlan(c, sess, platform, manifest, opts, resolvedSpec) + return executePlan(c, sess, platform, manifest, opts, resolvedSpec, newClusterReaders) } -// executePlan renders the chart, diffs it against the last successful -// release, and displays the resulting plan. -func executePlan(c *nabat.Context, sess *session.Session, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolvedSpec *spec.ResolvedSpec) error { +// executePlan builds and writes the plan for opts. +func executePlan(c *nabat.Context, sess *session.Session, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolvedSpec *spec.ResolvedSpec, newReaders clusterReadersFunc) error { cluster, err := sess.Target(c, opts.Environment) if err != nil { return fmt.Errorf("target cluster: %w", err) @@ -153,9 +142,8 @@ func executePlan(c *nabat.Context, sess *session.Session, platform *spec.Platfor cmdopts.WarnContextFallback(c, cluster, opts.Environment) - // Materialize self-signed TLS certs once, before rendering, matching - // deploy's determinism guarantee (a fresh keypair per render would make - // every plan show a phantom Secret change). + // Materialize self-signed TLS once, before render. A new keypair on + // every render would show up as a Secret change. k8sClient, k8sErr := cluster.Kubernetes() if k8sErr != nil { c.Logger().Debug("kubernetes client unavailable", "err", k8sErr) @@ -166,97 +154,75 @@ func executePlan(c *nabat.Context, sess *session.Session, platform *spec.Platfor } } + // Plan always needs a Kubernetes config. restCfg, restErr := cluster.RESTConfig() if restErr != nil { - c.Logger().Debug("rest config unavailable for extras scope discovery", "err", restErr) + return fmt.Errorf("kubernetes config: %w", restErr) } bundle, err := extras.LoadFromSpec(sess.Workspace().SpecPath(), manifest, platform, opts.Environment, cluster.Namespace(), restCfg) if err != nil { return fmt.Errorf("load extras: %w", err) } - postRenderer := bundle.PostRendererFor() - p, result, cleanup, err := planengine.BuildPlan(c, helmClient, cluster.Context(), resolvedSpec, postRenderer, bundle.CRDs) + mapper, live, err := newReaders(restCfg) + if err != nil { + return fmt.Errorf("cluster readers: %w%s", err, cmdopts.ClusterHint(err)) + } + + // Compare with the previous release baseline Helm selected. An existing + // release also compares that baseline with live objects. + p, _, cleanup, err := planengine.BuildSemanticPlan(c, helmClient, mapper, live, planengine.SemanticBuildInput{ + ClusterContext: cluster.Context(), + Resolved: resolvedSpec, + PostRenderer: bundle.PostRendererFor(), + CRDs: bundle.CRDs, + CRDDocs: bundle.CRDDocs, + SkipCRDs: false, // plan never asks Helm to skip chart CRDs + }) defer cleanup() if err != nil { return fmt.Errorf("%w%s", err, cmdopts.ClusterHint(err)) } - planengine.StampChartCRDs(p, bundle.CRDDocs, result.IsUpgrade, false) - - if opts.Drift { - if driftErr := checkDrift(c, cluster, p, result.Manifest); driftErr != nil { - return fmt.Errorf("check drift: %w%s", driftErr, cmdopts.ClusterHint(driftErr)) - } - } - - return outputPlan(c, p, opts) -} - -// checkDrift runs `--drift` detection against the resolved cluster and -// records the outcome directly on p (DriftChecked, Drift, DriftIncomplete), -// so it takes effect no matter which renderer outputPlan picks. On a fresh -// install there's no live release to compare against, so it reports that -// via c.Info (stderr, not the stdout diff body) and leaves DriftChecked -// false. -func checkDrift(c *nabat.Context, cluster *session.Cluster, p *planengine.Plan, currentManifest string) error { - if p.Header.FreshInstall { - c.Info("--drift is a no-op on a fresh install; there is no live release to compare against.") - return nil - } - - cfg, err := cluster.RESTConfig() + err = writePlan(c, p, opts) if err != nil { - return fmt.Errorf("kubernetes config: %w", err) + return err } - predictor, err := drift.NewClient(cfg) - if err != nil { - return fmt.Errorf("drift client: %w", err) - } - - result, err := drift.ComputeDrift(c, predictor, p, currentManifest) - if err != nil { - return fmt.Errorf("compute drift: %w", err) + // The plan is already written. Exit 2 is only a signal for CI. + if opts.DetailedExitCode && p.HasEffects() { + return ErrChangesPresent } - - p.DriftChecked = true - p.Drift = result.Changes - p.DriftIncomplete = result.Incomplete return nil } -func outputPlan(c *nabat.Context, p *planengine.Plan, opts *Options) error { +func writePlan(c *nabat.Context, p semantic.Plan, opts *Options) error { + // --show-secrets reveals values in human and JSON. + // Otherwise they stay redacted. + renderOpts := view.Options{ShowSecrets: opts.ShowSecrets} if opts.OutputFormat == outputFormatJSON { - var buf bytes.Buffer - if err := planengine.RenderJSON(&buf, p); err != nil { - return fmt.Errorf("render json: %w", err) - } - if err := c.FprintHighlight(c.IO().Out, strings.TrimRight(buf.String(), "\n"), "json"); err != nil { - return fmt.Errorf("write json: %w", err) - } - } else { - textOpts := planengine.TextOptions{ - Mode: textMode(opts), - ShowSecrets: opts.ShowSecrets, - Theme: c.Theme(), - } - if err := planengine.RenderText(c.IO().Out, p, textOpts); err != nil { - return fmt.Errorf("render text: %w", err) + if err := view.WriteJSON(c.IO().Out, p, renderOpts); err != nil { + return fmt.Errorf("write json plan: %w", err) } + return nil } - - if opts.DetailedExitCode && p.HasChanges() { - return planengine.ErrChangesPresent + renderOpts.Styler = nabatStyler{c: c} + if err := view.WriteHuman(c.IO().Out, p, renderOpts); err != nil { + return fmt.Errorf("write human plan: %w", err) } return nil } -func textMode(opts *Options) planengine.Mode { - switch { - case opts.Raw: - return planengine.ModeRaw - case opts.YAML: - return planengine.ModeYAML - default: - return planengine.ModeCompact +func newClusterReaders( + cfg *rest.Config, +) (planengine.RESTMapper, planengine.LiveReader, error) { + // Deferred discovery and the dynamic client send no requests here. + disco, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, nil, fmt.Errorf("discovery client: %w", err) + } + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(disco)) + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, nil, fmt.Errorf("dynamic client: %w", err) } + return mapper, planengine.NewDynamicLiveReader(dyn), nil } diff --git a/internal/cmd/plan/plan_test.go b/internal/cmd/plan/plan_test.go index fd2a269..8830c0d 100644 --- a/internal/cmd/plan/plan_test.go +++ b/internal/cmd/plan/plan_test.go @@ -15,21 +15,29 @@ package plan import ( + "bytes" "context" "encoding/json" + "errors" + "io" "os" "path/filepath" + "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/postrenderer" - "helm.sh/helm/v4/pkg/release/common" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" "deployah.dev/deployah/internal/extras" "deployah.dev/deployah/internal/helm" + "deployah.dev/deployah/internal/plan/view" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/session" "deployah.dev/deployah/internal/spec" @@ -38,36 +46,61 @@ import ( planengine "deployah.dev/deployah/internal/plan" v1 "helm.sh/helm/v4/pkg/release/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" ) +// planKubeconfig resolves a REST config without contacting a cluster. +const planKubeconfig = `apiVersion: v1 +kind: Config +current-context: test-context +clusters: +- name: test-cluster + cluster: + server: https://example.com:6443 +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-user +users: +- name: test-user + user: + token: fake-token +` + // stubHelmClient implements [session.HelmClient] for plan command tests. -// Only the methods executePlan actually calls are wired; every other method -// panics if invoked unexpectedly, matching the pattern in -// internal/cmd/deploy/deploy_test.go. +// RenderManifestsWithPrep is the only render path the command may call. type stubHelmClient struct { reachableErr error - renderResult *render.RenderResult - renderErr error + result *render.RenderResult + prep helm.ReleasePrep + renderErr error - history []*v1.Release - historyErr error + calls int + gotCRDs []extras.RawFile + gotPostRenderer postrenderer.PostRenderer } func (s *stubHelmClient) IsReachable() error { return s.reachableErr } -func (s *stubHelmClient) RenderManifests(context.Context, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile) (*render.RenderResult, func(), error) { +func (s *stubHelmClient) RenderManifestsWithPrep(_ context.Context, _ *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, helm.ReleasePrep, func(), error) { + s.calls++ + s.gotCRDs = crds + s.gotPostRenderer = postRenderer if s.renderErr != nil { - return nil, nil, s.renderErr + return nil, helm.ReleasePrep{}, nil, s.renderErr } - return s.renderResult, func() {}, nil + return s.result, s.prep, func() {}, nil +} + +func (s *stubHelmClient) RenderManifests(context.Context, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile) (*render.RenderResult, func(), error) { + panic("unexpected RenderManifests call") } func (s *stubHelmClient) GetReleaseHistory(context.Context, string, string) ([]*v1.Release, error) { - if s.historyErr != nil { - return nil, s.historyErr - } - return s.history, nil + panic("unexpected GetReleaseHistory call") } func (s *stubHelmClient) InstallApp(context.Context, bool, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile, bool) error { @@ -92,22 +125,135 @@ func (s *stubHelmClient) RollbackRelease(context.Context, string, int, time.Dura var _ session.HelmClient = (*stubHelmClient)(nil) -// sessionWithStub builds a [session.Session] whose Helm client is stub. -func sessionWithStub(stub *stubHelmClient) *session.Session { - return session.New(session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - })) +type fakeRESTMapper struct{} + +func (fakeRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + version := "" + if len(versions) > 0 { + version = versions[0] + } + scope := meta.RESTScopeNamespace + if gk.Kind == "Namespace" || gk.Kind == "CustomResourceDefinition" { + scope = meta.RESTScopeRoot + } + return &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: gk.Group, Version: version, Resource: strings.ToLower(gk.Kind) + "s"}, + GroupVersionKind: gk.WithVersion(version), + Scope: scope, + }, nil +} + +type fakeLive struct { + gets int + lists int + objs []*unstructured.Unstructured } -func releaseAt(version int, status common.Status, manifest string) *v1.Release { - return &v1.Release{ - Name: "web-production", - Version: version, - Manifest: manifest, - Info: &v1.Info{Status: status}, +func (f *fakeLive) Get(_ context.Context, mapping *meta.RESTMapping, _, name string) (*unstructured.Unstructured, error) { + f.gets++ + for _, obj := range f.objs { + if obj.GetName() == name && obj.GetKind() == mapping.GroupVersionKind.Kind { + return obj.DeepCopy(), nil + } } + gr := schema.GroupResource{Group: mapping.GroupVersionKind.Group, Resource: mapping.Resource.Resource} + return nil, apierrors.NewNotFound(gr, name) +} + +func (f *fakeLive) List(context.Context, *meta.RESTMapping, string, labels.Selector) ([]unstructured.Unstructured, error) { + f.lists++ + return nil, nil } +var _ planengine.LiveReader = (*fakeLive)(nil) + +type recordedReaders struct { + host string + calls int + err error + live planengine.LiveReader +} + +func (r *recordedReaders) build(cfg *rest.Config) (planengine.RESTMapper, planengine.LiveReader, error) { + r.calls++ + if cfg != nil { + r.host = cfg.Host + } + if r.err != nil { + return nil, nil, r.err + } + live := r.live + if live == nil { + live = &fakeLive{} + } + return fakeRESTMapper{}, live, nil +} + +func testResolved(m *spec.Spec) *spec.ResolvedSpec { + if m == nil { + m = &spec.Spec{Project: "web", APIVersion: spec.CurrentManifestVersion} + } + return &spec.ResolvedSpec{Spec: m, Env: spec.NormalizeEnv("production")} +} + +func installResult(manifest string) *render.RenderResult { + return &render.RenderResult{ + ReleaseName: "web-production", + Namespace: "default", + Manifest: manifest, + Revision: 1, + } +} + +func upgradeResult(manifest string, revision int) *render.RenderResult { + result := installResult(manifest) + result.IsUpgrade = true + result.Revision = revision + return result +} + +func installPrep() helm.ReleasePrep { + return helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1} +} + +func upgradePrep(manifest string, version int) helm.ReleasePrep { + rel := &v1.Release{ + Name: "web-production", + Namespace: "default", + Version: version, + Manifest: manifest, + } + return helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: rel, + Newest: rel, + NextRevision: version + 1, + } +} + +func mustObject(t *testing.T, manifest string) *unstructured.Unstructured { + t.Helper() + obj := &unstructured.Unstructured{} + require.NoError(t, yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096).Decode(obj)) + return obj +} + +func writeSpecDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "deployah.yaml"), []byte("apiVersion: deployah.dev/v1-alpha.4\nproject: web\n"), 0o600)) + return dir +} + +func writePlanExtras(t *testing.T, dir, relative, content string) { + t.Helper() + path := filepath.Join(dir, relative) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +const planCRDBody = "kind: CustomResourceDefinition\nmetadata:\n name: widgets.example.com\n" + const deploymentV1 = ` apiVersion: apps/v1 kind: Deployment @@ -138,6 +284,21 @@ spec: image: myapp:v1.3 ` +const deploymentLive = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: default +spec: + replicas: 9 + template: + spec: + containers: + - name: web + image: myapp:v1.2 +` + const configMap = ` apiVersion: v1 kind: ConfigMap @@ -158,350 +319,379 @@ data: password: b2xk ` -const secretV2 = ` -apiVersion: v1 -kind: Secret -metadata: - name: web-secret - namespace: default -data: - password: bmV3 -` - -func testManifest() *spec.Spec { - return &spec.Spec{Project: "web", APIVersion: spec.CurrentManifestVersion} +func migrateHook() *v1.Hook { + return &v1.Hook{ + Name: "migrate", + Kind: "Job", + Weight: 1, + Events: []v1.HookEvent{v1.HookPreInstall}, + Manifest: "apiVersion: batch/v1\nkind: Job\nmetadata:\n name: migrate\n namespace: default\n labels:\n " + + spec.LabelTask + ": migrate\n " + spec.LabelComponent + ": migrate\nspec:\n template:\n spec:\n containers:\n - name: job\n image: busybox\n", + } } -func testOptions() *Options { - return &Options{Environment: "production", OutputFormat: outputFormatText} +func taskResolved() *spec.ResolvedSpec { + resolved := testResolved(nil) + resolved.Tasks = map[string]spec.ResolvedTask{ + "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, + } + return resolved } -func testResolved(m *spec.Spec) *spec.ResolvedSpec { - if m == nil { - m = testManifest() - } - return &spec.ResolvedSpec{Spec: m, Env: spec.NormalizeEnv("production")} +type planFixture struct { + dir string + stub *stubHelmClient + opts *Options + resolved *spec.ResolvedSpec + live *fakeLive + readers *recordedReaders + kubeconfig string } -func renderResult(manifest string) *render.RenderResult { - return &render.RenderResult{ - ReleaseName: "web-production", - Namespace: "default", - Manifest: manifest, - Revision: 1, +func (f planFixture) run(t *testing.T) (stdout, stderr string, err error) { + t.Helper() + if f.dir == "" { + f.dir = writeSpecDir(t) + } + if f.opts == nil { + f.opts = &Options{Environment: "production", OutputFormat: outputFormatHuman} + } + if f.resolved == nil { + f.resolved = testResolved(nil) + } + if f.readers == nil { + f.readers = &recordedReaders{live: f.live} + } else if f.readers.live == nil { + f.readers.live = f.live } + kube := f.kubeconfig + if kube == "" { + kube = filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(kube, []byte(planKubeconfig), 0o600)) + } + sess := session.New( + session.WithSpecPath(filepath.Join(f.dir, "deployah.yaml")), + session.WithKubeconfig(kube), + session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { + return f.stub, nil + }), + ) + h := nabatctx.New(t, "test") + err = executePlan(h.Context, sess, nil, &spec.Spec{Project: "web", APIVersion: spec.CurrentManifestVersion}, f.opts, f.resolved, f.readers.build) + return h.Stdout.String(), h.Stderr.String(), err } -// TestExecutePlan covers the common plan paths: fresh install, image -// bump, add/remove, no-op, detailed-exitcode, secret masking, and failed- -// latest-revision warnings. Drift stream discipline keeps its own test -// because it needs different IO wiring. -func TestExecutePlan(t *testing.T) { +func TestExecutePlan_Human(t *testing.T) { t.Parallel() - tests := []struct { name string stub *stubHelmClient - detailed bool - wantErrIs error + live *fakeLive contains []string notContains []string }{ { name: "fresh install", stub: &stubHelmClient{ - historyErr: helm.ErrReleaseNotFound, - renderResult: renderResult(deploymentV1 + "---\n" + configMap), + result: installResult(deploymentV1 + "---\n" + configMap), + prep: installPrep(), }, contains: []string{ - "(fresh install)", - "+ Deployment/web", - "+ ConfigMap/web-config", - "Plan: 2 to add, 0 to change, 0 to destroy.", + `+ create apps/v1/Deployment "web"`, + `+ create v1/ConfigMap "web-config"`, + "Resources: 2 create, 0 update, 0 delete", }, }, { name: "image bump", stub: &stubHelmClient{ - history: []*v1.Release{releaseAt(7, common.StatusDeployed, deploymentV1)}, - renderResult: renderResult(deploymentV2), - }, - contains: []string{ - "(revision 7)", - "~ Deployment/web", - "myapp:v1.2 -> myapp:v1.3", - "Plan: 0 to add, 1 to change, 0 to destroy.", + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + contains: []string{`~ update apps/v1/Deployment "web"`, "myapp:v1.2", "myapp:v1.3"}, }, { name: "resource added and removed", stub: &stubHelmClient{ - history: []*v1.Release{releaseAt(3, common.StatusDeployed, deploymentV1+"---\n"+secretV1)}, - renderResult: renderResult(deploymentV1 + "---\n" + configMap), + result: upgradeResult(deploymentV1+"---\n"+configMap, 4), + prep: upgradePrep(deploymentV1+"---\n"+secretV1, 3), }, + live: &fakeLive{objs: []*unstructured.Unstructured{ + mustObject(t, deploymentV1), + mustObject(t, secretV1), + }}, contains: []string{ - "+ ConfigMap/web-config", - "- Secret/web-secret", - "Plan: 1 to add, 0 to change, 1 to destroy.", - }, - }, - { - name: "no changes with detailed-exitcode stays success", - stub: &stubHelmClient{ - history: []*v1.Release{releaseAt(4, common.StatusDeployed, deploymentV1)}, - renderResult: renderResult(deploymentV1), + `+ create v1/ConfigMap "web-config"`, + `- delete v1/Secret "web-secret"`, }, - detailed: true, - contains: []string{"No changes."}, }, { - name: "detailed-exitcode returns ErrChangesPresent", + name: "no changes", stub: &stubHelmClient{ - history: []*v1.Release{releaseAt(1, common.StatusDeployed, deploymentV1)}, - renderResult: renderResult(deploymentV2), + result: upgradeResult(deploymentV1, 5), + prep: upgradePrep(deploymentV1, 4), }, - detailed: true, - wantErrIs: planengine.ErrChangesPresent, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + contains: []string{"Resources: 0 create, 0 update, 0 delete"}, }, { name: "masked secret hides values", stub: &stubHelmClient{ - history: []*v1.Release{releaseAt(2, common.StatusDeployed, secretV1)}, - renderResult: renderResult(secretV2), - }, - contains: []string{"(masked) changed"}, - notContains: []string{"b2xk", "bmV3"}, - }, - { - name: "failed latest revision surfaces warning", - stub: &stubHelmClient{ - history: []*v1.Release{ - releaseAt(1, common.StatusDeployed, deploymentV1), - releaseAt(2, common.StatusFailed, deploymentV2), - }, - renderResult: renderResult(deploymentV2), + result: installResult(secretV1), + prep: installPrep(), }, - contains: []string{"Warning:", "revision 2", "(revision 1)"}, + contains: []string{"(redacted)"}, + notContains: []string{"b2xk"}, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - sess := sessionWithStub(tt.stub) - h := nabatctx.New(t, "test") - opts := testOptions() - opts.DetailedExitCode = tt.detailed - - err := executePlan(h.Context, sess, nil, testManifest(), opts, testResolved(nil)) - if tt.wantErrIs != nil { - require.Error(t, err) - assert.ErrorIs(t, err, tt.wantErrIs) - return - } + stdout, _, err := (planFixture{stub: tt.stub, live: tt.live}).run(t) require.NoError(t, err) - got := h.Stdout.String() + assert.NotContains(t, stdout, "\x1b") for _, s := range tt.contains { - assert.Contains(t, got, s) + assert.Contains(t, stdout, s) } for _, s := range tt.notContains { - assert.NotContains(t, got, s) + assert.NotContains(t, stdout, s) } }) } } -// TestExecutePlan_DriftOnFreshInstall_NoStdoutFootprint verifies --drift on a -// fresh install prints its explanation to stderr (via checkDrift's -// c.Info), not stdout, and never touches the cluster's REST config. -func TestExecutePlan_DriftOnFreshInstall_NoStdoutFootprint(t *testing.T) { +func TestExecutePlan_Tasks(t *testing.T) { t.Parallel() - stub := &stubHelmClient{ - historyErr: helm.ErrReleaseNotFound, - renderResult: renderResult(deploymentV1), - } - sess := sessionWithStub(stub) - - h := nabatctx.New(t, "test") - - opts := testOptions() - opts.Drift = true - err := executePlan(h.Context, sess, nil, testManifest(), opts, testResolved(nil)) - require.NoError(t, err, "checkDrift must short-circuit cleanly without a working cluster config") - - assert.NotContains(t, h.Stdout.String(), "Drift (cluster changed outside deployah):", - "a fresh install must not grow a stdout Drift section") - assert.NotContains(t, h.Stdout.String(), "no-op on a fresh install", - "the explanation must not appear in the captured diff body") - assert.Contains(t, h.Stderr.String(), "no-op on a fresh install", - "the explanation belongs on stderr, via c.Info") + result := installResult("") + result.Hooks = []*v1.Hook{migrateHook()} + stdout, _, err := (planFixture{ + stub: &stubHelmClient{result: result, prep: installPrep()}, + resolved: taskResolved(), + }).run(t) + require.NoError(t, err) + assert.NotContains(t, stdout, "\x1b") + assert.Contains(t, stdout, "Tasks") + assert.Contains(t, stdout, "preDeploy") } -func writePlanExtras(t *testing.T, dir, relative, content string) { - t.Helper() - path := filepath.Join(dir, relative) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +func TestExecutePlan_FreshInstallDoesNotReadLive(t *testing.T) { + t.Parallel() + live := &fakeLive{} + stdout, _, err := (planFixture{ + stub: &stubHelmClient{ + result: installResult(deploymentV1), + prep: installPrep(), + }, + live: live, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON}, + }).run(t) + require.NoError(t, err) + assert.Zero(t, live.gets) + assert.Zero(t, live.lists) + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Empty(t, doc["drift"]) } -func writePlanCRDFile(t *testing.T, dir, name, body string) { - t.Helper() - writePlanExtras(t, dir, filepath.Join(".deployah", "crds", name), body) +func TestExecutePlan_DriftOnExistingRelease(t *testing.T) { + t.Parallel() + live := &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentLive)}} + stdout, _, err := (planFixture{ + stub: &stubHelmClient{ + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), + }, + live: live, + }).run(t) + require.NoError(t, err) + assert.Positive(t, live.gets) + assert.Contains(t, stdout, "Drift") + assert.Contains(t, stdout, "Drift: ") } -func TestExecutePlan_LoadExtrasError(t *testing.T) { +func TestExecutePlan_DriftOnExistingRelease_JSON(t *testing.T) { t.Parallel() - dir := t.TempDir() - specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") - writePlanExtras(t, dir, ".deployah/manifests/bad.yaml", "not: [valid") - stub := &stubHelmClient{renderResult: renderResult(deploymentV1)} - sess := session.New( - session.WithSpecPath(specPath), - session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - }), - ) - h := nabatctx.New(t, "test") + live := &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentLive)}} + stdout, _, err := (planFixture{ + stub: &stubHelmClient{ + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), + }, + live: live, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON}, + }).run(t) + require.NoError(t, err) + assert.Positive(t, live.gets) + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + drift, ok := doc["drift"].([]any) + require.True(t, ok) + assert.NotEmpty(t, drift) +} - err := executePlan(h.Context, sess, nil, testManifest(), testOptions(), testResolved(nil)) - require.Error(t, err) - assert.ErrorContains(t, err, "load extras") +func TestExecutePlan_DriftOnly(t *testing.T) { + t.Parallel() + stdout, _, err := (planFixture{ + stub: &stubHelmClient{ + result: upgradeResult(deploymentV1, 5), + prep: upgradePrep(deploymentV1, 4), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentLive)}}, + }).run(t) + require.NoError(t, err) + assert.Contains(t, stdout, "Drift") } -const planCRDBody = "kind: CustomResourceDefinition\nmetadata:\n name: widgets.example.com\n" +func TestExecutePlan_DriftOnly_JSON(t *testing.T) { + t.Parallel() + stdout, _, err := (planFixture{ + stub: &stubHelmClient{ + result: upgradeResult(deploymentV1, 5), + prep: upgradePrep(deploymentV1, 4), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentLive)}}, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON, DetailedExitCode: true}, + }).run(t) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "none", doc["helmAction"]) + assert.Empty(t, doc["changes"]) + drift, ok := doc["drift"].([]any) + require.True(t, ok) + assert.NotEmpty(t, drift) +} -func TestExecutePlan_PrintsCRDs(t *testing.T) { +func TestExecutePlan_JSONIsWriterBytes(t *testing.T) { t.Parallel() - dir := t.TempDir() - specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") - writePlanCRDFile(t, dir, "widget.yaml", planCRDBody) stub := &stubHelmClient{ - historyErr: helm.ErrReleaseNotFound, - renderResult: renderResult(deploymentV1), + result: installResult(deploymentV1), + prep: installPrep(), } - sess := session.New( - session.WithSpecPath(specPath), - session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - }), - ) - h := nabatctx.New(t, "test") - h.Context.SetContext(session.WithContext(h.Context.Context(), sess)) - - err := executePlan(h.Context, sess, nil, testManifest(), testOptions(), testResolved(nil)) + live := &fakeLive{} + dir := writeSpecDir(t) + readers := &recordedReaders{live: live} + stdout, _, err := (planFixture{ + dir: dir, + stub: stub, + live: live, + readers: readers, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON}, + }).run(t) require.NoError(t, err) - got := h.Stdout.String() - assert.Contains(t, got, "+ CustomResourceDefinition/widgets.example.com") - assert.Contains(t, got, "Helm install will process this chart CRD") - assert.Contains(t, got, "name: widgets.example.com") - assert.NotContains(t, got, "CRD files to process") + assert.NotContains(t, stdout, "\x1b") + assert.Equal(t, "https://example.com:6443", readers.host) + + wantPlan, _, cleanup, buildErr := planengine.BuildSemanticPlan(t.Context(), stub, fakeRESTMapper{}, live, planengine.SemanticBuildInput{ + ClusterContext: "test-context", + Resolved: testResolved(nil), + SkipCRDs: false, + }) + t.Cleanup(cleanup) + require.NoError(t, buildErr) + var want bytes.Buffer + require.NoError(t, view.WriteJSON(&want, wantPlan, view.Options{})) + assert.Equal(t, want.String(), stdout) + + dec := json.NewDecoder(strings.NewReader(stdout)) + var doc map[string]any + require.NoError(t, dec.Decode(&doc)) + var extra any + assert.ErrorIs(t, dec.Decode(&extra), io.EOF) + for _, key := range []string{"schema", "header", "helmAction", "changes", "drift", "tasks", "chartCRDs", "summary"} { + assert.Contains(t, doc, key) + } + assert.NotContains(t, doc, "chart_crds") + assert.NotContains(t, doc, "first_install_note") } -func TestExecutePlan_PrintsCRDsOnUpgrade(t *testing.T) { +func TestExecutePlan_ChartCRDsHuman(t *testing.T) { t.Parallel() tests := []struct { - name string - file string - history []*v1.Release + name string + result *render.RenderResult + prep helm.ReleasePrep + live *fakeLive + contains string }{ { - name: "existing file", - file: "widget.yaml", - history: []*v1.Release{releaseAt(3, common.StatusDeployed, deploymentV1)}, + name: "fresh install", + result: installResult(deploymentV1), + prep: installPrep(), + contains: "lifecycle: process (Helm install will process this chart CRD)", }, { - name: "newly added file", - file: "new.yaml", - history: []*v1.Release{releaseAt(3, common.StatusDeployed, deploymentV1)}, - }, - { - name: "failed-only history", - file: "widget.yaml", - history: []*v1.Release{releaseAt(1, common.StatusFailed, deploymentV1)}, + name: "upgrade", + result: upgradeResult(deploymentV2, 4), + prep: upgradePrep(deploymentV1, 3), + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + contains: "lifecycle: upgrade (Helm upgrade does not process chart CRDs)", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - dir := t.TempDir() - specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") - writePlanCRDFile(t, dir, tc.file, planCRDBody) - result := renderResult(deploymentV1) - result.IsUpgrade = true - stub := &stubHelmClient{ - history: tc.history, - renderResult: result, - } - sess := session.New( - session.WithSpecPath(specPath), - session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - }), - ) - h := nabatctx.New(t, "test") - h.Context.SetContext(session.WithContext(h.Context.Context(), sess)) - - err := executePlan(h.Context, sess, nil, testManifest(), testOptions(), testResolved(nil)) + dir := writeSpecDir(t) + writePlanExtras(t, dir, ".deployah/crds/widget.yaml", planCRDBody) + stdout, _, err := (planFixture{ + dir: dir, + stub: &stubHelmClient{result: tc.result, prep: tc.prep}, + live: tc.live, + }).run(t) require.NoError(t, err) - got := h.Stdout.String() - assert.Contains(t, got, "CustomResourceDefinition/widgets.example.com") - assert.Contains(t, got, "Helm upgrade will not process this chart CRD") - assert.NotContains(t, got, "+ CustomResourceDefinition/") - assert.NotContains(t, got, "CRD files to process on install:") + assert.Contains(t, stdout, tc.contains) + resources := strings.Index(stdout, "Resources") + crds := strings.Index(stdout, "Chart CRDs") + summary := strings.Index(stdout, "Summary") + assert.GreaterOrEqual(t, resources, 0) + assert.Greater(t, crds, resources) + assert.Greater(t, summary, crds) + assert.NotContains(t, stdout, "+ CustomResourceDefinition") }) } } -func TestExecutePlan_JSONStdoutUnmarshalsWithCRDs(t *testing.T) { +func TestExecutePlan_ChartCRDsJSON(t *testing.T) { t.Parallel() tests := []struct { name string - upgrade bool + result *render.RenderResult + prep helm.ReleasePrep + live *fakeLive wantLife string wantProcess bool }{ - {name: "fresh install", wantLife: "process", wantProcess: true}, - {name: "upgrade", upgrade: true, wantLife: "upgrade"}, + { + name: "fresh install", + result: installResult(deploymentV1), + prep: installPrep(), + wantLife: "process", + wantProcess: true, + }, + { + name: "upgrade", + result: upgradeResult(deploymentV1, 4), + prep: upgradePrep(deploymentV1, 3), + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + wantLife: "upgrade", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - dir := t.TempDir() - specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") - writePlanCRDFile(t, dir, "widget.yaml", planCRDBody) - result := renderResult(deploymentV1) - stub := &stubHelmClient{renderResult: result} - if tc.upgrade { - result.IsUpgrade = true - stub.history = []*v1.Release{releaseAt(3, common.StatusDeployed, deploymentV1)} - } else { - stub.historyErr = helm.ErrReleaseNotFound - } - sess := session.New( - session.WithSpecPath(specPath), - session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - }), - ) - h := nabatctx.New(t, "test") - h.Context.SetContext(session.WithContext(h.Context.Context(), sess)) - opts := testOptions() - opts.OutputFormat = outputFormatJSON - - err := executePlan(h.Context, sess, nil, testManifest(), opts, testResolved(nil)) + dir := writeSpecDir(t) + writePlanExtras(t, dir, ".deployah/crds/widget.yaml", planCRDBody) + stdout, _, err := (planFixture{ + dir: dir, + stub: &stubHelmClient{result: tc.result, prep: tc.prep}, + live: tc.live, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON}, + }).run(t) require.NoError(t, err) - stdout := h.Stdout.String() var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(stdout), &doc), "stdout must be a single JSON document") - assert.NotContains(t, stdout, "Helm install will process this chart CRD") - assert.NotContains(t, stdout, "Helm upgrade will not process this chart CRD") - assert.NotContains(t, stdout, "CRD files to process") - assert.NotContains(t, h.Stderr.String(), "Helm install will process this chart CRD") - crds, ok := doc["chart_crds"].([]any) + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + crds, ok := doc["chartCRDs"].([]any) require.True(t, ok) require.Len(t, crds, 1) entry, ok := crds[0].(map[string]any) @@ -509,91 +699,286 @@ func TestExecutePlan_JSONStdoutUnmarshalsWithCRDs(t *testing.T) { assert.Equal(t, "CustomResourceDefinition", entry["kind"]) assert.Equal(t, "widgets.example.com", entry["name"]) assert.Equal(t, tc.wantLife, entry["lifecycle"]) - assert.Equal(t, tc.wantProcess, entry["will_process"]) - assert.NotContains(t, entry, "action") - assert.NotContains(t, entry, "api_version") + assert.Equal(t, tc.wantProcess, entry["willProcess"]) }) } } -func TestOutputPlan_JSONSkipCRDsStdoutUnmarshals(t *testing.T) { +func TestExecutePlan_ForwardsCRDsAndPostRendererOnce(t *testing.T) { t.Parallel() - p := &planengine.Plan{Header: planengine.Header{Project: "web", FreshInstall: true}} - planengine.StampChartCRDs(p, []extras.CRDDoc{{ - Path: "widget.yaml", - Kind: "CustomResourceDefinition", - Name: "widgets.example.com", - YAML: []byte(planCRDBody), - }}, false, true) - h := nabatctx.New(t, "test") - opts := testOptions() - opts.OutputFormat = outputFormatJSON - opts.DetailedExitCode = true + dir := writeSpecDir(t) + writePlanExtras(t, dir, ".deployah/crds/widget.yaml", planCRDBody) + writePlanExtras(t, dir, ".deployah/manifests/extra.yaml", "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: extra\ndata:\n key: value\n") + // A closed port makes scope discovery fail fast and fall back to the + // built-in table. example.com:6443 would wait out a dial timeout. + kube := filepath.Join(t.TempDir(), "kubeconfig") + closed := strings.Replace(planKubeconfig, "https://example.com:6443", "https://127.0.0.1:1", 1) + require.NoError(t, os.WriteFile(kube, []byte(closed), 0o600)) + stub := &stubHelmClient{result: installResult(deploymentV1), prep: installPrep()} + _, _, err := (planFixture{dir: dir, stub: stub, kubeconfig: kube}).run(t) + require.NoError(t, err) + assert.Equal(t, 1, stub.calls) + require.Len(t, stub.gotCRDs, 1) + assert.NotNil(t, stub.gotPostRenderer) +} + +func TestExecutePlan_ShowSecrets(t *testing.T) { + t.Parallel() + tests := []struct { + name string + format string + reveal bool + contains []string + notContains []string + }{ + { + name: "human default", + format: outputFormatHuman, + contains: []string{"(redacted)"}, + notContains: []string{"b2xk"}, + }, + { + name: "human reveal", + format: outputFormatHuman, + reveal: true, + contains: []string{"b2xk"}, + }, + { + name: "json default", + format: outputFormatJSON, + contains: []string{"(redacted)"}, + notContains: []string{"b2xk", "\x1b"}, + }, + { + name: "json reveal", + format: outputFormatJSON, + reveal: true, + contains: []string{"b2xk"}, + notContains: []string{"\x1b"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, _, err := (planFixture{ + stub: &stubHelmClient{result: installResult(secretV1), prep: installPrep()}, + opts: &Options{Environment: "production", OutputFormat: tc.format, ShowSecrets: tc.reveal}, + }).run(t) + require.NoError(t, err) + if tc.format == outputFormatJSON { + require.NoError(t, json.Unmarshal([]byte(stdout), &map[string]any{})) + } + for _, s := range tc.contains { + assert.Contains(t, stdout, s) + } + for _, s := range tc.notContains { + assert.NotContains(t, stdout, s) + } + }) + } - err := outputPlan(h.Context, p, opts) + hidden, _, err := (planFixture{ + stub: &stubHelmClient{result: installResult(secretV1), prep: installPrep()}, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON}, + }).run(t) require.NoError(t, err) - stdout := h.Stdout.String() - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(stdout), &doc), "stdout must be a single JSON document") - assert.NotContains(t, stdout, "install-time CRD processing disabled") - assert.Empty(t, h.Stderr.String()) - crds, ok := doc["chart_crds"].([]any) - require.True(t, ok) - require.Len(t, crds, 1) - entry, ok := crds[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "CustomResourceDefinition", entry["kind"]) - assert.Equal(t, "widgets.example.com", entry["name"]) - assert.Equal(t, "skip", entry["lifecycle"]) - assert.Equal(t, false, entry["will_process"]) - assert.NotContains(t, entry, "api_version") + shown, _, err := (planFixture{ + stub: &stubHelmClient{result: installResult(secretV1), prep: installPrep()}, + opts: &Options{Environment: "production", OutputFormat: outputFormatJSON, ShowSecrets: true}, + }).run(t) + require.NoError(t, err) + assert.JSONEq(t, hidden, strings.ReplaceAll(shown, "b2xk", "(redacted)")) +} + +func TestShowSecrets_PresentationOnly(t *testing.T) { + t.Parallel() + stub := &stubHelmClient{result: installResult(secretV1), prep: installPrep()} + p, _, cleanup, err := planengine.BuildSemanticPlan(t.Context(), stub, fakeRESTMapper{}, nil, planengine.SemanticBuildInput{ + ClusterContext: "test-context", + Resolved: testResolved(nil), + }) + t.Cleanup(cleanup) + require.NoError(t, err) + before, err := json.Marshal(p) + require.NoError(t, err) + + var hidden, shown bytes.Buffer + require.NoError(t, view.WriteHuman(&hidden, p, view.Options{})) + require.NoError(t, view.WriteHuman(&shown, p, view.Options{ShowSecrets: true})) + require.NoError(t, view.WriteJSON(&hidden, p, view.Options{})) + require.NoError(t, view.WriteJSON(&shown, p, view.Options{ShowSecrets: true})) + + after, err := json.Marshal(p) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.NotContains(t, hidden.String(), "b2xk") + assert.Contains(t, shown.String(), "b2xk") } -func TestExecutePlan_DetailedExitCode_ChartCRDs(t *testing.T) { +func TestExecutePlan_DetailedExitCode(t *testing.T) { t.Parallel() + taskResult := installResult("") + taskResult.Hooks = []*v1.Hook{migrateHook()} tests := []struct { name string - upgrade bool - current string - previous string - wantErrIs error + stub *stubHelmClient + live *fakeLive + resolved *spec.ResolvedSpec + dirCRD bool + format string + wantErr error + wantPlain string + contains []string + off bool }{ - {name: "fresh install only crds", current: "", wantErrIs: planengine.ErrChangesPresent}, - {name: "upgrade only crds", upgrade: true, current: deploymentV1, previous: deploymentV1}, - {name: "upgrade with resource change", upgrade: true, current: deploymentV2, previous: deploymentV1, wantErrIs: planengine.ErrChangesPresent}, - {name: "fresh with resources and crds", current: deploymentV1, wantErrIs: planengine.ErrChangesPresent}, + { + name: "resource change", + stub: &stubHelmClient{ + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + wantErr: ErrChangesPresent, + contains: []string{`~ update apps/v1/Deployment "web"`, "Summary"}, + }, + { + name: "task only", + stub: &stubHelmClient{result: taskResult, prep: installPrep()}, + resolved: taskResolved(), + wantErr: ErrChangesPresent, + }, + { + name: "chart crd process", + stub: &stubHelmClient{result: installResult(""), prep: installPrep()}, + dirCRD: true, + wantErr: ErrChangesPresent, + }, + { + name: "no changes", + stub: &stubHelmClient{ + result: upgradeResult(deploymentV1, 5), + prep: upgradePrep(deploymentV1, 4), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + }, + { + name: "drift only", + stub: &stubHelmClient{ + result: upgradeResult(deploymentV1, 5), + prep: upgradePrep(deploymentV1, 4), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentLive)}}, + }, + { + name: "chart crd upgrade", + stub: &stubHelmClient{ + result: upgradeResult("", 2), + prep: upgradePrep("", 1), + }, + dirCRD: true, + live: &fakeLive{}, + }, + { + name: "render error", + stub: &stubHelmClient{ + result: installResult(deploymentV1), + prep: installPrep(), + renderErr: errors.New("render broke"), + }, + wantPlain: "render manifests", + }, + { + name: "json resource change", + format: outputFormatJSON, + stub: &stubHelmClient{ + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + wantErr: ErrChangesPresent, + }, + { + name: "flag off ignores effects", + stub: &stubHelmClient{ + result: upgradeResult(deploymentV2, 8), + prep: upgradePrep(deploymentV1, 7), + }, + live: &fakeLive{objs: []*unstructured.Unstructured{mustObject(t, deploymentV1)}}, + off: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - dir := t.TempDir() - specPath := filepath.Join(dir, "deployah.yaml") - writePlanExtras(t, dir, "deployah.yaml", "apiVersion: deployah.dev/v1-alpha.4\nproject: web\n") - writePlanCRDFile(t, dir, "widget.yaml", planCRDBody) - result := renderResult(tc.current) - stub := &stubHelmClient{renderResult: result} - if tc.upgrade { - result.IsUpgrade = true - stub.history = []*v1.Release{releaseAt(3, common.StatusDeployed, tc.previous)} - } else { - stub.historyErr = helm.ErrReleaseNotFound + dir := "" + if tc.dirCRD { + dir = writeSpecDir(t) + writePlanExtras(t, dir, ".deployah/crds/widget.yaml", planCRDBody) + } + format := tc.format + if format == "" { + format = outputFormatHuman } - sess := session.New( - session.WithSpecPath(specPath), - session.WithHelmFactory(func(*target.Target, session.HelmConfig) (session.HelmClient, error) { - return stub, nil - }), - ) - h := nabatctx.New(t, "test") - h.Context.SetContext(session.WithContext(h.Context.Context(), sess)) - opts := testOptions() - opts.DetailedExitCode = true - err := executePlan(h.Context, sess, nil, testManifest(), opts, testResolved(nil)) - if tc.wantErrIs != nil { - require.ErrorIs(t, err, tc.wantErrIs) + stdout, _, err := (planFixture{ + dir: dir, + stub: tc.stub, + live: tc.live, + resolved: tc.resolved, + opts: &Options{Environment: "production", OutputFormat: format, DetailedExitCode: !tc.off}, + }).run(t) + if tc.wantPlain != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tc.wantPlain) + assert.NotErrorIs(t, err, ErrChangesPresent) return } - require.NoError(t, err) + assert.NotEmpty(t, stdout) + for _, s := range tc.contains { + assert.Contains(t, stdout, s) + } + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + if format == outputFormatJSON { + require.NoError(t, json.Unmarshal([]byte(stdout), &map[string]any{})) + } }) } } + +func TestExecutePlan_ReaderError(t *testing.T) { + t.Parallel() + readers := &recordedReaders{err: errors.New("mapper down")} + _, _, err := (planFixture{ + stub: &stubHelmClient{result: installResult(deploymentV1), prep: installPrep()}, + readers: readers, + }).run(t) + require.Error(t, err) + assert.ErrorContains(t, err, "cluster readers") + assert.ErrorContains(t, err, "mapper down") + assert.NotErrorIs(t, err, ErrChangesPresent) +} + +func TestExecutePlan_MissingKubeconfig(t *testing.T) { + t.Parallel() + _, _, err := (planFixture{ + stub: &stubHelmClient{result: installResult(deploymentV1), prep: installPrep()}, + kubeconfig: filepath.Join(t.TempDir(), "missing"), + }).run(t) + require.Error(t, err) + assert.ErrorContains(t, err, "kubernetes config") +} + +func TestExecutePlan_LoadExtrasError(t *testing.T) { + t.Parallel() + dir := writeSpecDir(t) + writePlanExtras(t, dir, ".deployah/manifests/bad.yaml", "not: [valid") + _, _, err := (planFixture{ + dir: dir, + stub: &stubHelmClient{result: installResult(deploymentV1), prep: installPrep()}, + }).run(t) + require.Error(t, err) + assert.ErrorContains(t, err, "load extras") +} diff --git a/internal/cmd/plan/styler.go b/internal/cmd/plan/styler.go new file mode 100644 index 0000000..40a3676 --- /dev/null +++ b/internal/cmd/plan/styler.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "nabat.dev/nabat" + "nabat.dev/theme" + + "deployah.dev/deployah/internal/plan/view" +) + +// roleTokens is the Nabat color for each Human plan role. +var roleTokens = map[view.Role]theme.Token{ + view.RoleTitle: theme.TextTitle, + view.RolePrimary: theme.TextPrimary, + view.RoleDiffAdded: theme.StatusSuccess, + view.RoleDiffRemoved: theme.StatusError, + view.RoleDiffModified: theme.StatusWarning, + view.RoleDiffContext: theme.TextMuted, +} + +// nabatStyler paints Human plan lines with the command theme. +type nabatStyler struct{ c *nabat.Context } + +// Style implements [view.Styler]. +func (s nabatStyler) Style(role view.Role, line string) string { + tok, ok := roleTokens[role] + if !ok { + return line // unknown roles stay plain + } + // Nabat applies color, including NO_COLOR and a non-TTY. + return s.c.Render(tok, line) +} diff --git a/internal/cmd/plan/styler_test.go b/internal/cmd/plan/styler_test.go new file mode 100644 index 0000000..d5a4a50 --- /dev/null +++ b/internal/cmd/plan/styler_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "nabat.dev/theme" + + "deployah.dev/deployah/internal/plan/view" +) + +func TestRoleTokens(t *testing.T) { + t.Parallel() + tests := []struct { + name string + role view.Role + tok theme.Token + }{ + {name: "title", role: view.RoleTitle, tok: theme.TextTitle}, + {name: "primary", role: view.RolePrimary, tok: theme.TextPrimary}, + {name: "added", role: view.RoleDiffAdded, tok: theme.StatusSuccess}, + {name: "removed", role: view.RoleDiffRemoved, tok: theme.StatusError}, + {name: "modified", role: view.RoleDiffModified, tok: theme.StatusWarning}, + {name: "context", role: view.RoleDiffContext, tok: theme.TextMuted}, + } + assert.Len(t, roleTokens, len(tests)) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.tok, roleTokens[tc.role]) + }) + } +} + +func TestNabatStyler_UnknownRole(t *testing.T) { + t.Parallel() + got := (nabatStyler{}).Style(0, "plain") + assert.Equal(t, "plain", got) +} diff --git a/internal/cmd/plan_cli_test.go b/internal/cmd/plan_cli_test.go new file mode 100644 index 0000000..5bb616e --- /dev/null +++ b/internal/cmd/plan_cli_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "nabat.dev/nabat" + "nabat.dev/nabat/nabattest" + + "deployah.dev/deployah/internal/cmd" + + planCmd "deployah.dev/deployah/internal/cmd/plan" +) + +func TestPlanFlagsRejected(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + }{ + {name: "output text", args: []string{"plan", "dev", "--output", "text"}}, + {name: "short output text", args: []string{"plan", "dev", "-o", "text"}}, + {name: "drift", args: []string{"plan", "dev", "--drift"}}, + {name: "raw", args: []string{"plan", "dev", "--raw"}}, + {name: "yaml", args: []string{"plan", "dev", "--yaml"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, stderr, err := runPlanCLI(t, tc.args...) + require.Error(t, err) + assert.Contains(t, stderr, "error:") + }) + } +} + +func TestPlanFlagsAccepted(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tests := []struct { + name string + args []string + }{ + {name: "output human", args: []string{"plan", "dev", "--output", "human", "--cwd", dir}}, + {name: "output json", args: []string{"plan", "dev", "--output", "json", "--cwd", dir}}, + {name: "short json", args: []string{"plan", "dev", "-o", "json", "--cwd", dir}}, + {name: "show secrets", args: []string{"plan", "dev", "--show-secrets", "--cwd", dir}}, + {name: "show secrets json", args: []string{"plan", "dev", "--show-secrets", "-o", "json", "--cwd", dir}}, + {name: "detailed exit code", args: []string{"plan", "dev", "--detailed-exitcode", "--cwd", dir}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, _, err := runPlanCLI(t, tc.args...) + require.Error(t, err) + assert.NotContains(t, err.Error(), "unknown flag") + assert.NotContains(t, err.Error(), "must be one of") + }) + } +} + +func TestPlanHelp(t *testing.T) { + t.Parallel() + stdout, _, err := runPlanCLI(t, "plan", "--help") + require.NoError(t, err) + tests := []struct { + text string + want bool + }{ + {text: "--output, -o ", want: true}, + {text: "default: human", want: true}, + {text: "--show-secrets", want: true}, + {text: "--detailed-exitcode", want: true}, + {text: "--drift"}, + {text: "--raw"}, + {text: "--yaml"}, + } + for _, tc := range tests { + t.Run(tc.text, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, strings.Contains(stdout, tc.text)) + }) + } +} + +func TestPlanErrorBanner(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + contains string + empty bool + }{ + {name: "changes present", err: fmt.Errorf("x: %w", planCmd.ErrChangesPresent), empty: true}, + {name: "ordinary error", err: fmt.Errorf("boom"), contains: "error:"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + appIO, _, _, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(appIO)) + app.MustCommand("boom", nabat.WithRun(func(*nabat.Context) error { return tc.err })) + err := nabattest.RunParallel(t, app, []string{"boom"}) + require.Error(t, err) + stderr := errOut.String() + if tc.empty { + assert.Empty(t, strings.TrimSpace(stderr)) + } + if tc.contains != "" { + assert.Contains(t, stderr, tc.contains) + } + }) + } +} + +func runPlanCLI(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + appIO, _, out, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(appIO)) + err = nabattest.RunParallel(t, app, args) + return out.String(), errOut.String(), err +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e8eb580..a01b232 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -39,7 +39,6 @@ import ( "deployah.dev/deployah/internal/cmd/shell" "deployah.dev/deployah/internal/cmd/status" "deployah.dev/deployah/internal/cmd/validate" - "deployah.dev/deployah/internal/plan" "deployah.dev/deployah/internal/session" "deployah.dev/deployah/internal/spec" @@ -71,12 +70,10 @@ func NewApp(opts ...nabat.Option) *nabat.App { nabat.WithFlag("timeout", session.DefaultTimeout, nabat.WithShort('t'), nabat.WithUsage("Timeout for Deployah operations (install/upgrade, list, status, logs, delete, run)"), nabat.WithPersistent()), nabat.WithFlag("cwd", "", nabat.WithShort('C'), nabat.WithUsage("Run as if deployah was started in this directory instead of the current working directory"), nabat.WithPersistent()), nabat.WithExtension(logging.New(logging.WithVerboseFlag("debug"))), - // plan.ErrChangesPresent is a normal CI signal (exit code 2, see - // Execute), not a failure, so it gets no error banner. Every other - // error keeps the same "error: " styling nabat's default - // handler would have used. + // Effects are exit code 2, not a failure, so skip the error banner. + // Other errors keep nabat's "error: " line. nabat.WithErrorHandler(func(err error) { - if errors.Is(err, plan.ErrChangesPresent) { + if errors.Is(err, planCmd.ErrChangesPresent) { return } errStyle := app.Theme().Style(theme.StatusError) @@ -142,10 +139,8 @@ func NewApp(opts ...nabat.Option) *nabat.App { return app } -// Execute is the main entry point for the Deployah application. It cancels -// the context on SIGINT/SIGTERM so a mid-flight command can unwind and clean -// up instead of being killed outright. Exit code: 0 success, 2 when -// `deployah plan --detailed-exitcode` found pending changes, 1 otherwise. +// Execute runs the Deployah CLI. On SIGINT or SIGTERM it cancels the +// context so the running command can clean up. func Execute() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) app := NewApp() @@ -154,11 +149,19 @@ func Execute() { // the error paths below call os.Exit, which skips deferred calls. stop() + if code := exitCode(err); code != 0 { + os.Exit(code) + } +} + +// exitCode maps a Run error to a process status. +// Nil is 0, [planCmd.ErrChangesPresent] is 2, and any other error is 1. +func exitCode(err error) int { if err == nil { - return + return 0 } - if errors.Is(err, plan.ErrChangesPresent) { - os.Exit(2) + if errors.Is(err, planCmd.ErrChangesPresent) { + return 2 } - os.Exit(1) + return 1 } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go new file mode 100644 index 0000000..adf2406 --- /dev/null +++ b/internal/cmd/root_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "deployah.dev/deployah/internal/plan" + + planCmd "deployah.dev/deployah/internal/cmd/plan" +) + +func TestExitCode(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + want int + }{ + {name: "nil", want: 0}, + {name: "plan changes", err: fmt.Errorf("wrap: %w", planCmd.ErrChangesPresent), want: 2}, + {name: "legacy sentinel", err: fmt.Errorf("wrap: %w", plan.ErrChangesPresent), want: 1}, + {name: "ordinary", err: errors.New("boom"), want: 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, exitCode(tc.err)) + }) + } +} diff --git a/internal/e2e/e2e_plan_cli_test.go b/internal/e2e/e2e_plan_cli_test.go new file mode 100644 index 0000000..4efc0c1 --- /dev/null +++ b/internal/e2e/e2e_plan_cli_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build e2e + +package e2e_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + planCmd "deployah.dev/deployah/internal/cmd/plan" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (s *E2ESuite) TestPlanCLI() { + t := s.T() + src := filepath.Join(s.scenariosDir, "basic-web-service") + require.DirExists(t, src) + dir := t.TempDir() + copyTree(t, src, dir) + + ns := fixtureNamespace("plan-cli") + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if _, _, delErr := runInErrContext(t, cleanupCtx, dir, "delete", "basic-web-service", "dev", + "--yes", "--wait", "--allow-missing-platform", + "--context", kindContext, "--namespace", ns); delErr != nil { + t.Logf("cleanup delete failed (non-fatal): %v", delErr) + } + s.deleteNamespace(t, ns) + }) + + stdout, _, err := runInErr(t, dir, "plan", "dev", "-o", "json", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err) + var fresh map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &fresh)) + assert.Empty(t, fresh["drift"]) + cs, _ := s.kubeClients(t) + _, err = cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "fresh plan must not create namespace %s: %v", ns, err) + + s.createNamespace(t, ns) + runIn(t, dir, "deploy", "dev", "--context", kindContext, "--namespace", ns) + + stdout, _, err = runInErr(t, dir, "plan", "dev", "-o", "json", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err) + var same map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &same)) + assert.Empty(t, same["changes"]) + assert.Empty(t, same["drift"]) + + _, _, err = runInErr(t, dir, "plan", "dev", "--detailed-exitcode", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err) + + dep, err := cs.AppsV1().Deployments(ns).Get(t.Context(), "basic-web-service-dev", metav1.GetOptions{}) + require.NoError(t, err) + replicas := int32(3) + dep.Spec.Replicas = &replicas + _, err = cs.AppsV1().Deployments(ns).Update(t.Context(), dep, metav1.UpdateOptions{}) + require.NoError(t, err) + + stdout, _, err = runInErr(t, dir, "plan", "dev", "-o", "json", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err) + var drifted map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &drifted)) + drift, ok := drifted["drift"].([]any) + require.True(t, ok) + modified := 0 + for _, item := range drift { + entry, isMap := item.(map[string]any) + require.True(t, isMap) + if entry["action"] == "modified" { + modified++ + } + } + assert.Equal(t, 1, modified) + + _, _, err = runInErr(t, dir, "plan", "dev", "--detailed-exitcode", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err, "drift alone must not exit 2") + + human, _, err := runInErr(t, dir, "plan", "dev", + "--context", kindContext, "--namespace", ns) + require.NoError(t, err) + assert.Contains(t, human, "Drift") + + specPath := filepath.Join(dir, "deployah.yaml") + raw, err := os.ReadFile(specPath) // #nosec G304 -- path under test-controlled temp dir + require.NoError(t, err) + old := []byte("port: 80\n") + require.Equal(t, 1, bytes.Count(raw, old)) + updated := bytes.Replace(raw, old, []byte("port: 80\n replicas: 2\n"), 1) + require.NoError(t, os.WriteFile(specPath, updated, 0o600)) // #nosec G703 -- path under test-controlled temp dir + stdout, _, err = runInErr(t, dir, "plan", "dev", "--detailed-exitcode", + "--context", kindContext, "--namespace", ns) + require.ErrorIs(t, err, planCmd.ErrChangesPresent) + assert.NotEmpty(t, stdout) +} diff --git a/internal/plan/semantic/chart_crd_test.go b/internal/plan/semantic/chart_crd_test.go index 6e1a659..7a77282 100644 --- a/internal/plan/semantic/chart_crd_test.go +++ b/internal/plan/semantic/chart_crd_test.go @@ -76,20 +76,57 @@ func TestAttachChartCRDs_PreservesOrder(t *testing.T) { assert.Equal(t, 1, p.ChartCRDs[1].Index) } -func TestAttachChartCRDs_DoesNotChangeHasEffects(t *testing.T) { +func TestAttachChartCRDs_HasEffectsFollowsWillProcess(t *testing.T) { t.Parallel() - base, err := semantic.New(semantic.Header{}, semantic.HelmNone, nil, nil) + fresh, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, nil, nil) require.NoError(t, err) - require.True(t, base.IsNoOp()) - p, err := semantic.AttachChartCRDs(base, []semantic.ChartCRD{{ - Kind: "CustomResourceDefinition", - Name: "widgets.example.com", - Lifecycle: semantic.ChartCRDUpgrade, - }}) + idle, err := semantic.New(semantic.Header{}, semantic.HelmNone, nil, nil) require.NoError(t, err) - assert.False(t, p.HasEffects()) - assert.True(t, p.IsNoOp()) - assert.Empty(t, p.Changes) + require.True(t, idle.IsNoOp()) + + tests := []struct { + name string + base semantic.Plan + lifecycle semantic.ChartCRDLifecycle + willProcess bool + wantEffects bool + wantNoOp bool + }{ + { + name: "process", + base: fresh, + lifecycle: semantic.ChartCRDProcess, + willProcess: true, + wantEffects: true, + }, + { + name: "skip", + base: fresh, + lifecycle: semantic.ChartCRDSkip, + }, + { + name: "upgrade", + base: idle, + lifecycle: semantic.ChartCRDUpgrade, + wantNoOp: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + p, attachErr := semantic.AttachChartCRDs(tc.base, []semantic.ChartCRD{{ + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + Lifecycle: tc.lifecycle, + WillProcess: tc.willProcess, + }}) + require.NoError(t, attachErr) + assert.Equal(t, tc.wantEffects, p.HasEffects()) + assert.Equal(t, tc.wantNoOp, p.IsNoOp()) + assert.Empty(t, p.Changes) + assert.Equal(t, semantic.Summary{}, p.Summary) + }) + } } func TestAttachChartCRDs_Validation(t *testing.T) { diff --git a/internal/plan/semantic/plan.go b/internal/plan/semantic/plan.go index d2b2f44..272a750 100644 --- a/internal/plan/semantic/plan.go +++ b/internal/plan/semantic/plan.go @@ -91,9 +91,8 @@ func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks [ }, nil } -// AttachChartCRDs returns a copy of p with checked chart CRDs in the -// given order. [Plan.Changes], [Plan.HasEffects], and [Plan.IsNoOp] -// stay the same. +// AttachChartCRDs returns a copy of p with crds attached in order. +// [Plan.Changes] and [Plan.Summary] stay the same. func AttachChartCRDs(p Plan, crds []ChartCRD) (Plan, error) { copied := slices.Clone(crds) if copied == nil { @@ -105,6 +104,7 @@ func AttachChartCRDs(p Plan, crds []ChartCRD) (Plan, error) { } } p.ChartCRDs = copied + // WillProcess still counts in HasEffects. It is not a resource change. return p, nil } @@ -125,9 +125,9 @@ func validateChartCRD(c ChartCRD) error { return nil } -// HasEffects reports whether the plan lists a resource change or a task -// that would change or run. [HelmAction] is not an effect. [Plan.Drift] -// is observed cluster state and is not an effect. +// HasEffects reports whether the plan would change or run something. +// A chart CRD with [ChartCRD.WillProcess] counts. A bare [HelmAction] +// and [Plan.Drift] do not. func (p Plan) HasEffects() bool { if len(p.Changes) > 0 { return true @@ -137,12 +137,16 @@ func (p Plan) HasEffects() bool { return true } } + for _, crd := range p.ChartCRDs { + if crd.WillProcess { + return true + } + } return false } -// IsNoOp reports whether the plan is a non-install [HelmNone] with no -// known release effects. Drift does not count, so a plan can be a -// no-op while [Plan.HasDrift] is true. +// IsNoOp reports a non-install [HelmNone] plan with no effects. +// Drift can still be present. func (p Plan) IsNoOp() bool { return !p.Header.FreshInstall && p.HelmAction == HelmNone && diff --git a/internal/plan/semantic/plan_test.go b/internal/plan/semantic/plan_test.go index 74106b2..4b4924d 100644 --- a/internal/plan/semantic/plan_test.go +++ b/internal/plan/semantic/plan_test.go @@ -518,9 +518,10 @@ func TestPlan_HasEffects(t *testing.T) { helmAction semantic.HelmAction changes []semantic.ResourceChange tasks []semantic.TaskPlan + crds []semantic.ChartCRD want bool }{ - {name: "empty upgrade", helmAction: semantic.HelmUpgrade}, + {name: "helm upgrade alone is not an effect", helmAction: semantic.HelmUpgrade}, {name: "empty none", helmAction: semantic.HelmNone}, { name: "resource change", @@ -559,12 +560,47 @@ func TestPlan_HasEffects(t *testing.T) { Action: semantic.TaskUnchanged, }}, }, + { + name: "chart crd helm will process", + header: semantic.Header{FreshInstall: true}, + helmAction: semantic.HelmInstall, + crds: []semantic.ChartCRD{{ + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + Lifecycle: semantic.ChartCRDProcess, + WillProcess: true, + }}, + want: true, + }, + { + name: "chart crd skip", + header: semantic.Header{FreshInstall: true}, + helmAction: semantic.HelmInstall, + crds: []semantic.ChartCRD{{ + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + Lifecycle: semantic.ChartCRDSkip, + }}, + }, + { + name: "chart crd upgrade", + helmAction: semantic.HelmNone, + crds: []semantic.ChartCRD{{ + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + Lifecycle: semantic.ChartCRDUpgrade, + }}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() p, err := semantic.New(tt.header, tt.helmAction, tt.changes, tt.tasks) require.NoError(t, err) + if len(tt.crds) > 0 { + p, err = semantic.AttachChartCRDs(p, tt.crds) + require.NoError(t, err) + } assert.Equal(t, tt.want, p.HasEffects()) }) } diff --git a/internal/plan/semantic/types.go b/internal/plan/semantic/types.go index 2c05863..4440d1d 100644 --- a/internal/plan/semantic/types.go +++ b/internal/plan/semantic/types.go @@ -353,5 +353,6 @@ type ChartCRD struct { // Lifecycle is Helm's handling of this document in this invocation. Lifecycle ChartCRDLifecycle // WillProcess is true only when Lifecycle is [ChartCRDProcess]. + // A true value contributes to [Plan.HasEffects]. WillProcess bool } diff --git a/internal/plan/semantic_build_crd_test.go b/internal/plan/semantic_build_crd_test.go index 148a278..16482f6 100644 --- a/internal/plan/semantic_build_crd_test.go +++ b/internal/plan/semantic_build_crd_test.go @@ -60,17 +60,19 @@ func TestBuildSemanticPlan_ChartCRDLifecycle(t *testing.T) { Name: "widgets.example.com", }} tests := []struct { - name string - op helm.Operation - skip bool - wantLife semantic.ChartCRDLifecycle - wantProcess bool - wantAction semantic.HelmAction - wantChanges int - wantEffects bool - wantNoOp bool + name string + op helm.Operation + skip bool + wantLife semantic.ChartCRDLifecycle + wantProcess bool + wantAction semantic.HelmAction + wantChanges int + wantEffects bool + wantNoOp bool + emptyManifest bool }{ {name: "fresh install", op: helm.OperationInstall, wantLife: semantic.ChartCRDProcess, wantProcess: true, wantAction: semantic.HelmInstall, wantChanges: 1, wantEffects: true}, + {name: "fresh install only crds", op: helm.OperationInstall, emptyManifest: true, wantLife: semantic.ChartCRDProcess, wantProcess: true, wantAction: semantic.HelmInstall, wantEffects: true}, {name: "fresh skip", op: helm.OperationInstall, skip: true, wantLife: semantic.ChartCRDSkip, wantAction: semantic.HelmInstall, wantChanges: 1, wantEffects: true}, {name: "upgrade", op: helm.OperationUpgrade, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone, wantNoOp: true}, {name: "upgrade ignores skip", op: helm.OperationUpgrade, skip: true, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone, wantNoOp: true}, @@ -82,7 +84,11 @@ func TestBuildSemanticPlan_ChartCRDLifecycle(t *testing.T) { var client *fakeBuildClient switch tc.op { case helm.OperationInstall: - client = installClient(configMapYAML("app", "prod", "v1")) + desired := configMapYAML("app", "prod", "v1") + if tc.emptyManifest { + desired = "" + } + client = installClient(desired) case helm.OperationUpgrade: client = upgradeClient(manifest, manifest, 4) } diff --git a/internal/session/interfaces.go b/internal/session/interfaces.go index 4198b74..5a9c6ea 100644 --- a/internal/session/interfaces.go +++ b/internal/session/interfaces.go @@ -22,6 +22,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" @@ -29,8 +30,7 @@ import ( ) // HelmClient is kept in this package so [WithHelmFactory] tests can inject a -// mock implementation without importing the concrete helm package. Render -// methods return [render.RenderResult] for the same reason. +// mock implementation. Render methods return [render.RenderResult]. type HelmClient interface { // IsReachable checks whether the configured Kubernetes cluster is reachable. IsReachable() error @@ -50,6 +50,11 @@ type HelmClient interface { // postRenderer, when non-nil, is applied to the rendered manifests. RenderManifests(ctx context.Context, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, func(), error) + // RenderManifestsWithPrep is [HelmClient.RenderManifests] and the + // [helm.ReleasePrep] that picked install or upgrade. + // Cleanup is nil on error. On success the caller runs it once. + RenderManifestsWithPrep(ctx context.Context, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, helm.ReleasePrep, func(), error) + // DeleteRelease uninstalls a Helm release. When wait is true the call // blocks until all resources are fully removed using the legacy polling // strategy with foreground cascade deletion. diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 9b797f2..ad0bf4e 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -36,6 +36,7 @@ import ( "k8s.io/client-go/rest" "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" "deployah.dev/deployah/internal/target" @@ -101,6 +102,30 @@ func (m *MockHelmClient) RenderManifests(ctx context.Context, resolved *spec.Res return result, cleanup, nil } +// RenderManifestsWithPrep implements [HelmClient]. +func (m *MockHelmClient) RenderManifestsWithPrep(ctx context.Context, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, helm.ReleasePrep, func(), error) { + args := m.Called(ctx, resolved, postRenderer, crds) + if err := args.Error(3); err != nil { + return nil, helm.ReleasePrep{}, func() {}, err + } + if args.Get(0) == nil { + return nil, helm.ReleasePrep{}, func() {}, errors.New("mock: render result not set") + } + result, ok := args.Get(0).(*render.RenderResult) + if !ok { + return nil, helm.ReleasePrep{}, func() {}, fmt.Errorf("unexpected mock return type %T", args.Get(0)) + } + prep, prepOK := args.Get(1).(helm.ReleasePrep) + if !prepOK { + return nil, helm.ReleasePrep{}, func() {}, fmt.Errorf("unexpected mock prep type %T", args.Get(1)) + } + cleanup, cleanupOK := args.Get(2).(func()) + if !cleanupOK || cleanup == nil { + cleanup = func() {} + } + return result, prep, cleanup, nil +} + // DeleteRelease implements [HelmClient]. func (m *MockHelmClient) DeleteRelease(ctx context.Context, project, environment string, wait bool) error { args := m.Called(ctx, project, environment, wait)