diff --git a/README.md b/README.md index 9200ba7..19ee928 100644 --- a/README.md +++ b/README.md @@ -507,14 +507,22 @@ Coverage profiles are written to `coverage-unit.out` and ### Running e2e tests locally -Requires Docker. Creates and destroys a Kind cluster named `deployah`. +You need Docker. The suite creates a Kind cluster named `deployah` and +deletes it afterwards. ```sh DEPLOYAH_E2E_FORCE=1 nix run .#test-e2e ``` -Skips automatically when no container engine is found (unless `CI=true`). -Set `DEPLOYAH_E2E_DUMP=1` with `-v` to print live objects when adding a scenario. +Without Docker the suite skips. With `CI=true` it fails. +Put `e2e.yaml` in `scenarios//` to run that spec on Kind. Generate a +skeleton with: + +```sh +go test ./internal/e2e/ -tags e2e -run TestE2EFixtures/ -e2e.scaffold +``` + +`-e2e.preserve` keeps the namespace after a failure. ### Build and run @@ -528,11 +536,10 @@ nix run .#publish-demo # sync docs/assets/ to R2 (needs R2_* env vars) ### CI GitHub Actions runs flake validation, lint/fmt/tidy checks, `nix run .#test-unit`, -`nix run .#test-integration`, and `nix run .#test-e2e` on every pull request and -push to `main`. -Scenario fixtures under `scenarios/` and e2e fixtures under -`internal/e2e/testdata/` (including `deployah.yaml`, `deployah.platform.yaml`, -and `.deployah/`) are tracked so tests can run on a clean checkout. +`nix run .#test-integration`, and `nix run .#test-e2e` on pull requests and +pushes to `main`. +Commit `deployah.yaml`, `expected/`, `e2e.yaml`, and `.deployah/` under +`scenarios/`. CI reads those files from a clean checkout. ```sh nix flake check # runs the pre-commit hooks (lint, markdownlint, links, tidy, nixfmt) diff --git a/flake.nix b/flake.nix index 8136d8e..72699a5 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,7 @@ buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-8nj4lEfjnl8xnNRCM0P32zXAxATUOh4XPJql4gqxvYE="; + deployahVendorHash = "sha256-zltKCgHj7sC3FSTbuAFxBjS03XaU/d6Vz5BKH8wgkbc="; inherit (pkgs) golangci-lint gopls; diff --git a/go.mod b/go.mod index 751f17e..763f335 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( k8s.io/apimachinery v0.36.4 k8s.io/client-go v0.36.4 mvdan.cc/sh/v3 v3.13.1 - nabat.dev v0.8.0 + nabat.dev v0.9.0 sigs.k8s.io/e2e-framework v0.7.0 sigs.k8s.io/kind v0.32.0 sigs.k8s.io/yaml v1.6.0 diff --git a/go.sum b/go.sum index 4b7ea94..514262f 100644 --- a/go.sum +++ b/go.sum @@ -708,8 +708,8 @@ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0x k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= -nabat.dev v0.8.0 h1:8tilU5GFZQHC0R7SzTtFO0a5xi9CMuQEG7ZMuYpUCrg= -nabat.dev v0.8.0/go.mod h1:S44WwDY/h4hQgAPrtEmazmFuQUO8eKISu1CR0Ea/nJE= +nabat.dev v0.9.0 h1:uWkVsLJXct8Bnm+wAfiEY5yqKDP3BxMEIohJRIcgD8k= +nabat.dev v0.9.0/go.mod h1:S44WwDY/h4hQgAPrtEmazmFuQUO8eKISu1CR0Ea/nJE= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a3e26b4..0791c9a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -99,14 +99,16 @@ func NewApp(opts ...nabat.Option) *nabat.App { rtOpts := []session.Option{ session.WithNamespace(opts.Namespace), - session.WithKubeconfig(opts.Kubeconfig), session.WithKubeContext(opts.Context), - session.WithSpecPath(opts.Spec), + session.WithSpecPath(c.Abs(opts.Spec)), session.WithDebug(opts.Debug), session.WithTimeout(opts.Timeout), } + if opts.Kubeconfig != "" { + rtOpts = append(rtOpts, session.WithKubeconfig(c.Abs(opts.Kubeconfig))) + } if opts.PlatformFile != "" { - rtOpts = append(rtOpts, session.WithPlatformFile(opts.PlatformFile)) + rtOpts = append(rtOpts, session.WithPlatformFile(c.Abs(opts.PlatformFile))) } if localKubeconfig != "" { rtOpts = append(rtOpts, session.WithExtraKubeconfigPaths(localKubeconfig)) diff --git a/internal/cmd/root_dir_test.go b/internal/cmd/root_dir_test.go new file mode 100644 index 0000000..332799f --- /dev/null +++ b/internal/cmd/root_dir_test.go @@ -0,0 +1,140 @@ +// Copyright 2025 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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "nabat.dev/nabat" + "nabat.dev/nabat/nabattest" + + "deployah.dev/deployah/internal/cmd" +) + +const specWithImageVar = `apiVersion: v1-alpha.5 +project: withdir +components: + web: + image: ${IMAGE} + port: 80 + environments: [dev] +environments: + dev: + envFile: .env.dev +` + +const specLiteralImage = `apiVersion: v1-alpha.5 +project: withdir +components: + web: + image: nginx:latest + port: 80 + environments: [dev] +environments: + dev: {} +` + +func writeWithDirFixture(t *testing.T, specFile, spec, envBody string) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, specFile), []byte(spec), 0o600)) + if envBody != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.dev"), []byte(envBody), 0o600)) + } + return dir +} + +// TestWithDirResolvesSpecAndEnvFile checks that nabattest.WithDir and +// c.Abs resolve --spec and .env files against the virtual directory +// rather than the process working directory. The test never calls [os.Chdir]. +func TestWithDirResolvesSpecAndEnvFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + specFile string + envBody string + args []string + }{ + { + name: "env file relative to virtual dir", + spec: specWithImageVar, + specFile: "deployah.yaml", + envBody: "DPY_VAR_IMAGE=nginx:1.27\n", + args: []string{"plan", "dev", "--offline"}, + }, + { + name: "explicit --spec is Abs against virtual dir", + spec: specWithImageVar, + specFile: "app.yaml", + envBody: "DPY_VAR_IMAGE=nginx:1.27\n", + args: []string{"plan", "dev", "--offline", "--spec", "app.yaml"}, + }, + { + name: "literal image without env file", + spec: specLiteralImage, + specFile: "deployah.yaml", + args: []string{"plan", "dev", "--offline"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := writeWithDirFixture(t, tt.specFile, tt.spec, tt.envBody) + appIO, _, _, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(appIO)) + err := nabattest.RunParallel(t, app, tt.args, nabattest.WithDir(dir)) + require.NoErrorf(t, err, "plan --offline under WithDir\nstderr:\n%s", errOut.String()) + }) + } +} + +func TestWithDirResolvesSpecAndEnvFile_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + specFile string + args []string + wantErr string + }{ + { + name: "missing env file fails substitution", + spec: specWithImageVar, + specFile: "deployah.yaml", + args: []string{"plan", "dev", "--offline"}, + wantErr: "environment file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := writeWithDirFixture(t, tt.specFile, tt.spec, "") + appIO, _, _, _ := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(appIO)) + err := nabattest.RunParallel(t, app, tt.args, nabattest.WithDir(dir)) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/internal/e2e/doc.go b/internal/e2e/doc.go index 95aa5aa..7bb4daf 100644 --- a/internal/e2e/doc.go +++ b/internal/e2e/doc.go @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package e2e holds Deployah's end-to-end suite, which drives the CLI -// in-process against a live Kind cluster. +// Package e2e drives the Deployah CLI in-process against a live Kind cluster. // -// The tests are gated behind the "e2e" build tag and need a container engine; -// run them with `nix run .#test-e2e`. This file intentionally carries no build -// tag so `go list ./...` and golangci-lint can resolve the package without it. +// TestE2EFixtures runs every scenarios/*/e2e.yaml. TestCRDLifecycle mutates +// CRD files between CLI calls. The suite uses the "e2e" build tag and needs +// a container engine (`nix run .#test-e2e`). +// This file has no build tag so `go list ./...` and golangci-lint can +// resolve the package without the tag. package e2e diff --git a/internal/e2e/e2e_assert_test.go b/internal/e2e/e2e_assert_test.go new file mode 100644 index 0000000..d70c47e --- /dev/null +++ b/internal/e2e/e2e_assert_test.go @@ -0,0 +1,704 @@ +// Copyright 2025 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 ( + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" + "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" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/klient/wait" + + inttest "deployah.dev/deployah/internal/testing" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + netutil "k8s.io/apimachinery/pkg/util/net" +) + +const ( + resourcePollInterval = 2 * time.Second + logsRetryInterval = 5 * time.Second + namespaceWaitTimeout = 2 * time.Minute + e2eSchemaModeLine = "# $schema: ../../internal/testing/e2e.schema.json" +) + +func (s *E2ESuite) assertE2EFixture(t *testing.T, dir, project, ns string, fx inttest.E2EFixture) { + t.Helper() + if len(fx.Steps) > 0 { + for i, step := range fx.Steps { + s.executeStep(t, dir, project, ns, fx, i, step) + } + return + } + s.executeStep(t, dir, project, ns, fx, 0, inttest.Step{ + Deploy: &inttest.DeployOp{}, + Resources: fx.Resources, + }) + if *flagScaffold { + s.scaffoldSimple(t, dir, ns, fx) + } +} + +func (s *E2ESuite) executeStep(t *testing.T, dir, project, ns string, fx inttest.E2EFixture, index int, step inttest.Step) { + t.Helper() + timeout, err := fx.StepTimeout(step) + require.NoErrorf(t, err, "steps[%d] timeout", index) + args := stepArgs(project, fx.Env, ns, step) + + if step.Logs != nil { + s.retryLogs(t, dir, args, step.Logs.Contains, timeout) + } else { + stdout, stderr := runIn(t, dir, args...) + if step.StdoutContains != "" { + require.Containsf(t, stdout, step.StdoutContains, "steps[%d] stdout", index) + } + if step.StderrContains != "" { + require.Containsf(t, stderr, step.StderrContains, "steps[%d] stderr", index) + } + } + + if len(step.Resources) == 0 { + return + } + s.waitForResources(t, ns, step.Resources, timeout) +} + +func (s *E2ESuite) retryLogs(t *testing.T, dir string, args []string, contains string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var stdout, stderr string + var runErr error + for { + stdout, stderr, runErr = runInErr(t, dir, args...) + if runErr == nil && strings.Contains(stdout, contains) { + return + } + if time.Now().After(deadline) { + break + } + select { + case <-t.Context().Done(): + require.Fail(t, "logs retry canceled", "want %q in stdout\nstdout:\n%s\nstderr:\n%s\nerr: %v", + contains, stdout, stderr, runErr) + case <-time.After(logsRetryInterval): + } + } + require.Fail(t, "logs never matched", "want %q in stdout within %s\nstdout:\n%s\nstderr:\n%s\nerr: %v", + contains, timeout, stdout, stderr, runErr) +} + +func stepArgs(project, env, ns string, step inttest.Step) []string { + switch step.OpName() { + case "deploy": + args := []string{"deploy", env, "--context", kindContext, "--yes", "--namespace", ns} + if step.Deploy != nil && step.Deploy.Spec != "" { + args = append(args, "--spec", step.Deploy.Spec) + } + if step.Deploy != nil { + args = append(args, step.Deploy.Args...) + } + return args + case "run": + return []string{"run", step.Run.Task, env, "--context", kindContext, "--yes", "--namespace", ns} + case "logs": + return []string{ + "logs", project, + "--component=" + step.Logs.Component, + "--environment=" + env, + "--no-follow", + "--context", kindContext, + "--namespace", ns, + } + case "delete": + return []string{ + "delete", project, env, + "--yes", "--wait", "--allow-missing-platform", + "--context", kindContext, + "--namespace", ns, + } + default: + return nil + } +} + +func (s *E2ESuite) waitForResources(t *testing.T, ns string, assertions []inttest.ResourceAssertion, timeout time.Duration) { + t.Helper() + var last []string + err := wait.For(func(ctx context.Context) (bool, error) { + var all []string + for i, ra := range assertions { + diffs, checkErr := s.checkAssertion(ctx, ns, ra) + if checkErr != nil { + if isRetryableAPIError(checkErr) { + all = append(all, fmt.Sprintf("resources[%d]: %v", i, checkErr)) + continue + } + return false, fmt.Errorf("resources[%d]: %w", i, checkErr) + } + for _, d := range diffs { + all = append(all, fmt.Sprintf("resources[%d]: %s", i, d)) + } + } + last = all + return len(all) == 0, nil + }, wait.WithTimeout(timeout), wait.WithInterval(resourcePollInterval), + wait.WithContext(t.Context()), wait.WithImmediate()) + if err == nil { + return + } + s.dumpDiagnostics(t, ns, assertions) + require.NoErrorf(t, err, "resource assertions failed:\n%s", strings.Join(last, "\n")) +} + +// isRetryableAPIError reports whether resource polling should retry err. +// It returns true for NotFound, conflict, timeout, and transport failures. +// REST mapping misses, forbidden requests, and canceled contexts fail +// immediately. +func isRetryableAPIError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + switch { + case apierrors.IsNotFound(err), + apierrors.IsConflict(err), + apierrors.IsServerTimeout(err), + apierrors.IsTooManyRequests(err), + apierrors.IsServiceUnavailable(err), + apierrors.IsTimeout(err), + apierrors.IsInternalError(err): + return true + } + return netutil.IsProbableEOF(err) || + netutil.IsConnectionReset(err) || + netutil.IsConnectionRefused(err) || + netutil.IsTimeout(err) +} + +func (s *E2ESuite) checkAssertion(ctx context.Context, ns string, ra inttest.ResourceAssertion) ([]string, error) { + gvk, err := gvkFromMatch(ra.Match) + if err != nil { + return nil, err + } + namespaced, err := s.isNamespaced(gvk) + if err != nil { + return nil, err + } + lookupNS := "" + if namespaced { + lookupNS = ns + } + name, labelSet := matchMeta(ra.Match) + want := ra.Count() + res, err := s.newResources(lookupNS) + if err != nil { + return nil, err + } + + if name != "" { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + getErr := res.Get(ctx, name, lookupNS, u) + if apierrors.IsNotFound(getErr) { + if want == 0 { + return nil, nil + } + return []string{fmt.Sprintf("%s %s not found", gvk.Kind, name)}, nil + } + if getErr != nil { + return nil, getErr + } + if want == 0 { + return []string{fmt.Sprintf("%s %s still exists", gvk.Kind, name)}, nil + } + return inttest.DiffSubset("$", ra.Match, u.Object), nil + } + + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + var opts []resources.ListOption + if len(labelSet) > 0 { + opts = append(opts, resources.WithLabelSelector(labels.Set(labelSet).String())) + } + if listErr := res.List(ctx, list, opts...); listErr != nil { + return nil, listErr + } + + matched := 0 + var sample []string + for i := range list.Items { + diffs := inttest.DiffSubset("$", ra.Match, list.Items[i].Object) + if len(diffs) == 0 { + matched++ + continue + } + if sample == nil { + sample = diffs + } + } + if want == 0 { + if matched == 0 { + return nil, nil + } + return []string{fmt.Sprintf("want 0 %s matching, got %d", gvk.Kind, matched)}, nil + } + if matched >= want { + return nil, nil + } + msg := fmt.Sprintf("want >= %d %s matching, got %d", want, gvk.Kind, matched) + if sample != nil { + return append([]string{msg}, sample...), nil + } + return []string{msg}, nil +} + +func (s *E2ESuite) isNamespaced(gvk schema.GroupVersionKind) (bool, error) { + s.mapperMu.Lock() + defer s.mapperMu.Unlock() + mapping, err := s.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if meta.IsNoMatchError(err) { + s.mapper.Reset() + mapping, err = s.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + } + if err != nil { + return false, fmt.Errorf("RESTMapping %s: %w", gvk.String(), err) + } + return mapping.Scope.Name() == meta.RESTScopeNameNamespace, nil +} + +func (s *E2ESuite) dumpDiagnostics(t *testing.T, ns string, assertions []inttest.ResourceAssertion) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for i, ra := range assertions { + gvk, err := gvkFromMatch(ra.Match) + if err != nil { + t.Logf("resources[%d]: %v", i, err) + continue + } + name, labelSet := matchMeta(ra.Match) + namespaced, nsErr := s.isNamespaced(gvk) + lookupNS := "" + if nsErr == nil && namespaced { + lookupNS = ns + } + res, resErr := s.newResources(lookupNS) + if resErr != nil { + t.Logf("resources client: %v", resErr) + continue + } + if name != "" { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + if getErr := res.Get(ctx, name, lookupNS, u); getErr != nil { + t.Logf("describe %s/%s: %v", gvk.Kind, name, getErr) + continue + } + logLiveYAML(t, gvk.Kind, name, u.Object) + continue + } + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List", + }) + var opts []resources.ListOption + if len(labelSet) > 0 { + opts = append(opts, resources.WithLabelSelector(labels.Set(labelSet).String())) + } + if listErr := res.List(ctx, list, opts...); listErr != nil { + t.Logf("list %s: %v", gvk.Kind, listErr) + continue + } + for j := range list.Items { + logLiveYAML(t, gvk.Kind, list.Items[j].GetName(), list.Items[j].Object) + } + } + var events corev1.EventList + eventRes, eventResErr := s.newResources(ns) + if eventResErr != nil { + t.Logf("events client: %v", eventResErr) + return + } + if listErr := eventRes.List(ctx, &events); listErr != nil { + t.Logf("list events: %v", listErr) + return + } + for i := range events.Items { + ev := events.Items[i] + t.Logf("event %s %s/%s: %s", ev.Type, ev.InvolvedObject.Kind, ev.InvolvedObject.Name, ev.Message) + } +} + +func (s *E2ESuite) createNamespace(t *testing.T, name string) { + t.Helper() + ns := &corev1.Namespace{Name: name} + res, err := s.newResources("") + require.NoError(t, err) + err = res.Create(t.Context(), ns) + if apierrors.IsAlreadyExists(err) { + return + } + require.NoErrorf(t, err, "create namespace %s", name) +} + +func (s *E2ESuite) deleteNamespace(t *testing.T, name string) { + t.Helper() + res, err := s.newResources(name) + require.NoError(t, err) + + // Each phase gets its own deadline. A shared ctx that deletePVCs + // exhausted used to make namespace Delete fail with rate limiter Wait. + workCtx, workCancel := context.WithTimeout(context.Background(), namespaceWaitTimeout) + defer workCancel() + if workErr := s.deleteWorkloads(workCtx, t, res); workErr != nil { + t.Logf("delete workloads in %s: %v", name, workErr) + } + + pvcCtx, pvcCancel := context.WithTimeout(context.Background(), namespaceWaitTimeout) + defer pvcCancel() + if pvcErr := s.deletePVCs(pvcCtx, t, res); pvcErr != nil { + t.Errorf("delete pvcs in %s: %v", name, pvcErr) + } + + ns := &corev1.Namespace{Name: name} + clusterRes, clusterErr := s.newResources("") + require.NoError(t, clusterErr) + nsCtx, nsCancel := context.WithTimeout(context.Background(), namespaceWaitTimeout) + defer nsCancel() + if delErr := clusterRes.Delete(nsCtx, ns); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Errorf("delete namespace %s: %v", name, delErr) + return + } + waitErr := wait.For(func(ctx context.Context) (bool, error) { + getErr := clusterRes.Get(ctx, name, "", ns) + if apierrors.IsNotFound(getErr) { + return true, nil + } + if getErr != nil && !isRetryableAPIError(getErr) { + return false, getErr + } + return false, nil + }, wait.WithTimeout(namespaceWaitTimeout), wait.WithInterval(resourcePollInterval), + wait.WithContext(nsCtx), wait.WithImmediate()) + if waitErr != nil { + t.Errorf("wait for namespace %s deletion: %v", name, waitErr) + } +} + +func (s *E2ESuite) deleteWorkloads(ctx context.Context, t *testing.T, res *resources.Resources) error { + t.Helper() + var cronjobs batchv1.CronJobList + if listErr := res.List(ctx, &cronjobs); listErr != nil && !apierrors.IsNotFound(listErr) { + return fmt.Errorf("list cronjobs: %w", listErr) + } + for i := range cronjobs.Items { + if delErr := res.Delete(ctx, &cronjobs.Items[i]); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Logf("delete cronjob %s: %v", cronjobs.Items[i].Name, delErr) + } + } + var jobs batchv1.JobList + if listErr := res.List(ctx, &jobs); listErr != nil && !apierrors.IsNotFound(listErr) { + return fmt.Errorf("list jobs: %w", listErr) + } + for i := range jobs.Items { + if delErr := res.Delete(ctx, &jobs.Items[i]); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Logf("delete job %s: %v", jobs.Items[i].Name, delErr) + } + } + var sts appsv1.StatefulSetList + if listErr := res.List(ctx, &sts); listErr != nil && !apierrors.IsNotFound(listErr) { + return fmt.Errorf("list statefulsets: %w", listErr) + } + for i := range sts.Items { + if delErr := res.Delete(ctx, &sts.Items[i]); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Logf("delete statefulset %s: %v", sts.Items[i].Name, delErr) + } + } + var deploys appsv1.DeploymentList + if listErr := res.List(ctx, &deploys); listErr != nil && !apierrors.IsNotFound(listErr) { + return fmt.Errorf("list deployments: %w", listErr) + } + for i := range deploys.Items { + if delErr := res.Delete(ctx, &deploys.Items[i]); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Logf("delete deployment %s: %v", deploys.Items[i].Name, delErr) + } + } + return wait.For(func(pollCtx context.Context) (bool, error) { + var pods corev1.PodList + if listErr := res.List(pollCtx, &pods); listErr != nil { + if apierrors.IsNotFound(listErr) { + return true, nil + } + if isRetryableAPIError(listErr) { + return false, nil + } + return false, listErr + } + return len(pods.Items) == 0, nil + }, wait.WithTimeout(namespaceWaitTimeout), wait.WithInterval(resourcePollInterval), + wait.WithContext(ctx), wait.WithImmediate()) +} + +func (s *E2ESuite) deletePVCs(ctx context.Context, t *testing.T, res *resources.Resources) error { + t.Helper() + var pvcs corev1.PersistentVolumeClaimList + if listErr := res.List(ctx, &pvcs); listErr != nil { + if apierrors.IsNotFound(listErr) { + return nil + } + return fmt.Errorf("list pvcs: %w", listErr) + } + for i := range pvcs.Items { + if delErr := res.Delete(ctx, &pvcs.Items[i]); delErr != nil && !apierrors.IsNotFound(delErr) { + t.Logf("delete pvc %s: %v", pvcs.Items[i].Name, delErr) + } + } + return wait.For(func(ctx context.Context) (bool, error) { + var remaining corev1.PersistentVolumeClaimList + if listErr := res.List(ctx, &remaining); listErr != nil { + if apierrors.IsNotFound(listErr) { + return true, nil + } + if isRetryableAPIError(listErr) { + return false, nil + } + return false, listErr + } + return len(remaining.Items) == 0, nil + }, wait.WithTimeout(namespaceWaitTimeout), wait.WithInterval(resourcePollInterval), + wait.WithContext(ctx), wait.WithImmediate()) +} + +func logLiveYAML(t *testing.T, kind, name string, obj map[string]any) { + t.Helper() + raw, err := yaml.Marshal(obj) + if err != nil { + t.Logf("marshal %s/%s: %v", kind, name, err) + return + } + t.Logf("live %s/%s:\n%s", kind, name, raw) +} + +func (s *E2ESuite) scaffoldSimple(t *testing.T, dir, ns string, fx inttest.E2EFixture) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + kinds := []schema.GroupVersionKind{ + {Group: "apps", Version: "v1", Kind: "Deployment"}, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}, + {Group: "", Version: "v1", Kind: "Service"}, + {Group: "", Version: "v1", Kind: "PersistentVolumeClaim"}, + {Group: "batch", Version: "v1", Kind: "Job"}, + {Group: "batch", Version: "v1", Kind: "CronJob"}, + } + var resourcesOut []map[string]any + for _, gvk := range kinds { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List", + }) + if listErr := s.listKindInNamespace(ctx, ns, list); listErr != nil { + t.Logf("scaffold list %s: %v", gvk.Kind, listErr) + continue + } + for i := range list.Items { + resourcesOut = append(resourcesOut, map[string]any{ + "match": scaffoldObject(list.Items[i].Object), + }) + } + } + doc := map[string]any{ + "env": fx.Env, + "resources": resourcesOut, + } + body, err := yaml.Marshal(doc) + require.NoError(t, err) + out := e2eSchemaModeLine + "\n" + string(body) + + path := filepath.Join(dir, inttest.E2EFixtureFile) + if _, statErr := os.Stat(path); statErr == nil { + t.Logf("-e2e.scaffold: %s already exists; skeleton follows\n%s", path, out) + return + } + require.NoError(t, os.WriteFile(path, []byte(out), 0o600)) + t.Logf("wrote scaffold %s", path) +} + +func (s *E2ESuite) listKindInNamespace(ctx context.Context, ns string, list *unstructured.UnstructuredList) error { + res, err := s.newResources(ns) + if err != nil { + return err + } + return res.List(ctx, list) +} + +func scaffoldObject(obj map[string]any) map[string]any { + kind := stringField(obj, "kind") + out := map[string]any{ + "apiVersion": obj["apiVersion"], + "kind": kind, + } + if metaMap := mapField(obj, "metadata"); metaMap != nil { + m := map[string]any{} + if name, hasName := metaMap["name"]; hasName { + m["name"] = name + } + if rawLabels := mapField(metaMap, "labels"); rawLabels != nil { + cleaned := maps.Clone(rawLabels) + delete(cleaned, "helm.sh/chart") + if len(cleaned) > 0 { + m["labels"] = cleaned + } + } + out["metadata"] = m + } + if specMap := mapField(obj, "spec"); specMap != nil { + out["spec"] = stripGeneratedSpec(deepCopyMap(specMap)) + } + if st := mapField(obj, "status"); st != nil { + ready := map[string]any{} + switch kind { + case "Deployment", "StatefulSet": + if v, hasReady := st["readyReplicas"]; hasReady { + ready["readyReplicas"] = v + } + case "Job": + if v, hasSucceeded := st["succeeded"]; hasSucceeded { + ready["succeeded"] = v + } + case "PersistentVolumeClaim": + if v, hasPhase := st["phase"]; hasPhase { + ready["phase"] = v + } + } + if len(ready) > 0 { + out["status"] = ready + } + } + return out +} + +func stripGeneratedSpec(spec map[string]any) map[string]any { + delete(spec, "clusterIP") + delete(spec, "clusterIPs") + ports, isPorts := spec["ports"].([]any) + if !isPorts { + return spec + } + for i := range ports { + pm, isMap := ports[i].(map[string]any) + if !isMap { + continue + } + delete(pm, "nodePort") + } + return spec +} + +func deepCopyMap(in map[string]any) map[string]any { + out := maps.Clone(in) + for k, v := range out { + switch n := v.(type) { + case map[string]any: + out[k] = deepCopyMap(n) + case []any: + cp := make([]any, 0, len(n)) + for _, item := range n { + m, isMap := item.(map[string]any) + if isMap { + cp = append(cp, deepCopyMap(m)) + continue + } + cp = append(cp, item) + } + out[k] = cp + } + } + return out +} + +func gvkFromMatch(match map[string]any) (schema.GroupVersionKind, error) { + kind := stringField(match, "kind") + apiVersion := stringField(match, "apiVersion") + if kind == "" || apiVersion == "" { + return schema.GroupVersionKind{}, fmt.Errorf("match requires apiVersion and kind") + } + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionKind{}, fmt.Errorf("parse apiVersion %q: %w", apiVersion, err) + } + return gv.WithKind(kind), nil +} + +func matchMeta(match map[string]any) (name string, labelSet map[string]string) { + metaMap := mapField(match, "metadata") + if metaMap == nil { + return "", nil + } + name = stringField(metaMap, "name") + raw := mapField(metaMap, "labels") + if raw == nil { + return name, nil + } + labelSet = make(map[string]string, len(raw)) + for k, v := range raw { + s, isString := v.(string) + if !isString { + continue + } + labelSet[k] = s + } + return name, labelSet +} + +func stringField(m map[string]any, key string) string { + s, ok := m[key].(string) + if !ok { + return "" + } + return s +} + +func mapField(m map[string]any, key string) map[string]any { + child, ok := m[key].(map[string]any) + if !ok { + return nil + } + return child +} diff --git a/internal/e2e/e2e_crd_test.go b/internal/e2e/e2e_crd_test.go new file mode 100644 index 0000000..3dba4d1 --- /dev/null +++ b/internal/e2e/e2e_crd_test.go @@ -0,0 +1,206 @@ +// Copyright 2025 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 ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/e2e-framework/klient/wait" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const crdLifecycleName = "clusterwidgets.example.com" + +// TestCRDLifecycle checks CRD apply outside the Helm release. +// It waits for Established, re-applies with no Helm changes, compares +// create to create-replace, and checks the CRD survives deployah delete. +// File mutation cannot be expressed in e2e.yaml. +func (s *E2ESuite) TestCRDLifecycle() { + t := s.T() + src := filepath.Join(s.scenariosDir, "crd-lifecycle") + require.DirExists(t, src) + + dir := t.TempDir() + copyTree(t, src, dir) + + ns := fixtureNamespace("crd-lifecycle") + s.createNamespace(t, ns) + + ext := newApiextensionsClient(t, s.kcPath, kindContext) + t.Cleanup(func() { + // t.Context() is canceled before Cleanup; teardown needs its own. + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if delCRDErr := ext.ApiextensionsV1().CustomResourceDefinitions().Delete( + cleanupCtx, crdLifecycleName, metav1.DeleteOptions{}); delCRDErr != nil { + t.Logf("cleanup CRD delete failed (non-fatal): %v", delCRDErr) + } + if _, _, delErr := runInErrContext(t, cleanupCtx, dir, "delete", "crd-lifecycle", "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) + }) + + runIn(t, dir, "deploy", "dev", "--context", kindContext, "--yes", + "--namespace", ns, "--crds", "create") + crd := waitCRDEstablished(t, ext, crdLifecycleName) + assert.Equal(t, "crd-lifecycle", crd.Labels["e2e-marker"]) + + _, stderr, err := runInErr(t, dir, "deploy", "dev", "--context", kindContext, + "--yes", "--namespace", ns, "--crds", "create") + require.NoError(t, err) + assert.Contains(t, stderr, "already present") + waitCRDEstablished(t, ext, crdLifecycleName) + + patched := strings.Replace( + readFixtureFile(t, filepath.Join(dir, ".deployah", "crds", "clusterwidget.yaml")), + `e2e-marker: "crd-lifecycle"`, + `e2e-marker: "create-skipped"`, + 1, + ) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".deployah", "crds", "clusterwidget.yaml"), + []byte(patched), 0o600)) + runIn(t, dir, "deploy", "dev", "--context", kindContext, "--yes", + "--namespace", ns, "--crds", "create") + crd = getCRD(t, ext, crdLifecycleName) + assert.Equal(t, "crd-lifecycle", crd.Labels["e2e-marker"], + "--crds create must not replace an existing CRD") + + runIn(t, dir, "deploy", "dev", "--context", kindContext, "--yes", + "--namespace", ns, "--crds", "create-replace") + require.NoError(t, wait.For(func(ctx context.Context) (bool, error) { + live, getErr := ext.ApiextensionsV1().CustomResourceDefinitions().Get( + ctx, crdLifecycleName, metav1.GetOptions{}) + if getErr != nil { + if isRetryableAPIError(getErr) { + return false, nil + } + return false, getErr + } + return live.Labels["e2e-marker"] == "create-skipped", nil + }, wait.WithTimeout(2*time.Minute), wait.WithInterval(time.Second), + wait.WithContext(t.Context()), wait.WithImmediate())) + + runIn(t, dir, "delete", "crd-lifecycle", "dev", + "--yes", "--wait", "--allow-missing-platform", + "--context", kindContext, "--namespace", ns) + _, err = ext.ApiextensionsV1().CustomResourceDefinitions().Get( + t.Context(), crdLifecycleName, metav1.GetOptions{}) + require.NoError(t, err, "CRD must survive deployah delete") +} + +func copyTree(tb testing.TB, src, dst string) { + tb.Helper() + err := filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, relErr := filepath.Rel(src, path) + if relErr != nil { + return relErr + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o750) + } + in, openErr := os.Open(path) // #nosec G304 G122 -- path under scenarios/ + if openErr != nil { + return openErr + } + defer in.Close() //nolint:errcheck // read-only copy helper + out, createErr := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- temp fixture copy + if createErr != nil { + return createErr + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) + require.NoError(tb, err) +} + +func readFixtureFile(tb testing.TB, path string) string { + tb.Helper() + raw, err := os.ReadFile(path) // #nosec G304 -- path under test-controlled temp dir + require.NoError(tb, err) + return string(raw) +} + +func newApiextensionsClient(tb testing.TB, kubeconfigPath, contextName string) apiextensionsclient.Interface { + tb.Helper() + rules := clientcmd.NewDefaultClientConfigLoadingRules() + rules.ExplicitPath = kubeconfigPath + overrides := &clientcmd.ConfigOverrides{CurrentContext: contextName} + restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + rules, overrides).ClientConfig() + require.NoError(tb, err) + cs, err := apiextensionsclient.NewForConfig(restCfg) + require.NoError(tb, err) + return cs +} + +func getCRD(tb testing.TB, ext apiextensionsclient.Interface, name string) *apiextensionsv1.CustomResourceDefinition { + tb.Helper() + crd, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( + tb.Context(), name, metav1.GetOptions{}) + require.NoError(tb, err) + return crd +} + +func waitCRDEstablished(tb testing.TB, ext apiextensionsclient.Interface, name string) *apiextensionsv1.CustomResourceDefinition { + tb.Helper() + var latest *apiextensionsv1.CustomResourceDefinition + require.NoError(tb, wait.For(func(ctx context.Context) (bool, error) { + crd, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( + ctx, name, metav1.GetOptions{}) + if err != nil { + if isRetryableAPIError(err) { + return false, nil + } + return false, err + } + latest = crd + for _, cond := range crd.Status.Conditions { + if cond.Type == apiextensionsv1.Established && + cond.Status == apiextensionsv1.ConditionTrue { + return true, nil + } + } + return false, nil + }, wait.WithTimeout(2*time.Minute), wait.WithInterval(time.Second), + wait.WithContext(tb.Context()), wait.WithImmediate())) + require.NotNil(tb, latest) + return latest +} diff --git a/internal/e2e/e2e_helpers_test.go b/internal/e2e/e2e_helpers_test.go new file mode 100644 index 0000000..fd4b705 --- /dev/null +++ b/internal/e2e/e2e_helpers_test.go @@ -0,0 +1,99 @@ +// Copyright 2025 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 ( + "context" + "errors" + "fmt" + "io" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +func TestIsRetryableAPIError(t *testing.T) { + t.Parallel() + + notFound := apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, "web") + conflict := apierrors.NewConflict(schema.GroupResource{Resource: "pods"}, "web", errors.New("conflict")) + noMatch := &meta.NoKindMatchError{GroupKind: schema.GroupKind{Kind: "Widget"}} + + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "not found", err: notFound, want: true}, + {name: "wrapped not found", err: fmt.Errorf("get: %w", notFound), want: true}, + {name: "conflict", err: conflict, want: true}, + {name: "server timeout", err: apierrors.NewServerTimeout(schema.GroupResource{Resource: "pods"}, "get", 1), want: true}, + {name: "too many requests", err: apierrors.NewTooManyRequests("slow down", 1), want: true}, + {name: "service unavailable", err: apierrors.NewServiceUnavailable("down"), want: true}, + {name: "timeout status", err: apierrors.NewTimeoutError("timed out", 1), want: true}, + {name: "internal error", err: apierrors.NewInternalError(errors.New("boom")), want: true}, + {name: "connection refused", err: fmt.Errorf("dial: %w", syscall.ECONNREFUSED), want: true}, + {name: "connection reset", err: fmt.Errorf("read: %w", syscall.ECONNRESET), want: true}, + {name: "probable eof", err: io.EOF, want: true}, + {name: "forbidden", err: apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "web", errors.New("no")), want: false}, + {name: "bad request", err: apierrors.NewBadRequest("nope"), want: false}, + {name: "no kind match", err: noMatch, want: false}, + {name: "wrapped rest mapping", err: fmt.Errorf("RESTMapping apps/v1, Kind=Widget: %w", noMatch), want: false}, + {name: "canceled", err: context.Canceled, want: false}, + {name: "deadline exceeded", err: context.DeadlineExceeded, want: false}, + {name: "plain error", err: errors.New("boom"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isRetryableAPIError(tt.err)) + }) + } +} + +func TestFixtureNamespace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "simple", in: "basic-web-service", want: "e2e-basic-web-service"}, + {name: "uppercase", in: "FooBar", want: "e2e-foobar"}, + {name: "underscores", in: "foo_bar", want: "e2e-foo-bar"}, + {name: "spaces become hyphens", in: "foo bar", want: "e2e-foo-bar"}, + {name: "truncates at 63", in: strings.Repeat("a", 80), want: "e2e-" + strings.Repeat("a", 59)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := fixtureNamespace(tt.in) + assert.Equal(t, tt.want, got) + assert.LessOrEqual(t, len(got), maxDNSLabel) + }) + } +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 90cccef..3037025 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -20,54 +20,64 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" - "io" "os" "os/exec" "path/filepath" + "slices" "strings" + "sync" "testing" - "time" + "unicode" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.yaml.in/yaml/v3" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" "nabat.dev/nabat" "nabat.dev/nabat/nabattest" "sigs.k8s.io/e2e-framework/klient" - "sigs.k8s.io/e2e-framework/klient/k8s" "sigs.k8s.io/e2e-framework/klient/k8s/resources" - "sigs.k8s.io/e2e-framework/klient/wait" - "sigs.k8s.io/e2e-framework/klient/wait/conditions" "deployah.dev/deployah/internal/cmd" "deployah.dev/deployah/internal/localkube" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + inttest "deployah.dev/deployah/internal/testing" +) + +const ( + kindContext = "kind-deployah" + clusterName = "deployah" + maxDNSLabel = 63 +) + +var ( + flagScaffold = flag.Bool("e2e.scaffold", false, "generate e2e.yaml skeleton from live cluster state") + flagPreserve = flag.Bool("e2e.preserve", false, "preserve namespace on test failure for debugging") ) // E2ESuite drives Deployah against a live Kind cluster created by // `deployah cluster up`. type E2ESuite struct { suite.Suite - kcPath string - client klient.Client - scenarios []scenario - testdataDir string // absolute; resolved before SetupSuite chdirs to a temp dir - created bool // true once the suite attempts cluster up (teardown if partial) + kcPath string + client klient.Client + mapper *restmapper.DeferredDiscoveryRESTMapper + mapperMu sync.Mutex + scenariosDir string + created bool // TearDownSuite runs cluster down when true } -type scenario struct { - Name string - Dir string // absolute - Project string // from deployah.yaml, needed by the delete cleanup +type e2eCase struct { + Name string + Dir string + Project string + Fixture inttest.E2EFixture + Parallel bool } // clusterStatusView mirrors the JSON tags on the unexported status view in @@ -80,79 +90,23 @@ type clusterStatusView struct { CloudProviderRunning bool `json:"cloudProviderRunning"` } -type expectations struct { - Env string `yaml:"env"` - Namespace string `yaml:"namespace"` - Deployments []expectedDeployment `yaml:"deployments"` - StatefulSets []expectedStatefulSet `yaml:"statefulSets"` - Services []expectedService `yaml:"services"` - PVCs []expectedPVC `yaml:"pvcs"` - Pods expectedPods `yaml:"pods"` - Jobs []expectedJob `yaml:"jobs"` -} - -type expectedDeployment struct { - Name string `yaml:"name"` - Replicas int32 `yaml:"replicas"` - Image string `yaml:"image"` - PortName string `yaml:"portName"` - Labels map[string]string `yaml:"labels"` -} - -type expectedStatefulSet struct { - Name string `yaml:"name"` - Replicas int32 `yaml:"replicas"` - Image string `yaml:"image"` - PortName string `yaml:"portName"` - Labels map[string]string `yaml:"labels"` -} - -type expectedService struct { - Name string `yaml:"name"` - Port int32 `yaml:"port"` - TargetPortName string `yaml:"targetPortName"` - Selector map[string]string `yaml:"selector"` - // ClusterIP, when set to "None", asserts a headless Service. - ClusterIP string `yaml:"clusterIP"` -} - -type expectedPVC struct { - NamePrefix string `yaml:"namePrefix"` - MinCount int `yaml:"minCount"` - Phase string `yaml:"phase"` - Storage string `yaml:"storage"` -} - -type expectedJob struct { - Name string `yaml:"name"` - Succeeded int32 `yaml:"succeeded"` -} - -type expectedPods struct { - LabelSelector string `yaml:"labelSelector"` - MinCount int `yaml:"minCount"` - Phase string `yaml:"phase"` -} - // TestE2E runs the Kind-based end-to-end suite. func TestE2E(t *testing.T) { suite.Run(t, new(E2ESuite)) } -// SetupSuite discovers fixtures, creates the Kind cluster, and checks status. +// SetupSuite creates the Kind cluster, preloads allowlisted images, and +// builds a discovery RESTMapper. func (s *E2ESuite) SetupSuite() { t := s.T() requireEngine(t) - // go test starts in the package directory. Resolve fixtures now, because - // the chdir below moves the whole suite out of it. - testdataDir, err := filepath.Abs("testdata") + scenariosDir, err := filepath.Abs(inttest.TestScenariosDir) s.Require().NoError(err) - s.testdataDir = testdataDir - s.scenarios = discoverScenarios(t, testdataDir) + s.scenariosDir = scenariosDir - // cluster up scaffolds deployah.platform.yaml into the cwd, so run from a - // temp dir to keep the repo clean. t.Chdir restores cwd when the suite ends. + // Run from a temp dir; cluster up writes deployah.platform.yaml into cwd. + // t.Chdir restores cwd when the suite ends. t.Chdir(t.TempDir()) requireNoCollision(t) @@ -164,16 +118,17 @@ func (s *E2ESuite) SetupSuite() { raw := run(t, "cluster", "status", "--output", "json") var status clusterStatusView s.Require().NoError(json.Unmarshal([]byte(raw), &status)) - s.Require().Equal("deployah", status.Name) + s.Require().Equal(clusterName, status.Name) s.Require().Equal("running", status.Status) - s.Require().Equal("kind-deployah", status.Context) + s.Require().Equal(kindContext, status.Context) s.Require().True(status.CloudProviderRunning) - // status already carries the kubeconfig path, so no second CLI call. s.kcPath = status.Kubeconfig s.Require().FileExists(s.kcPath) - s.client = newKlient(t, s.kcPath, "kind-deployah") + s.client = newKlient(t, s.kcPath, kindContext) + s.preloadImages(t) + s.mapper = newRESTMapper(t, s.client) } // TearDownSuite destroys the Kind cluster only when this suite created it. @@ -186,466 +141,119 @@ func (s *E2ESuite) TearDownSuite() { } } -// TestStatefulScale deploys a stateful component at replicas 1, then upgrades -// to replicas 2 and asserts a second PVC is created. -func (s *E2ESuite) TestStatefulScale() { +// TestE2EFixtures runs every scenarios/*/e2e.yaml. Parallel fixtures run +// first (bounded by go test -parallel); sequential fixtures run after, +// alphabetically by directory name. +func (s *E2ESuite) TestE2EFixtures() { t := s.T() - src := filepath.Join(s.testdataDir, "stateful-scale") - require.DirExists(t, src) - - // Work in a temp copy so swapping deployah.yaml never dirties testdata/. - dir := t.TempDir() - for _, name := range []string{"deployah.yaml", "deployah-replicas-2.yaml"} { - data, readErr := os.ReadFile(filepath.Join(src, name)) // #nosec G304 -- fixture under testdata - require.NoError(t, readErr) - // #nosec G703 -- dir is t.TempDir() and name comes from the literal above - require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600)) - } + cases := s.loadE2ECases(t) + require.NotEmpty(t, cases, "no e2e.yaml fixtures under %s", s.scenariosDir) - t.Chdir(dir) - t.Cleanup(func() { - if delErr := runErr(t, "delete", "stateful-scale", "dev", - "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah"); delErr != nil { - t.Logf("cleanup delete failed (non-fatal): %v", delErr) + var parallel, sequential []e2eCase + for _, c := range cases { + if c.Parallel { + parallel = append(parallel, c) + } else { + sequential = append(sequential, c) } + } + slices.SortFunc(sequential, func(a, b e2eCase) int { + return strings.Compare(a.Name, b.Name) }) - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - - res := s.client.Resources("default") - ctx := t.Context() - stsName := "stateful-scale-dev-cache" - - require.NoError(t, wait.For( - conditions.New(res).ResourceMatch(&appsv1.StatefulSet{ - Name: stsName, Namespace: "default", - }, func(obj k8s.Object) bool { - live, ok := obj.(*appsv1.StatefulSet) - return ok && live.Status.ReadyReplicas >= 1 - }), - wait.WithTimeout(5*time.Minute), - wait.WithInterval(2*time.Second), - )) - - replicas2, readErr := os.ReadFile("deployah-replicas-2.yaml") // #nosec G304 -- temp fixture copy - require.NoError(t, readErr) - // #nosec G703 -- constant name, written into the temp working dir - require.NoError(t, os.WriteFile("deployah.yaml", replicas2, 0o600)) - - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - require.NoError(t, wait.For( - conditions.New(res).ResourceMatch(&appsv1.StatefulSet{ - Name: stsName, Namespace: "default", - }, func(obj k8s.Object) bool { - live, ok := obj.(*appsv1.StatefulSet) - return ok && live.Spec.Replicas != nil && - *live.Spec.Replicas == 2 && live.Status.ReadyReplicas >= 2 - }), - wait.WithTimeout(5*time.Minute), - wait.WithInterval(2*time.Second), - )) - - var pvcs corev1.PersistentVolumeClaimList - require.NoError(t, res.List(ctx, &pvcs)) - matched := 0 - for _, pvc := range pvcs.Items { - if strings.HasPrefix(pvc.Name, "data-stateful-scale-dev-cache-") { - matched++ - assert.Equal(t, corev1.ClaimBound, pvc.Status.Phase, pvc.Name) + t.Run("parallel", func(t *testing.T) { + for _, c := range parallel { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + s.runE2ECase(t, c) + }) } - } - assert.GreaterOrEqual(t, matched, 2, "expected per-pod PVCs after scale-up") + }) + t.Run("sequential", func(t *testing.T) { + for _, c := range sequential { + t.Run(c.Name, func(t *testing.T) { + s.runE2ECase(t, c) + }) + } + }) } -const crdLifecycleName = "clusterwidgets.example.com" - -// TestCRDLifecycle covers CRD apply outside the Helm release: Established -// before install, idle-Helm re-apply, create vs create-replace, and survival -// across deployah delete. -func (s *E2ESuite) TestCRDLifecycle() { - t := s.T() - src := filepath.Join(s.testdataDir, "crd-lifecycle") - require.DirExists(t, src) - - dir := t.TempDir() - copyTree(t, src, dir) - t.Chdir(dir) +func (s *E2ESuite) loadE2ECases(t *testing.T) []e2eCase { + t.Helper() + scenarios, err := inttest.DiscoverScenarios(s.scenariosDir) + require.NoError(t, err) - ext := newApiextensionsClient(t, s.kcPath, "kind-deployah") - t.Cleanup(func() { - // t.Context() is canceled just before Cleanup runs (Go 1.24+), so - // teardown API calls need an independent context. - cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - // Best-effort: remove the fixture CRD so later suite runs stay clean. - if delCRDErr := ext.ApiextensionsV1().CustomResourceDefinitions().Delete( - cleanupCtx, crdLifecycleName, metav1.DeleteOptions{}); delCRDErr != nil { - t.Logf("cleanup CRD delete failed (non-fatal): %v", delCRDErr) - } - if delErr := runErr(t, "delete", "crd-lifecycle", "dev", - "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah"); delErr != nil { - t.Logf("cleanup delete failed (non-fatal): %v", delErr) + seen := map[string]struct{}{} + var cases []e2eCase + for _, sc := range scenarios { + if !sc.HasE2EFixture { + continue } - }) - - // First deploy installs the CRD and the release. - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes", "--crds", "create") - crd := waitCRDEstablished(t, ext, crdLifecycleName) - assert.Equal(t, "crd-lifecycle", crd.Labels["e2e-marker"]) - - // Idle Helm plan must still visit CRDs (already present). Success messages - // go to stderr via nabat, so assert via a dedicated IO capture. - _, stderr := runCapture(t, "deploy", "dev", "--context", "kind-deployah", "--yes", "--crds", "create") - assert.Contains(t, stderr, "already present") - waitCRDEstablished(t, ext, crdLifecycleName) - - // --crds create leaves an existing CRD alone when the file changes. - patched := strings.Replace( - readFixtureFile(t, filepath.Join(dir, ".deployah", "crds", "clusterwidget.yaml")), - `e2e-marker: "crd-lifecycle"`, - `e2e-marker: "create-skipped"`, - 1, - ) - require.NoError(t, os.WriteFile( - filepath.Join(dir, ".deployah", "crds", "clusterwidget.yaml"), - []byte(patched), 0o600)) - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes", "--crds", "create") - crd = getCRD(t, ext, crdLifecycleName) - assert.Equal(t, "crd-lifecycle", crd.Labels["e2e-marker"], - "--crds create must not replace an existing CRD") - - // --crds create-replace server-side-applies over the existing CRD. - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes", "--crds", "create-replace") - require.NoError(t, wait.For(func(ctx context.Context) (bool, error) { - live, getErr := ext.ApiextensionsV1().CustomResourceDefinitions().Get( - ctx, crdLifecycleName, metav1.GetOptions{}) - if getErr != nil { - return false, getErr + if _, ok := seen[sc.ScenarioDir]; ok { + continue } - return live.Labels["e2e-marker"] == "create-skipped", nil - }, wait.WithTimeout(2*time.Minute), wait.WithInterval(time.Second))) - - // CRDs are never pruned on uninstall. - run(t, "delete", "crd-lifecycle", "dev", - "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah") - _, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( - t.Context(), crdLifecycleName, metav1.GetOptions{}) - require.NoError(t, err, "CRD must survive deployah delete") -} + seen[sc.ScenarioDir] = struct{}{} -// TestDeployScenarios deploys each discovered fixture and asserts expect.yaml. -func (s *E2ESuite) TestDeployScenarios() { - for _, sc := range s.scenarios { - s.Run(sc.Name, func() { - t := s.T() - - // Load before the chdir; sc.Dir is absolute so order is safe either - // way, but reading first keeps the dependency obvious. - exp := loadExpectations(t, sc.Dir) - t.Chdir(sc.Dir) // deploy reads deployah.yaml from the cwd - - // Registered before deploy: a partial apply still gets torn down. - t.Cleanup(func() { - if err := runErr(t, "delete", sc.Project, exp.Env, - "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah"); err != nil { - t.Logf("cleanup delete failed (non-fatal): %v", err) - } - }) + dir := filepath.Join(s.scenariosDir, sc.ScenarioDir) + fx, loadErr := inttest.LoadE2EFixture(sc.E2EFixturePath, dir) + require.NoErrorf(t, loadErr, "load %s", sc.E2EFixturePath) + project, projErr := projectName(dir) + require.NoError(t, projErr) - run(t, "deploy", exp.Env, "--context", "kind-deployah", "--yes") - s.assertExpectations(t, exp) + cases = append(cases, e2eCase{ + Name: sc.ScenarioDir, + Dir: dir, + Project: project, + Fixture: fx, + Parallel: fx.RunParallel(), }) } + return cases } -func (s *E2ESuite) TestTaskRun() { - t := s.T() - s.prepareTaskdemo(t) - - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - run(t, "run", "backfill", "dev", "--context", "kind-deployah", "--yes") - run(t, "run", "backfill", "dev", "--context", "kind-deployah", "--yes") - - res := s.client.Resources("default") - var jobs batchv1.JobList - require.NoError(t, res.List(t.Context(), &jobs, - resources.WithLabelSelector("deployah.dev/project=taskdemo,deployah.dev/component=backfill"))) - assert.GreaterOrEqual(t, len(jobs.Items), 2, "two runs get unique Job names") - for _, job := range jobs.Items { - assert.GreaterOrEqual(t, job.Status.Succeeded, int32(1), "job %s", job.Name) - } -} - -func (s *E2ESuite) TestTaskLogs() { - t := s.T() - s.prepareTaskdemo(t) - - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - run(t, "run", "backfill", "dev", "--context", "kind-deployah", "--yes") - out := run(t, "logs", "taskdemo", "--component=backfill", "--environment=dev", - "--no-follow", "--context", "kind-deployah") - assert.Contains(t, out, "backfill-ok") -} - -func (s *E2ESuite) TestDeleteCleansCLIJobs() { - t := s.T() - s.prepareTaskdemo(t) - - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - run(t, "run", "backfill", "dev", "--context", "kind-deployah", "--yes") - run(t, "delete", "taskdemo", "dev", "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah") - - res := s.client.Resources("default") - var jobs batchv1.JobList - require.NoError(t, res.List(t.Context(), &jobs, - resources.WithLabelSelector("deployah.dev/project=taskdemo,deployah.dev/environment=dev"))) - assert.Empty(t, jobs.Items) -} - -func (s *E2ESuite) TestTaskSchedule() { - t := s.T() - src := filepath.Join(s.testdataDir, "task-schedule") - dir := t.TempDir() - copyTree(t, src, dir) - t.Chdir(dir) +func (s *E2ESuite) runE2ECase(t *testing.T, c e2eCase) { + t.Helper() + ns := fixtureNamespace(c.Name) + s.createNamespace(t, ns) t.Cleanup(func() { - if err := runErr(t, "delete", "taskcron", "dev", + if t.Failed() && *flagPreserve { + t.Logf("preserving namespace %s (-e2e.preserve)", ns) + return + } + // Helm uninstall drops pods so PVC protection finalizers can + // clear. t.Context() is canceled before Cleanup. + cleanupCtx, cancel := context.WithTimeout(context.Background(), namespaceWaitTimeout) + defer cancel() + if _, _, delErr := runInErrContext(t, cleanupCtx, c.Dir, "delete", c.Project, c.Fixture.Env, "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah"); err != nil { - t.Logf("cleanup delete failed (non-fatal): %v", err) + "--context", kindContext, "--namespace", ns); delErr != nil { + t.Logf("cleanup deployah delete failed (non-fatal): %v", delErr) } + s.deleteNamespace(t, ns) }) - - run(t, "deploy", "dev", "--context", "kind-deployah", "--yes") - - res := s.client.Resources("default") - var cronjobs batchv1.CronJobList - require.NoError(t, res.List(t.Context(), &cronjobs, - resources.WithLabelSelector("deployah.dev/project=taskcron,deployah.dev/component=cleanup"))) - require.Len(t, cronjobs.Items, 1) - cj := cronjobs.Items[0] - assert.Empty(t, cj.Annotations["helm.sh/hook"]) - assert.Equal(t, "@every 1h", cj.Spec.Schedule) - require.NotNil(t, cj.Spec.TimeZone) - assert.Equal(t, "Etc/UTC", *cj.Spec.TimeZone) - assert.Equal(t, batchv1.ForbidConcurrent, cj.Spec.ConcurrencyPolicy) - require.NotNil(t, cj.Spec.SuccessfulJobsHistoryLimit) - assert.Equal(t, int32(3), *cj.Spec.SuccessfulJobsHistoryLimit) - require.NotNil(t, cj.Spec.FailedJobsHistoryLimit) - assert.Equal(t, int32(3), *cj.Spec.FailedJobsHistoryLimit) - assert.Equal(t, corev1.RestartPolicyOnFailure, cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy) - assert.Nil(t, cj.Spec.StartingDeadlineSeconds) - require.NotNil(t, cj.Spec.JobTemplate.Spec.CompletionMode) - assert.Equal(t, batchv1.IndexedCompletion, *cj.Spec.JobTemplate.Spec.CompletionMode) - require.Len(t, cj.Spec.JobTemplate.Spec.Template.Spec.Containers, 1) - assert.Equal(t, []string{"echo", "cleanup-ok"}, cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command) - require.NotNil(t, cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) - assert.Equal(t, int64(3600), *cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) - - run(t, "run", "cleanup", "dev", "--context", "kind-deployah", "--yes") - var jobs batchv1.JobList - require.NoError(t, res.List(t.Context(), &jobs, - resources.WithLabelSelector("deployah.dev/project=taskcron,deployah.dev/component=cleanup"))) - require.NotEmpty(t, jobs.Items) - var cliJob *batchv1.Job - for i := range jobs.Items { - job := &jobs.Items[i] - if job.Labels["deployah.dev/managed-by"] == "deployah" { - cliJob = job - break - } - } - require.NotNil(t, cliJob, "deployah run must create a standalone Job") - assert.Nil(t, cliJob.Spec.ActiveDeadlineSeconds) + s.assertE2EFixture(t, c.Dir, c.Project, ns, c.Fixture) } -// prepareTaskdemo copies the task-migrate-smoke scenario into a temp dir, -// makes it the working directory, and registers a best-effort delete. -func (s *E2ESuite) prepareTaskdemo(t *testing.T) { +func (s *E2ESuite) preloadImages(t *testing.T) { t.Helper() - src := filepath.Join(s.testdataDir, "task-migrate-smoke") - dir := t.TempDir() - copyTree(t, src, dir) - t.Chdir(dir) + m, err := localkube.New() + require.NoError(t, err) t.Cleanup(func() { - if err := runErr(t, "delete", "taskdemo", "dev", - "--yes", "--wait", "--allow-missing-platform", - "--context", "kind-deployah"); err != nil { - t.Logf("cleanup delete failed (non-fatal): %v", err) + if closeErr := m.Close(); closeErr != nil { + t.Logf("close localkube manager: %v", closeErr) } }) + for _, img := range inttest.AllowedE2EImages { + t.Logf("preloading image %s", img) + require.NoErrorf(t, m.LoadImage(t.Context(), clusterName, img), "load %s", img) + } } -func (s *E2ESuite) assertExpectations(tb testing.TB, exp expectations) { +func newRESTMapper(tb testing.TB, c klient.Client) *restmapper.DeferredDiscoveryRESTMapper { tb.Helper() - res := s.client.Resources(exp.Namespace) - ctx := tb.Context() - - for _, dep := range exp.Deployments { - target := &appsv1.Deployment{ - Name: dep.Name, Namespace: exp.Namespace, - } - - // Spelled out rather than using the DeploymentAvailable shorthand, so - // the condition under test is unambiguous in the source. - err := wait.For( - conditions.New(res).DeploymentConditionMatch( - target, appsv1.DeploymentAvailable, corev1.ConditionTrue), - wait.WithTimeout(5*time.Minute), - wait.WithInterval(2*time.Second), - ) - require.NoErrorf(tb, err, "deployment %s/%s never became Available", - exp.Namespace, dep.Name) - - var live appsv1.Deployment - require.NoError(tb, res.Get(ctx, dep.Name, exp.Namespace, &live)) - dumpActual(tb, &live) - - for key, val := range dep.Labels { // subset match - assert.Equalf(tb, val, live.Labels[key], - "deployment %s label %s", dep.Name, key) - } - - containers := live.Spec.Template.Spec.Containers - require.NotEmptyf(tb, containers, "deployment %s has no containers", dep.Name) - assert.Equalf(tb, dep.Image, containers[0].Image, - "deployment %s image", dep.Name) - - if dep.PortName != "" { - require.NotEmptyf(tb, containers[0].Ports, - "deployment %s has no ports", dep.Name) - assert.Equalf(tb, dep.PortName, containers[0].Ports[0].Name, - "deployment %s port name", dep.Name) - } - if dep.Replicas > 0 { - require.NotNil(tb, live.Spec.Replicas) - assert.Equalf(tb, dep.Replicas, *live.Spec.Replicas, - "deployment %s replicas", dep.Name) - } - } - - for _, sts := range exp.StatefulSets { - target := &appsv1.StatefulSet{ - Name: sts.Name, Namespace: exp.Namespace, - } - err := wait.For( - conditions.New(res).ResourceMatch(target, func(obj k8s.Object) bool { - live, ok := obj.(*appsv1.StatefulSet) - if !ok || live.Spec.Replicas == nil { - return false - } - return live.Status.ReadyReplicas >= *live.Spec.Replicas && - live.Status.ReadyReplicas > 0 - }), - wait.WithTimeout(5*time.Minute), - wait.WithInterval(2*time.Second), - ) - require.NoErrorf(tb, err, "statefulset %s/%s never became ready", - exp.Namespace, sts.Name) - - var live appsv1.StatefulSet - require.NoError(tb, res.Get(ctx, sts.Name, exp.Namespace, &live)) - dumpActual(tb, &live) - - for key, val := range sts.Labels { - assert.Equalf(tb, val, live.Labels[key], - "statefulset %s label %s", sts.Name, key) - } - containers := live.Spec.Template.Spec.Containers - require.NotEmptyf(tb, containers, "statefulset %s has no containers", sts.Name) - assert.Equalf(tb, sts.Image, containers[0].Image, - "statefulset %s image", sts.Name) - if sts.PortName != "" { - require.NotEmptyf(tb, containers[0].Ports, - "statefulset %s has no ports", sts.Name) - assert.Equalf(tb, sts.PortName, containers[0].Ports[0].Name, - "statefulset %s port name", sts.Name) - } - if sts.Replicas > 0 { - require.NotNil(tb, live.Spec.Replicas) - assert.Equalf(tb, sts.Replicas, *live.Spec.Replicas, - "statefulset %s replicas", sts.Name) - } - } - - for _, svc := range exp.Services { - var live corev1.Service - require.NoError(tb, res.Get(ctx, svc.Name, exp.Namespace, &live)) - dumpActual(tb, &live) - - require.NotEmptyf(tb, live.Spec.Ports, "service %s has no ports", svc.Name) - assert.Equalf(tb, svc.Port, live.Spec.Ports[0].Port, "service %s port", svc.Name) - - // TargetPort is an intstr; a named port lives in StrVal, not IntVal. - if svc.TargetPortName != "" { - assert.Equalf(tb, svc.TargetPortName, live.Spec.Ports[0].TargetPort.StrVal, - "service %s targetPort name", svc.Name) - } - if svc.ClusterIP == "None" { - assert.Equalf(tb, corev1.ClusterIPNone, live.Spec.ClusterIP, - "service %s should be headless", svc.Name) - } - for key, val := range svc.Selector { - assert.Equalf(tb, val, live.Spec.Selector[key], - "service %s selector %s", svc.Name, key) - } - } - - for _, wantPVC := range exp.PVCs { - var pvcs corev1.PersistentVolumeClaimList - require.NoError(tb, res.List(ctx, &pvcs)) - matched := 0 - for _, pvc := range pvcs.Items { - if !strings.HasPrefix(pvc.Name, wantPVC.NamePrefix) { - continue - } - matched++ - if wantPVC.Phase != "" { - assert.Equalf(tb, wantPVC.Phase, string(pvc.Status.Phase), - "pvc %s phase", pvc.Name) - } - if wantPVC.Storage != "" { - req := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - assert.Equalf(tb, wantPVC.Storage, req.String(), - "pvc %s storage", pvc.Name) - } - } - assert.GreaterOrEqualf(tb, matched, wantPVC.MinCount, - "pvcs with prefix %s", wantPVC.NamePrefix) - } - - if exp.Pods.LabelSelector != "" { - var pods corev1.PodList - require.NoError(tb, res.List(ctx, &pods, - resources.WithLabelSelector(exp.Pods.LabelSelector))) - assert.GreaterOrEqualf(tb, len(pods.Items), exp.Pods.MinCount, - "pods matching %s", exp.Pods.LabelSelector) - for _, pod := range pods.Items { - assert.Equalf(tb, exp.Pods.Phase, string(pod.Status.Phase), - "pod %s phase", pod.Name) - } - } - - for _, wantJob := range exp.Jobs { - target := &batchv1.Job{ - Name: wantJob.Name, Namespace: exp.Namespace, - } - err := wait.For( - conditions.New(res).ResourceMatch(target, func(obj k8s.Object) bool { - live, ok := obj.(*batchv1.Job) - return ok && live.Status.Succeeded >= wantJob.Succeeded - }), - wait.WithTimeout(5*time.Minute), - wait.WithInterval(2*time.Second), - ) - require.NoErrorf(tb, err, "job %s/%s never reached succeeded>=%d", - exp.Namespace, wantJob.Name, wantJob.Succeeded) - } + disco, err := discovery.NewDiscoveryClientForConfig(c.RESTConfig()) + require.NoError(tb, err) + return restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(disco)) } func newKlient(tb testing.TB, kubeconfigPath, contextName string) klient.Client { @@ -664,56 +272,48 @@ func newKlient(tb testing.TB, kubeconfigPath, contextName string) klient.Client return c } -func discoverScenarios(tb testing.TB, testdataDir string) []scenario { - tb.Helper() - entries, err := os.ReadDir(testdataDir) - require.NoErrorf(tb, err, "read %s", testdataDir) - - var found []scenario - for _, entry := range entries { - if !entry.IsDir() { - continue - } - dir := filepath.Join(testdataDir, entry.Name()) // absolute - specPath := filepath.Join(dir, "deployah.yaml") - if !regularFileExists(specPath) || - !regularFileExists(filepath.Join(dir, "expect.yaml")) { - continue - } - - raw, readErr := os.ReadFile(specPath) // #nosec G304 -- path under testdata/ - require.NoError(tb, readErr) - var spec struct { - Project string `yaml:"project"` - } - require.NoError(tb, yaml.Unmarshal(raw, &spec)) - require.NotEmptyf(tb, spec.Project, "%s has no project field", specPath) - - found = append(found, scenario{ - Name: entry.Name(), Dir: dir, Project: spec.Project, - }) +// newResources returns a dedicated Resources client. klient.Client.Resources +// mutates a shared namespace field, which is not safe under t.Parallel. +func (s *E2ESuite) newResources(ns string) (*resources.Resources, error) { + res, err := resources.New(s.client.RESTConfig()) + if err != nil { + return nil, err } - require.NotEmptyf(tb, found, "no scenarios found in %s", testdataDir) - return found + return res.WithNamespace(ns), nil } -func regularFileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.Mode().IsRegular() +func projectName(dir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dir, "deployah.yaml")) // #nosec G304 -- scenario spec + if err != nil { + return "", fmt.Errorf("read deployah.yaml: %w", err) + } + var spec struct { + Project string `yaml:"project"` + } + if err = yaml.Unmarshal(raw, &spec); err != nil { + return "", fmt.Errorf("parse deployah.yaml: %w", err) + } + if spec.Project == "" { + return "", fmt.Errorf("%s/deployah.yaml has no project field", dir) + } + return spec.Project, nil } -func loadExpectations(tb testing.TB, dir string) expectations { - tb.Helper() - raw, err := os.ReadFile(filepath.Join(dir, "expect.yaml")) // #nosec G304 -- path under testdata/ - require.NoError(tb, err) - - var exp expectations - require.NoError(tb, yaml.Unmarshal(raw, &exp)) - require.NotEmptyf(tb, exp.Env, "%s/expect.yaml has no env field", dir) - if exp.Namespace == "" { - exp.Namespace = "default" +func fixtureNamespace(name string) string { + var b strings.Builder + b.WriteString("e2e-") + for _, r := range strings.ToLower(name) { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + b.WriteRune(r) + continue + } + b.WriteRune('-') + } + ns := strings.Trim(b.String(), "-") + if len(ns) > maxDNSLabel { + ns = strings.Trim(ns[:maxDNSLabel], "-") } - return exp + return ns } func run(tb testing.TB, args ...string) string { @@ -722,7 +322,7 @@ func run(tb testing.TB, args ...string) string { return stdout } -// runCapture runs deployah and returns stdout and stderr on success. +// runCapture runs deployah in the process cwd and returns stdout and stderr. func runCapture(tb testing.TB, args ...string) (stdout, stderr string) { tb.Helper() appIO, _, out, errOut := nabattest.NewIO() @@ -733,101 +333,39 @@ func runCapture(tb testing.TB, args ...string) (stdout, stderr string) { return out.String(), errOut.String() } -func copyTree(tb testing.TB, src, dst string) { +func runIn(tb testing.TB, dir string, args ...string) (stdout, stderr string) { tb.Helper() - err := filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, relErr := filepath.Rel(src, path) - if relErr != nil { - return relErr - } - target := filepath.Join(dst, rel) - if d.IsDir() { - return os.MkdirAll(target, 0o750) - } - // G122 flags the Walk-callback path as symlink-TOCTOU prone; src is - // testdata/ and dst is a t.TempDir(), both test-controlled. - in, openErr := os.Open(path) // #nosec G304 G122 -- path under testdata/ - if openErr != nil { - return openErr - } - defer in.Close() //nolint:errcheck // read-only copy helper - out, createErr := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- temp fixture copy - if createErr != nil { - return createErr - } - _, copyErr := io.Copy(out, in) - closeErr := out.Close() - if copyErr != nil { - return copyErr - } - return closeErr - }) - require.NoError(tb, err) -} - -func readFixtureFile(tb testing.TB, path string) string { - tb.Helper() - raw, err := os.ReadFile(path) // #nosec G304 -- path under test-controlled temp dir - require.NoError(tb, err) - return string(raw) + stdout, stderr, err := runInErr(tb, dir, args...) + require.NoErrorf(tb, err, "deployah %s\nstderr:\n%s", + strings.Join(args, " "), stderr) + return stdout, stderr } -func newApiextensionsClient(tb testing.TB, kubeconfigPath, contextName string) apiextensionsclient.Interface { +func runInErr(tb testing.TB, dir string, args ...string) (stdout, stderr string, err error) { tb.Helper() - rules := clientcmd.NewDefaultClientConfigLoadingRules() - rules.ExplicitPath = kubeconfigPath - overrides := &clientcmd.ConfigOverrides{CurrentContext: contextName} - restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - rules, overrides).ClientConfig() - require.NoError(tb, err) - cs, err := apiextensionsclient.NewForConfig(restCfg) - require.NoError(tb, err) - return cs + return runInErrContext(tb, tb.Context(), dir, args...) } -func getCRD(tb testing.TB, ext apiextensionsclient.Interface, name string) *apiextensionsv1.CustomResourceDefinition { +func runInErrContext(tb testing.TB, ctx context.Context, dir string, args ...string) (stdout, stderr string, err error) { tb.Helper() - crd, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( - tb.Context(), name, metav1.GetOptions{}) - require.NoError(tb, err) - return crd -} - -func waitCRDEstablished(tb testing.TB, ext apiextensionsclient.Interface, name string) *apiextensionsv1.CustomResourceDefinition { - tb.Helper() - var latest *apiextensionsv1.CustomResourceDefinition - require.NoError(tb, wait.For(func(ctx context.Context) (bool, error) { - crd, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( - ctx, name, metav1.GetOptions{}) - if err != nil { - return false, err - } - latest = crd - for _, cond := range crd.Status.Conditions { - if cond.Type == apiextensionsv1.Established && - cond.Status == apiextensionsv1.ConditionTrue { - return true, nil - } - } - return false, nil - }, wait.WithTimeout(2*time.Minute), wait.WithInterval(time.Second))) - require.NotNil(tb, latest) - return latest + appIO, _, out, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(appIO)) + opts := []nabattest.RunOption{nabattest.WithContext(ctx)} + if dir != "" { + opts = append(opts, nabattest.WithDir(dir)) + } + err = nabattest.RunParallel(tb, app, args, opts...) + return out.String(), errOut.String(), err } func runErr(tb testing.TB, args ...string) error { tb.Helper() - appIO, _, _, errOut := nabattest.NewIO() - app := cmd.NewApp(nabat.WithIO(appIO)) - err := nabattest.Run(tb, app, args) + _, stderr, err := runInErr(tb, "", args...) if err == nil { return nil } - if stderr := strings.TrimSpace(errOut.String()); stderr != "" { - return fmt.Errorf("%w\nstderr:\n%s", err, stderr) + if trimmed := strings.TrimSpace(stderr); trimmed != "" { + return fmt.Errorf("%w\nstderr:\n%s", err, trimmed) } return err } @@ -846,11 +384,15 @@ func requireNoCollision(tb testing.TB) { tb.Helper() m, err := localkube.New() require.NoError(tb, err) - defer m.Close() //nolint:errcheck // best-effort cleanup of provider resources + tb.Cleanup(func() { + if closeErr := m.Close(); closeErr != nil { + tb.Logf("close localkube manager: %v", closeErr) + } + }) - _, getErr := m.Get(tb.Context(), "deployah") + _, getErr := m.Get(tb.Context(), clusterName) if errors.Is(getErr, localkube.ErrNotFound) { - return // no existing cluster, nothing to do + return } require.NoError(tb, getErr) @@ -861,18 +403,3 @@ func requireNoCollision(tb testing.TB) { tb.Log("DEPLOYAH_E2E_FORCE=1: destroying the existing cluster") require.NoError(tb, runErr(tb, "cluster", "down", "--force")) } - -// dumpActual logs a live object as YAML when DEPLOYAH_E2E_DUMP=1, so a new -// scenario's expect.yaml can be curated from what deployah actually renders. -func dumpActual(tb testing.TB, obj any) { - tb.Helper() - if os.Getenv("DEPLOYAH_E2E_DUMP") != "1" { - return - } - out, err := yaml.Marshal(obj) - if err != nil { - tb.Logf("dump failed: %v", err) - return - } - tb.Logf("ACTUAL:\n%s", out) -} diff --git a/internal/e2e/testdata/basic-web-service/expect.yaml b/internal/e2e/testdata/basic-web-service/expect.yaml deleted file mode 100644 index d93351b..0000000 --- a/internal/e2e/testdata/basic-web-service/expect.yaml +++ /dev/null @@ -1,22 +0,0 @@ -env: dev -namespace: default -deployments: - - name: basic-web-service-dev - replicas: 1 - image: docker.io/library/nginx:latest - portName: http - labels: - deployah.dev/project: basic-web-service - deployah.dev/environment: dev - deployah.dev/component: web -services: - - name: basic-web-service-dev - port: 80 - targetPortName: http - selector: - app.kubernetes.io/instance: basic-web-service-dev - app.kubernetes.io/name: web -pods: - labelSelector: "deployah.dev/project=basic-web-service,deployah.dev/environment=dev" - minCount: 1 - phase: Running diff --git a/internal/e2e/testdata/stateful-basic/deployah.yaml b/internal/e2e/testdata/stateful-basic/deployah.yaml deleted file mode 100644 index 989ad7e..0000000 --- a/internal/e2e/testdata/stateful-basic/deployah.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1-alpha.5 -project: stateful-basic -components: - cache: - kind: stateful - image: redis:7-alpine - port: 6379 - resourcePreset: nano - environments: [dev] - persistence: - size: 1Gi - mountPath: /data -environments: - dev: {} diff --git a/internal/e2e/testdata/stateful-basic/expect.yaml b/internal/e2e/testdata/stateful-basic/expect.yaml deleted file mode 100644 index 255363c..0000000 --- a/internal/e2e/testdata/stateful-basic/expect.yaml +++ /dev/null @@ -1,34 +0,0 @@ -env: dev -namespace: default -statefulSets: - - name: stateful-basic-dev-cache - replicas: 1 - image: docker.io/library/redis:7-alpine - portName: http - labels: - deployah.dev/project: stateful-basic - deployah.dev/environment: dev - deployah.dev/component: cache -services: - - name: stateful-basic-dev-cache - port: 80 - targetPortName: http - selector: - app.kubernetes.io/instance: stateful-basic-dev - app.kubernetes.io/name: cache - - name: stateful-basic-dev-cache-headless - port: 80 - targetPortName: http - clusterIP: None - selector: - app.kubernetes.io/instance: stateful-basic-dev - app.kubernetes.io/name: cache -pvcs: - - namePrefix: data-stateful-basic-dev-cache- - minCount: 1 - phase: Bound - storage: 1Gi -pods: - labelSelector: "deployah.dev/project=stateful-basic,deployah.dev/environment=dev" - minCount: 1 - phase: Running diff --git a/internal/e2e/testdata/task-migrate-smoke/expect.yaml b/internal/e2e/testdata/task-migrate-smoke/expect.yaml deleted file mode 100644 index 1d6b862..0000000 --- a/internal/e2e/testdata/task-migrate-smoke/expect.yaml +++ /dev/null @@ -1,22 +0,0 @@ -env: dev -namespace: default -deployments: - - name: taskdemo-dev-api - replicas: 1 - image: docker.io/library/nginx:latest - portName: http - labels: - deployah.dev/project: taskdemo - deployah.dev/environment: dev - deployah.dev/component: api -services: - - name: taskdemo-dev-api - port: 80 - targetPortName: http - selector: - app.kubernetes.io/instance: taskdemo-dev - app.kubernetes.io/name: api -pods: - labelSelector: "deployah.dev/project=taskdemo,deployah.dev/environment=dev,deployah.dev/component=api" - minCount: 1 - phase: Running diff --git a/internal/e2e/testdata/worker-basic/expect.yaml b/internal/e2e/testdata/worker-basic/expect.yaml deleted file mode 100644 index 4e350ae..0000000 --- a/internal/e2e/testdata/worker-basic/expect.yaml +++ /dev/null @@ -1,14 +0,0 @@ -env: dev -namespace: default -deployments: - - name: worker-basic-dev - replicas: 1 - image: docker.io/library/busybox:1.36 - labels: - deployah.dev/project: worker-basic - deployah.dev/environment: dev - deployah.dev/component: worker -pods: - labelSelector: "deployah.dev/project=worker-basic,deployah.dev/environment=dev" - minCount: 1 - phase: Running diff --git a/internal/spec/loader.go b/internal/spec/loader.go index 7bfd1ea..69d30cd 100644 --- a/internal/spec/loader.go +++ b/internal/spec/loader.go @@ -134,14 +134,24 @@ func ResolveEnvironment(environments map[string]Environment, platform *PlatformC } // resolveEnvFile determines which env file to use for the given environment, -// following Deployah's resolution order. Returns the path, whether it was -// explicitly set, and an error if explicitly set but missing. -func resolveEnvFile(env *Environment, envName string) (string, bool, error) { +// following Deployah's resolution order. Candidates are resolved against +// specDir (the directory containing the spec), not the process working +// directory. Returns the path, whether it was explicitly set, and an error +// if explicitly set but missing. +func resolveEnvFile(env *Environment, envName, specDir string) (string, bool, error) { + join := func(rel string) string { + if filepath.IsAbs(rel) { + return rel + } + return filepath.Join(specDir, rel) + } + if env.EnvFile != "" { - if fileExists(env.EnvFile) { - return env.EnvFile, true, nil + path := join(env.EnvFile) + if fileExists(path) { + return path, true, nil } - return "", true, fmt.Errorf("explicit envFile %q does not exist", env.EnvFile) + return "", true, fmt.Errorf("explicit envFile %q does not exist (resolved %q)", env.EnvFile, path) } sanitizedName := sanitizeEnvName(envName) @@ -152,7 +162,8 @@ func resolveEnvFile(env *Environment, envName string) (string, bool, error) { ".env", filepath.Join(".deployah", ".env"), } - for _, path := range candidates { + for _, rel := range candidates { + path := join(rel) if fileExists(path) { return path, false, nil } @@ -255,7 +266,7 @@ func Load(ctx context.Context, path, desiredEnv string, platform *PlatformConfig slog.InfoContext(ctx, "selected environment", "environment", envName) - envFilePath, explicitlySet, err := resolveEnvFile(env, envName) + envFilePath, explicitlySet, err := resolveEnvFile(env, envName, filepath.Dir(path)) if err != nil { return nil, fmt.Errorf("failed to resolve environment file: %w", err) } diff --git a/internal/spec/loader_test.go b/internal/spec/loader_test.go index e8b74c7..12efc17 100644 --- a/internal/spec/loader_test.go +++ b/internal/spec/loader_test.go @@ -229,6 +229,8 @@ func TestSanitizeEnvName(t *testing.T) { // TestResolveEnvFileWithSanitization verifies env file resolution for names // containing wildcards and path separators. func TestResolveEnvFileWithSanitization(t *testing.T) { + t.Parallel() + tests := []struct { name string envName string @@ -283,23 +285,27 @@ func TestResolveEnvFileWithSanitization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Chdir(t.TempDir()) + t.Parallel() + specDir := t.TempDir() for _, file := range tt.setupFiles { - dir := filepath.Dir(file) - if dir != "." && dir != "" { - mkdirErr := os.MkdirAll(dir, 0o750) - require.NoError(t, mkdirErr) - } - writeErr := os.WriteFile(file, []byte("TEST_VAR=test"), 0o600) + full := filepath.Join(specDir, file) + dir := filepath.Dir(full) + mkdirErr := os.MkdirAll(dir, 0o750) + require.NoError(t, mkdirErr) + writeErr := os.WriteFile(full, []byte("TEST_VAR=test"), 0o600) require.NoError(t, writeErr) } env := &Environment{} - path, explicit, resolveErr := resolveEnvFile(env, tt.envName) + path, explicit, resolveErr := resolveEnvFile(env, tt.envName, specDir) require.NoError(t, resolveErr) - assert.Equal(t, tt.expectedPath, path) + want := tt.expectedPath + if want != "" { + want = filepath.Join(specDir, tt.expectedPath) + } + assert.Equal(t, want, path) assert.Equal(t, tt.expectedExplicit, explicit) }) } @@ -308,20 +314,63 @@ func TestResolveEnvFileWithSanitization(t *testing.T) { // TestResolveEnvFileExplicitWithWildcard verifies explicit env files work // when the environment name contains wildcards. func TestResolveEnvFileExplicitWithWildcard(t *testing.T) { - t.Chdir(t.TempDir()) + t.Parallel() + + specDir := t.TempDir() explicitFile := "custom.env" - err := os.WriteFile(explicitFile, []byte("EXPLICIT_VAR=explicit"), 0o600) + err := os.WriteFile(filepath.Join(specDir, explicitFile), []byte("EXPLICIT_VAR=explicit"), 0o600) require.NoError(t, err) env := &Environment{ EnvFile: explicitFile, } - path, explicit, err := resolveEnvFile(env, "review/*") + path, explicit, err := resolveEnvFile(env, "review/*", specDir) require.NoError(t, err) - assert.Equal(t, explicitFile, path) + assert.Equal(t, filepath.Join(specDir, explicitFile), path) + assert.True(t, explicit) +} + +// TestResolveEnvFile_MissingExplicitIncludesResolvedPath reports the +// spec-relative envFile and the path resolved against specDir. +func TestResolveEnvFile_MissingExplicitIncludesResolvedPath(t *testing.T) { + t.Parallel() + + specDir := t.TempDir() + env := &Environment{EnvFile: "missing.env"} + path, explicit, err := resolveEnvFile(env, "dev", specDir) + require.Error(t, err) assert.True(t, explicit) + assert.Empty(t, path) + assert.ErrorContains(t, err, `explicit envFile "missing.env" does not exist`) + assert.ErrorContains(t, err, filepath.Join(specDir, "missing.env")) +} + +// TestLoad_EnvFileRelativeToSpecDir loads a spec whose .env.dev lives next +// to the spec file, while the process cwd is elsewhere (no [os.Chdir]). +func TestLoad_EnvFileRelativeToSpecDir(t *testing.T) { + t.Parallel() + + specDir := t.TempDir() + specYAML := `apiVersion: v1-alpha.5 +project: withdir +components: + web: + image: ${IMAGE} + port: 80 + environments: [dev] +environments: + dev: + envFile: .env.dev +` + require.NoError(t, os.WriteFile(filepath.Join(specDir, "deployah.yaml"), []byte(specYAML), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(specDir, ".env.dev"), []byte("DPY_VAR_IMAGE=nginx:1.27\n"), 0o600)) + + got, err := Load(t.Context(), filepath.Join(specDir, "deployah.yaml"), "dev", nil) + require.NoError(t, err) + require.Contains(t, got.Components, "web") + assert.Equal(t, "nginx:1.27", got.Components["web"].Image) } // TestSave_WritesParseableYAML verifies Save writes a spec that round-trips diff --git a/internal/testing/doc.go b/internal/testing/doc.go index edbfdca..6c3de74 100644 --- a/internal/testing/doc.go +++ b/internal/testing/doc.go @@ -15,7 +15,8 @@ // Package testing provides integration test helpers for Deployah scenarios. // // Scenario directories under scenarios/ hold sample specs, environment -// files, and expected Kubernetes output. [DiscoverScenarios] finds them -// automatically; [IntegrationTestSuite] loads a spec, generates a chart, -// renders templates, and compares results to golden files. +// files, expected Kubernetes output, and optional e2e.yaml Kind fixtures. +// [DiscoverScenarios] finds them; [IntegrationTestSuite] loads a spec, +// generates a chart, renders templates, and compares results to golden +// files. [LoadE2EFixture] decodes e2e.yaml. package testing diff --git a/internal/testing/e2e.schema.json b/internal/testing/e2e.schema.json new file mode 100644 index 0000000..c1e4814 --- /dev/null +++ b/internal/testing/e2e.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://deployah.dev/schemas/e2e.schema.json", + "title": "Deployah e2e fixture", + "description": "Kind cluster assertions for a scenarios/*/e2e.yaml file. Exactly one of resources (simple deploy-then-assert) or steps (explicit CLI sequence) must be set.", + "type": "object", + "additionalProperties": false, + "required": ["env"], + "oneOf": [ + { + "required": ["resources"], + "not": { "required": ["steps"] } + }, + { + "required": ["steps"], + "not": { "required": ["resources"] } + } + ], + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "Positional environment argument passed to deploy/run/delete." + }, + "parallel": { + "type": "boolean", + "description": "When false, the fixture runs sequentially after parallel fixtures. Default true." + }, + "timeout": { + "type": "string", + "description": "Default per-step wait (Go duration, e.g. 3m). Default 3m." + }, + "resources": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/resourceAssertion" } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/step" } + } + }, + "$defs": { + "resourceAssertion": { + "type": "object", + "additionalProperties": false, + "required": ["match"], + "properties": { + "match": { + "type": "object", + "description": "Partial Kubernetes object compared as a subset of the live object.", + "required": ["apiVersion", "kind"], + "properties": { + "apiVersion": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "minLength": 1 } + } + }, + "minCount": { + "type": "integer", + "minimum": 0, + "description": "Minimum matching resources when selecting by labels. Default 1. 0 asserts absence. Must be 0 or 1 when match.metadata.name is set." + } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "oneOf": [ + { "required": ["deploy"], "not": { "anyOf": [{ "required": ["run"] }, { "required": ["logs"] }, { "required": ["delete"] }] } }, + { "required": ["run"], "not": { "anyOf": [{ "required": ["deploy"] }, { "required": ["logs"] }, { "required": ["delete"] }] } }, + { "required": ["logs"], "not": { "anyOf": [{ "required": ["deploy"] }, { "required": ["run"] }, { "required": ["delete"] }] } }, + { "required": ["delete"], "not": { "anyOf": [{ "required": ["deploy"] }, { "required": ["run"] }, { "required": ["logs"] }] } } + ], + "properties": { + "deploy": { "$ref": "#/$defs/deployOp" }, + "run": { "$ref": "#/$defs/runOp" }, + "logs": { "$ref": "#/$defs/logsOp" }, + "delete": { "$ref": "#/$defs/deleteOp" }, + "timeout": { "type": "string" }, + "stderrContains": { "type": "string" }, + "stdoutContains": { "type": "string" }, + "resources": { + "type": "array", + "items": { "$ref": "#/$defs/resourceAssertion" } + } + } + }, + "deployOp": { + "type": "object", + "additionalProperties": false, + "properties": { + "spec": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "runOp": { + "type": "object", + "additionalProperties": false, + "required": ["task"], + "properties": { + "task": { "type": "string", "minLength": 1 } + } + }, + "logsOp": { + "type": "object", + "additionalProperties": false, + "required": ["component", "contains"], + "properties": { + "component": { "type": "string", "minLength": 1 }, + "contains": { "type": "string", "minLength": 1 } + } + }, + "deleteOp": { + "type": "object", + "additionalProperties": false + } + } +} diff --git a/internal/testing/e2e_fixture.go b/internal/testing/e2e_fixture.go new file mode 100644 index 0000000..1be4355 --- /dev/null +++ b/internal/testing/e2e_fixture.go @@ -0,0 +1,314 @@ +// Copyright 2025 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 testing + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "sigs.k8s.io/yaml" + + k8sjson "k8s.io/apimachinery/pkg/util/json" +) + +// E2EFixtureFile is the Kind fixture filename in a scenario directory. +const E2EFixtureFile = "e2e.yaml" + +// DefaultE2EStepTimeout is the wait applied when e2e.yaml omits timeout. +const DefaultE2EStepTimeout = 3 * time.Minute + +// AllowedE2EImages is the Kind preload list. [LoadE2EFixture] rejects +// scenario specs that reference any other image. +var AllowedE2EImages = []string{ + "nginx:latest", + "nginx:1.26", + "busybox:1.36", + "redis:7-alpine", + "redis:7", + "postgres:16", +} + +// E2EFixture is the decoded form of scenarios/*/e2e.yaml, requiring +// exactly one of Resources or Steps. +type E2EFixture struct { + Env string `json:"env"` + Parallel *bool `json:"parallel,omitempty"` + Timeout string `json:"timeout,omitempty"` + Resources []ResourceAssertion `json:"resources,omitempty"` + Steps []Step `json:"steps,omitempty"` +} + +// ResourceAssertion is one cluster assertion. +// Match is a partial Kubernetes object compared with [DiffSubset]. +// MinCount defaults to 1; 0 requires the list to be empty. +// When Match sets metadata.name, MinCount may only be 0 or 1 because a +// named Get cannot count list items. +type ResourceAssertion struct { + Match map[string]any `json:"match"` + MinCount *int `json:"minCount,omitempty"` +} + +// Step is one CLI operation plus optional output and resource assertions. +// Exactly one of Deploy, Run, Logs, or Delete must be set. +type Step struct { + Deploy *DeployOp `json:"deploy,omitempty"` + Run *RunOp `json:"run,omitempty"` + Logs *LogsOp `json:"logs,omitempty"` + Delete *DeleteOp `json:"delete,omitempty"` + Timeout string `json:"timeout,omitempty"` + StderrContains string `json:"stderrContains,omitempty"` + StdoutContains string `json:"stdoutContains,omitempty"` + Resources []ResourceAssertion `json:"resources,omitempty"` +} + +// DeployOp maps to `deployah deploy`. +type DeployOp struct { + Spec string `json:"spec,omitempty"` + Args []string `json:"args,omitempty"` +} + +// RunOp maps to `deployah run`. +type RunOp struct { + Task string `json:"task"` +} + +// LogsOp maps to `deployah logs`. +type LogsOp struct { + Component string `json:"component"` + Contains string `json:"contains"` +} + +// DeleteOp maps to `deployah delete`. +type DeleteOp struct{} + +// RunParallel reports whether the fixture may run concurrently. +// A nil Parallel field means true. +func (f E2EFixture) RunParallel() bool { + return f.Parallel == nil || *f.Parallel +} + +// StepTimeout is the per-step wait: step override, then fixture default, +// then [DefaultE2EStepTimeout]. +func (f E2EFixture) StepTimeout(step Step) (time.Duration, error) { + raw := step.Timeout + if raw == "" { + raw = f.Timeout + } + if raw == "" { + return DefaultE2EStepTimeout, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("timeout %q: %w", raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("timeout %q must be positive", raw) + } + return d, nil +} + +// Count returns the minimum matching resources, or 1 when MinCount is unset. +func (r ResourceAssertion) Count() int { + if r.MinCount == nil { + return 1 + } + return *r.MinCount +} + +// OpName returns the operation name, or "" if none or more than one is set. +func (s Step) OpName() string { + n := 0 + name := "" + if s.Deploy != nil { + n++ + name = "deploy" + } + if s.Run != nil { + n++ + name = "run" + } + if s.Logs != nil { + n++ + name = "logs" + } + if s.Delete != nil { + n++ + name = "delete" + } + if n != 1 { + return "" + } + return name +} + +// LoadE2EFixture reads path, validates document shape, and checks that +// images in scenarioDir specs are on [AllowedE2EImages]. +func LoadE2EFixture(path, scenarioDir string) (E2EFixture, error) { + raw, err := os.ReadFile(path) // #nosec G304 -- scenario e2e.yaml under test + if err != nil { + return E2EFixture{}, fmt.Errorf("read e2e.yaml: %w", err) + } + jsonBytes, err := yaml.YAMLToJSON(raw) + if err != nil { + return E2EFixture{}, fmt.Errorf("parse e2e.yaml: %w", err) + } + var fx E2EFixture + if decErr := k8sjson.Unmarshal(jsonBytes, &fx); decErr != nil { + return E2EFixture{}, fmt.Errorf("decode e2e.yaml: %w", decErr) + } + if valErr := fx.validate(); valErr != nil { + return E2EFixture{}, valErr + } + if imgErr := validateScenarioImages(scenarioDir, fx); imgErr != nil { + return E2EFixture{}, imgErr + } + return fx, nil +} + +func (f E2EFixture) validate() error { + if f.Env == "" { + return fmt.Errorf("e2e.yaml: env is required") + } + hasRes := len(f.Resources) > 0 + hasSteps := len(f.Steps) > 0 + if hasRes == hasSteps { + return fmt.Errorf("e2e.yaml: exactly one of resources or steps is required") + } + if _, err := f.StepTimeout(Step{}); err != nil { + return fmt.Errorf("e2e.yaml: %w", err) + } + for i, ra := range f.Resources { + if err := ra.validate(); err != nil { + return fmt.Errorf("e2e.yaml resources[%d]: %w", i, err) + } + } + for i, step := range f.Steps { + if step.OpName() == "" { + return fmt.Errorf("e2e.yaml steps[%d]: exactly one of deploy, run, logs, delete is required", i) + } + if step.Run != nil && step.Run.Task == "" { + return fmt.Errorf("e2e.yaml steps[%d]: run.task is required", i) + } + if step.Logs != nil && (step.Logs.Component == "" || step.Logs.Contains == "") { + return fmt.Errorf("e2e.yaml steps[%d]: logs.component and logs.contains are required", i) + } + if _, err := f.StepTimeout(step); err != nil { + return fmt.Errorf("e2e.yaml steps[%d]: %w", i, err) + } + for j, ra := range step.Resources { + if err := ra.validate(); err != nil { + return fmt.Errorf("e2e.yaml steps[%d].resources[%d]: %w", i, j, err) + } + } + } + return nil +} + +func (r ResourceAssertion) validate() error { + if len(r.Match) == 0 { + return fmt.Errorf("match is required") + } + kind, hasKind := r.Match["kind"].(string) + apiVersion, hasAPI := r.Match["apiVersion"].(string) + if !hasKind || kind == "" || !hasAPI || apiVersion == "" { + return fmt.Errorf("match requires apiVersion and kind") + } + if r.Count() < 0 { + return fmt.Errorf("minCount must be >= 0") + } + if r.Count() > 1 { + metaMap, hasMeta := r.Match["metadata"].(map[string]any) + if hasMeta { + name, hasName := metaMap["name"].(string) + if hasName && name != "" { + return fmt.Errorf("minCount > 1 cannot be used with metadata.name") + } + } + } + return nil +} + +func validateScenarioImages(scenarioDir string, fx E2EFixture) error { + specs := []string{filepath.Join(scenarioDir, "deployah.yaml")} + for _, step := range fx.Steps { + if step.Deploy != nil && step.Deploy.Spec != "" { + specs = append(specs, filepath.Join(scenarioDir, step.Deploy.Spec)) + } + } + seen := map[string]struct{}{} + for _, specPath := range specs { + if _, err := os.Stat(specPath); err != nil { + continue + } + raw, err := os.ReadFile(specPath) // #nosec G304 -- scenario spec under test + if err != nil { + return fmt.Errorf("read spec %s: %w", specPath, err) + } + var doc any + if parseErr := yaml.Unmarshal(raw, &doc); parseErr != nil { + return fmt.Errorf("parse spec %s: %w", specPath, parseErr) + } + for _, img := range collectImages(doc) { + if _, ok := seen[img]; ok { + continue + } + seen[img] = struct{}{} + if !imageAllowed(img) { + return fmt.Errorf("image %q is not on the e2e allowlist", img) + } + } + } + return nil +} + +func collectImages(v any) []string { + var out []string + switch n := v.(type) { + case map[string]any: + for k, child := range n { + if k == "image" { + if s, ok := child.(string); ok && s != "" && !strings.Contains(s, "${") { + out = append(out, s) + } + continue + } + out = append(out, collectImages(child)...) + } + case []any: + for _, child := range n { + out = append(out, collectImages(child)...) + } + } + return out +} + +func imageAllowed(img string) bool { + norm := normalizeImageRef(img) + for _, allowed := range AllowedE2EImages { + if normalizeImageRef(allowed) == norm { + return true + } + } + return false +} + +func normalizeImageRef(img string) string { + img = strings.TrimPrefix(img, "docker.io/library/") + img = strings.TrimPrefix(img, "docker.io/") + return img +} diff --git a/internal/testing/e2e_fixture_test.go b/internal/testing/e2e_fixture_test.go new file mode 100644 index 0000000..2b4ac63 --- /dev/null +++ b/internal/testing/e2e_fixture_test.go @@ -0,0 +1,333 @@ +// Copyright 2025 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 testing + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +const specNginxLatest = ` +apiVersion: v1-alpha.5 +project: demo +components: + web: + image: nginx:latest +` + +func TestLoadE2EFixture(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + e2e string + wantEnv string + wantParallel bool + wantNRes int + wantCount int + }{ + { + name: "simple deployment", + spec: specNginxLatest, + e2e: ` +env: dev +resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo-dev +`, + wantEnv: "dev", + wantParallel: true, + wantNRes: 1, + wantCount: 1, + }, + { + name: "docker.io library prefix allowed", + spec: ` +apiVersion: v1-alpha.5 +project: demo +components: + web: + image: docker.io/library/nginx:latest +`, + e2e: ` +env: dev +resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: {name: x} +`, + wantEnv: "dev", + wantParallel: true, + wantNRes: 1, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "deployah.yaml"), []byte(tt.spec), 0o600)) + path := filepath.Join(dir, "e2e.yaml") + require.NoError(t, os.WriteFile(path, []byte(tt.e2e), 0o600)) + + fx, err := LoadE2EFixture(path, dir) + require.NoError(t, err) + assert.Equal(t, tt.wantEnv, fx.Env) + assert.Equal(t, tt.wantParallel, fx.RunParallel()) + require.Len(t, fx.Resources, tt.wantNRes) + assert.Equal(t, tt.wantCount, fx.Resources[0].Count()) + }) + } +} + +func TestLoadE2EFixture_Stepped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + e2e string + wantEnv string + wantParallel bool + wantOps []string + wantCount int + }{ + { + name: "four closed ops", + spec: specNginxLatest, + e2e: ` +env: staging +parallel: false +steps: + - deploy: + spec: deployah.yaml + args: [--crds, create] + stderrContains: already present + - run: + task: backfill + - logs: + component: web + contains: listening + - delete: {} + resources: + - minCount: 0 + match: + apiVersion: batch/v1 + kind: Job + metadata: + labels: + app: demo +`, + wantEnv: "staging", + wantParallel: false, + wantOps: []string{"deploy", "run", "logs", "delete"}, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "deployah.yaml"), []byte(tt.spec), 0o600)) + path := filepath.Join(dir, "e2e.yaml") + require.NoError(t, os.WriteFile(path, []byte(tt.e2e), 0o600)) + + fx, err := LoadE2EFixture(path, dir) + require.NoError(t, err) + assert.Equal(t, tt.wantEnv, fx.Env) + assert.Equal(t, tt.wantParallel, fx.RunParallel()) + require.Len(t, fx.Steps, len(tt.wantOps)) + for i, op := range tt.wantOps { + assert.Equal(t, op, fx.Steps[i].OpName()) + } + require.NotEmpty(t, fx.Steps[len(fx.Steps)-1].Resources) + assert.Equal(t, tt.wantCount, fx.Steps[len(fx.Steps)-1].Resources[0].Count()) + }) + } +} + +func TestLoadE2EFixture_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + e2e string + wantErr string + }{ + { + name: "unknown image", + spec: ` +apiVersion: v1-alpha.5 +project: demo +components: + web: + image: ghcr.io/acme/not-allowed:1 +`, + e2e: ` +env: dev +resources: + - match: + kind: Deployment + apiVersion: apps/v1 + metadata: + name: x +`, + wantErr: "allowlist", + }, + { + name: "mixed resources and steps", + e2e: ` +env: dev +resources: + - match: + kind: Deployment + apiVersion: apps/v1 + metadata: {name: x} +steps: + - deploy: {} +`, + wantErr: "exactly one of resources or steps", + }, + { + name: "missing env", + e2e: ` +resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: {name: x} +`, + wantErr: "env is required", + }, + { + name: "match missing kind", + e2e: ` +env: dev +resources: + - match: + apiVersion: apps/v1 + metadata: {name: x} +`, + wantErr: "match requires apiVersion and kind", + }, + { + name: "named match with minCount 2", + e2e: ` +env: dev +resources: + - minCount: 2 + match: + apiVersion: apps/v1 + kind: Deployment + metadata: {name: x} +`, + wantErr: "minCount > 1 cannot be used with metadata.name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if tt.spec != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "deployah.yaml"), []byte(tt.spec), 0o600)) + } + path := filepath.Join(dir, "e2e.yaml") + require.NoError(t, os.WriteFile(path, []byte(tt.e2e), 0o600)) + + _, err := LoadE2EFixture(path, dir) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestImageAllowed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + img string + want bool + }{ + {name: "nginx latest", img: "nginx:latest", want: true}, + {name: "docker.io library prefix", img: "docker.io/library/nginx:latest", want: true}, + {name: "docker.io prefix", img: "docker.io/nginx:latest", want: true}, + {name: "redis alpine", img: "redis:7-alpine", want: true}, + {name: "unknown registry", img: "ghcr.io/acme/not-allowed:1", want: false}, + {name: "unknown tag", img: "nginx:not-a-real-tag", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, imageAllowed(tt.img)) + }) + } +} + +func TestE2ESchema_validatesScenarioFiles(t *testing.T) { + t.Parallel() + + schemaBytes, err := os.ReadFile("e2e.schema.json") + require.NoError(t, err) + + compiler := jsonschema.NewCompiler() + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaBytes)) + require.NoError(t, err) + require.NoError(t, compiler.AddResource("e2e.schema.json", doc)) + compiled, err := compiler.Compile("e2e.schema.json") + require.NoError(t, err) + + if _, statErr := os.Stat(TestScenariosDir); statErr != nil { + t.Skip("scenarios directory not found") + } + scenarios, err := DiscoverScenarios(TestScenariosDir) + require.NoError(t, err) + found := 0 + for _, sc := range scenarios { + if !sc.HasE2EFixture { + continue + } + found++ + t.Run(sc.Name, func(t *testing.T) { + t.Parallel() + raw, readErr := os.ReadFile(sc.E2EFixturePath) + require.NoError(t, readErr) + jsonBytes, yamlErr := yaml.YAMLToJSON(raw) + require.NoError(t, yamlErr) + var obj any + require.NoError(t, json.Unmarshal(jsonBytes, &obj)) + require.NoError(t, compiled.Validate(obj)) + }) + } + require.Greater(t, found, 0, "expected at least one scenarios/*/e2e.yaml") +} diff --git a/internal/testing/scenario_discovery.go b/internal/testing/scenario_discovery.go index b4a8aef..4f8c70e 100644 --- a/internal/testing/scenario_discovery.go +++ b/internal/testing/scenario_discovery.go @@ -91,6 +91,16 @@ func DiscoverScenarios(scenariosDir string) ([]TestScenario, error) { base.PlatformFile = "deployah.platform.yaml" } + e2ePath := filepath.Join(path, E2EFixtureFile) + if e2eInfo, statErr := os.Stat(e2ePath); statErr == nil && e2eInfo.Mode().IsRegular() { + abs, absErr := filepath.Abs(e2ePath) + if absErr != nil { + return absErr + } + base.HasE2EFixture = true + base.E2EFixturePath = abs + } + // A scenario with per-environment goldens (expected-/ // directories) is discovered as one TestScenario per environment, // instead of a single scenario with a plain "expected" directory. diff --git a/internal/testing/schema_validate.go b/internal/testing/schema_validate.go index c73d440..32fafae 100644 --- a/internal/testing/schema_validate.go +++ b/internal/testing/schema_validate.go @@ -28,7 +28,7 @@ import ( // emit that [validateAgainstScheme] cannot check against [scheme.Scheme]: // // - ServiceMonitor, PodMonitor, PrometheusRule: Prometheus Operator CRDs. -// - ClusterWidget: fixture CRD used by extras scenarios. +// - ClusterWidget, IdempotentWidget: fixture CRDs used by extras/e2e scenarios. // - HorizontalPodAutoscaler: [helm.Client.RenderOffline] has no live // cluster, so Capabilities.KubeVersion falls back to Helm's pre-1.23 // default, making the chart select the removed autoscaling/v2beta1 API @@ -40,6 +40,7 @@ var unregisteredSchemeKinds = []string{ "PodMonitor", "PrometheusRule", "ClusterWidget", + "IdempotentWidget", "HorizontalPodAutoscaler", } diff --git a/internal/testing/subset.go b/internal/testing/subset.go new file mode 100644 index 0000000..06c8be6 --- /dev/null +++ b/internal/testing/subset.go @@ -0,0 +1,238 @@ +// Copyright 2025 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 testing + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// DiffSubset reports JSON-pointer-style paths where want is not a subset of +// got. An empty result means every value in want is present in got. +// +// Maps: only keys present in want are compared; extra keys in got are +// ignored. A nil value in want (YAML null) requires the key to be absent +// or nil in got. Scalar slices must match in length and order. An empty +// want slice matches any got slice. Object slices (elements that are +// maps) are matched by identity key "name", then "type"; extra got +// elements are ignored. +func DiffSubset(path string, want, got any) []string { + if path == "" { + path = "$" + } + if want == nil { + if got == nil { + return nil + } + return []string{fmt.Sprintf("%s: want absent/null, got %s", path, describe(got))} + } + if got == nil { + return []string{fmt.Sprintf("%s: missing, want %s", path, describe(want))} + } + + if wantMap, ok := asMap(want); ok { + gotMap, gotOK := asMap(got) + if !gotOK { + return []string{fmt.Sprintf("%s: want map, got %s", path, describe(got))} + } + return diffMap(path, wantMap, gotMap) + } + + if wantSlice, ok := asSlice(want); ok { + gotSlice, gotOK := asSlice(got) + if !gotOK { + return []string{fmt.Sprintf("%s: want list, got %s", path, describe(got))} + } + return diffSlice(path, wantSlice, gotSlice) + } + + if numbersEqual(want, got) { + return nil + } + if reflect.DeepEqual(want, got) { + return nil + } + return []string{fmt.Sprintf("%s: want %s, got %s", path, describe(want), describe(got))} +} + +// DiffContainsByKey reports paths where each object in want is not found +// among got by identity key ("name", then "type") and recursive subset +// match. Extra got items are ignored. An empty want matches anything. +func DiffContainsByKey(path string, want, got []any) []string { + if path == "" { + path = "$" + } + if len(want) == 0 { + return nil + } + var diffs []string + for i, w := range want { + itemPath := fmt.Sprintf("%s[%d]", path, i) + wm, ok := asMap(w) + if !ok { + diffs = append(diffs, fmt.Sprintf("%s: want object, got %s", itemPath, describe(w))) + continue + } + key, keyName := identityKey(wm) + matched := false + for _, g := range got { + gm, gotOK := asMap(g) + if !gotOK { + continue + } + if keyName != "" { + gv, has := gm[keyName] + if !has || !reflect.DeepEqual(gv, key) { + continue + } + } + itemDiffs := DiffSubset(itemPath, w, g) + if len(itemDiffs) == 0 { + matched = true + break + } + // Keep subset diffs when the identity key matched. + if keyName != "" { + diffs = append(diffs, itemDiffs...) + matched = true + break + } + } + if !matched { + if keyName != "" { + diffs = append(diffs, fmt.Sprintf("%s: no item with %s=%s", itemPath, keyName, describe(key))) + } else { + diffs = append(diffs, fmt.Sprintf("%s: no matching object", itemPath)) + } + } + } + return diffs +} + +func diffMap(path string, want, got map[string]any) []string { + var diffs []string + for k, wv := range want { + child := path + "." + k + gv, ok := got[k] + if wv == nil { + if ok && gv != nil { + diffs = append(diffs, fmt.Sprintf("%s: want absent/null, got %s", child, describe(gv))) + } + continue + } + if !ok { + diffs = append(diffs, fmt.Sprintf("%s: missing, want %s", child, describe(wv))) + continue + } + diffs = append(diffs, DiffSubset(child, wv, gv)...) + } + return diffs +} + +func diffSlice(path string, want, got []any) []string { + if len(want) == 0 { + return nil + } + if isObjectSlice(want) { + return DiffContainsByKey(path, want, got) + } + if len(want) != len(got) { + return []string{fmt.Sprintf("%s: want list len %d, got %d", path, len(want), len(got))} + } + var diffs []string + for i := range want { + diffs = append(diffs, DiffSubset(fmt.Sprintf("%s[%d]", path, i), want[i], got[i])...) + } + return diffs +} + +func isObjectSlice(s []any) bool { + for _, v := range s { + if _, ok := asMap(v); ok { + return true + } + } + return false +} + +func identityKey(m map[string]any) (any, string) { + if v, ok := m["name"]; ok && v != nil { + return v, "name" + } + if v, ok := m["type"]; ok && v != nil { + return v, "type" + } + return nil, "" +} + +func asMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + return m, ok +} + +func asSlice(v any) ([]any, bool) { + switch s := v.(type) { + case []any: + return s, true + case []map[string]any: + out := make([]any, 0, len(s)) + for _, m := range s { + out = append(out, m) + } + return out, true + default: + return nil, false + } +} + +func numbersEqual(a, b any) bool { + af, aok := asFloat(a) + bf, bok := asFloat(b) + return aok && bok && af == bf +} + +func asFloat(v any) (float64, bool) { + switch n := v.(type) { + case int: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + default: + return 0, false + } +} + +func describe(v any) string { + if v == nil { + return "null" + } + return fmt.Sprintf("%T(%v)", v, v) +} diff --git a/internal/testing/subset_test.go b/internal/testing/subset_test.go new file mode 100644 index 0000000..2f801bf --- /dev/null +++ b/internal/testing/subset_test.go @@ -0,0 +1,193 @@ +// Copyright 2025 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 testing + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + k8sjson "k8s.io/apimachinery/pkg/util/json" +) + +func TestDiffSubset(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want string + got string + wantErr bool + contain string + }{ + { + name: "absent keys in want are ignored", + want: `replicas: 1`, + got: `replicas: 1 +image: nginx`, + }, + { + name: "typo key is reported missing", + want: `replica: 1`, + got: `replicas: 1`, + wantErr: true, + contain: "replica: missing", + }, + { + name: "null means absent", + want: `startingDeadlineSeconds: null`, + got: `schedule: "@hourly"`, + }, + { + name: "null fails when field is set", + want: `startingDeadlineSeconds: null`, + got: `startingDeadlineSeconds: 30`, + wantErr: true, + contain: "want absent/null", + }, + { + name: "scalar list exact match", + want: `command: ["echo", "ok"]`, + got: `command: ["echo", "ok"]`, + }, + { + name: "scalar list order mismatch", + want: `command: ["echo", "ok"]`, + got: `command: ["ok", "echo"]`, + wantErr: true, + contain: "want string(echo), got string(ok)", + }, + { + name: "scalar list length mismatch", + want: `command: ["echo"]`, + got: `command: ["echo", "ok"]`, + wantErr: true, + contain: "want list len 1, got 2", + }, + { + name: "empty want slice matches any", + want: `command: []`, + got: `command: ["echo", "ok"]`, + }, + { + name: "object list matched by name, extra got items ignored", + want: `containers: + - name: web + image: nginx:latest`, + got: `containers: + - name: sidecar + image: busybox + - name: web + image: nginx:latest + ports: + - containerPort: 80`, + }, + { + name: "object list missing name", + want: `containers: + - name: web + image: nginx:latest`, + got: `containers: + - name: sidecar + image: nginx:latest`, + wantErr: true, + contain: "no item with name=", + }, + { + name: "nested maps", + want: `spec: + replicas: 1 + template: + spec: + restartPolicy: Always`, + got: `spec: + replicas: 1 + selector: + matchLabels: + app: web + template: + spec: + restartPolicy: Always + containers: + - name: web`, + }, + { + name: "int64 vs float64", + want: `replicas: 1`, + got: `replicas: 1`, + }, + { + name: "nested null in spec", + want: `spec: + activeDeadlineSeconds: null`, + got: `spec: + completions: 1`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + want := decodeYAMLMap(t, tt.want) + got := decodeYAMLMap(t, tt.got) + diffs := DiffSubset("$", want, got) + if !tt.wantErr { + assert.Empty(t, diffs) + return + } + require.NotEmpty(t, diffs) + if tt.contain != "" { + joined := "" + for _, d := range diffs { + joined += d + } + assert.Contains(t, joined, tt.contain) + } + }) + } +} + +func TestDiffSubset_jsonNumberEqualsInt(t *testing.T) { + t.Parallel() + + want := map[string]any{"replicas": int64(1)} + got := map[string]any{"replicas": json.Number("1")} + assert.Empty(t, DiffSubset("$", want, got)) +} + +func TestDiffContainsByKey_typeFallback(t *testing.T) { + t.Parallel() + + want := []any{ + map[string]any{"type": "http", "port": int64(80)}, + } + got := []any{ + map[string]any{"type": "tcp", "port": int64(22)}, + map[string]any{"type": "http", "port": int64(80), "name": "web"}, + } + assert.Empty(t, DiffContainsByKey("ports", want, got)) +} + +func decodeYAMLMap(t *testing.T, raw string) map[string]any { + t.Helper() + jsonBytes, err := yaml.YAMLToJSON([]byte(raw)) + require.NoError(t, err) + var m map[string]any + require.NoError(t, k8sjson.Unmarshal(jsonBytes, &m)) + return m +} diff --git a/internal/testing/types.go b/internal/testing/types.go index 6edc405..e9486d3 100644 --- a/internal/testing/types.go +++ b/internal/testing/types.go @@ -93,6 +93,10 @@ type TestScenario struct { ExpectError bool // ExpectedErrors requires specific substrings in the load/resolve error message. ExpectedErrors []string + // HasE2EFixture is true when the scenario directory contains e2e.yaml. + HasE2EFixture bool + // E2EFixturePath is the absolute path to e2e.yaml when HasE2EFixture is true. + E2EFixturePath string } // IntegrationTestSuite runs scenario-based chart and manifest tests. diff --git a/nix/apps/testing.nix b/nix/apps/testing.nix index 38f5659..33e993b 100644 --- a/nix/apps/testing.nix +++ b/nix/apps/testing.nix @@ -26,7 +26,8 @@ coverProfile = "coverage-e2e.out"; junitFile = "junit-e2e.xml"; testPackages = "./internal/e2e"; - timeout = "15m"; + timeout = "30m"; + extraArgs = "-parallel=4"; race = false; # the work is a live cluster, not concurrent Go }; } diff --git a/nix/lib.nix b/nix/lib.nix index d415178..7cd43a0 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -34,6 +34,7 @@ rec { testPackages ? "./...", timeout ? "10m", race ? true, + extraArgs ? "", }: mkApp { inherit name description; @@ -57,7 +58,7 @@ rec { exec gotestsum --junitfile=${junitFile} -- \ -tags=${tags} ${pkgs.lib.optionalString race "-race"} \ -shuffle=on -covermode=atomic \ - -coverpkg=./... -coverprofile=${coverProfile} -timeout ${timeout} "''${testpkgs[@]}" + -coverpkg=./... -coverprofile=${coverProfile} -timeout ${timeout} ${extraArgs} "''${testpkgs[@]}" ''; }; } diff --git a/scenarios/basic-web-service/deployah.yaml b/scenarios/basic-web-service/deployah.yaml index 3796312..6d17516 100644 --- a/scenarios/basic-web-service/deployah.yaml +++ b/scenarios/basic-web-service/deployah.yaml @@ -4,7 +4,7 @@ project: basic-web-service components: web: image: nginx:latest - port: 8080 + port: 80 environments: [dev] resourcePreset: small environments: diff --git a/scenarios/basic-web-service/e2e.yaml b/scenarios/basic-web-service/e2e.yaml new file mode 100644 index 0000000..1178352 --- /dev/null +++ b/scenarios/basic-web-service/e2e.yaml @@ -0,0 +1,46 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: basic-web-service-dev + labels: + deployah.dev/project: basic-web-service + deployah.dev/environment: dev + deployah.dev/component: web + spec: + replicas: 1 + template: + spec: + containers: + - name: web + image: docker.io/library/nginx:latest + ports: + - name: http + containerPort: 80 + status: + readyReplicas: 1 + - match: + apiVersion: v1 + kind: Service + metadata: + name: basic-web-service-dev + spec: + ports: + - name: http + port: 80 + targetPort: http + selector: + app.kubernetes.io/instance: basic-web-service-dev + app.kubernetes.io/name: web + - match: + apiVersion: v1 + kind: Pod + metadata: + labels: + deployah.dev/project: basic-web-service + deployah.dev/environment: dev + status: + phase: Running diff --git a/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml b/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml index 9abd99f..be85005 100644 --- a/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml +++ b/scenarios/basic-web-service/expected/deployment-basic-web-service-dev.yaml @@ -56,7 +56,7 @@ spec: timeoutSeconds: 3 name: web ports: - - containerPort: 8080 + - containerPort: 80 name: http protocol: TCP readinessProbe: diff --git a/scenarios/crd-create-idempotent/.deployah/crds/idempotentwidget.yaml b/scenarios/crd-create-idempotent/.deployah/crds/idempotentwidget.yaml new file mode 100644 index 0000000..b2d8efd --- /dev/null +++ b/scenarios/crd-create-idempotent/.deployah/crds/idempotentwidget.yaml @@ -0,0 +1,24 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: idempotentwidgets.example.com + labels: + e2e-marker: "crd-create-idempotent" +spec: + group: example.com + scope: Cluster + names: + kind: IdempotentWidget + plural: idempotentwidgets + singular: idempotentwidget + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/internal/e2e/testdata/basic-web-service/deployah.yaml b/scenarios/crd-create-idempotent/deployah.yaml similarity index 61% rename from internal/e2e/testdata/basic-web-service/deployah.yaml rename to scenarios/crd-create-idempotent/deployah.yaml index 1dec9dd..1684ea3 100644 --- a/internal/e2e/testdata/basic-web-service/deployah.yaml +++ b/scenarios/crd-create-idempotent/deployah.yaml @@ -1,5 +1,6 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 -project: basic-web-service +project: crd-create-idempotent components: web: image: nginx:latest diff --git a/scenarios/crd-create-idempotent/e2e.yaml b/scenarios/crd-create-idempotent/e2e.yaml new file mode 100644 index 0000000..451a7a8 --- /dev/null +++ b/scenarios/crd-create-idempotent/e2e.yaml @@ -0,0 +1,19 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +parallel: false +steps: + - deploy: + args: [--crds, create] + resources: + - match: + apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + metadata: + name: idempotentwidgets.example.com + status: + conditions: + - type: Established + status: "True" + - deploy: + args: [--crds, create] + stderrContains: already present diff --git a/internal/e2e/testdata/crd-lifecycle/.deployah/crds/clusterwidget.yaml b/scenarios/crd-lifecycle/.deployah/crds/clusterwidget.yaml similarity index 100% rename from internal/e2e/testdata/crd-lifecycle/.deployah/crds/clusterwidget.yaml rename to scenarios/crd-lifecycle/.deployah/crds/clusterwidget.yaml diff --git a/internal/e2e/testdata/crd-lifecycle/deployah.yaml b/scenarios/crd-lifecycle/deployah.yaml similarity index 73% rename from internal/e2e/testdata/crd-lifecycle/deployah.yaml rename to scenarios/crd-lifecycle/deployah.yaml index ea48346..8a1520e 100644 --- a/internal/e2e/testdata/crd-lifecycle/deployah.yaml +++ b/scenarios/crd-lifecycle/deployah.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: crd-lifecycle components: diff --git a/scenarios/stateful-basic/deployah.yaml b/scenarios/stateful-basic/deployah.yaml index 35accfa..12ce4a0 100644 --- a/scenarios/stateful-basic/deployah.yaml +++ b/scenarios/stateful-basic/deployah.yaml @@ -4,12 +4,12 @@ project: stateful-basic components: db: kind: stateful - image: postgres:16 - port: 5432 + image: redis:7-alpine + port: 6379 resourcePreset: small environments: [dev] persistence: size: 20Gi - mountPath: /var/lib/postgresql/data + mountPath: /data environments: dev: {} diff --git a/scenarios/stateful-basic/e2e.yaml b/scenarios/stateful-basic/e2e.yaml new file mode 100644 index 0000000..f7d55ea --- /dev/null +++ b/scenarios/stateful-basic/e2e.yaml @@ -0,0 +1,59 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +resources: + - match: + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: stateful-basic-dev-db + labels: + deployah.dev/project: stateful-basic + deployah.dev/environment: dev + deployah.dev/component: db + spec: + replicas: 1 + template: + spec: + containers: + - name: db + image: docker.io/library/redis:7-alpine + ports: + - name: http + containerPort: 6379 + status: + readyReplicas: 1 + - match: + apiVersion: v1 + kind: Service + metadata: + name: stateful-basic-dev-db + spec: + ports: + - name: http + port: 80 + targetPort: http + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + - match: + apiVersion: v1 + kind: Service + metadata: + name: stateful-basic-dev-db-headless + spec: + clusterIP: None + selector: + app.kubernetes.io/instance: stateful-basic-dev + app.kubernetes.io/name: db + - match: + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + labels: + deployah.dev/component: db + spec: + resources: + requests: + storage: 20Gi + status: + phase: Bound diff --git a/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml index bd46d2f..799982e 100644 --- a/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml +++ b/scenarios/stateful-basic/expected/statefulset-stateful-basic-dev-db.yaml @@ -49,7 +49,7 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - image: docker.io/library/postgres:16 + - image: docker.io/library/redis:7-alpine imagePullPolicy: IfNotPresent livenessProbe: failureThreshold: 6 @@ -59,7 +59,7 @@ spec: timeoutSeconds: 3 name: db ports: - - containerPort: 5432 + - containerPort: 6379 name: http protocol: TCP readinessProbe: @@ -81,7 +81,7 @@ spec: port: http timeoutSeconds: 3 volumeMounts: - - mountPath: /var/lib/postgresql/data + - mountPath: /data name: data restartPolicy: Always serviceAccountName: default diff --git a/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml b/scenarios/stateful-scale/deployah-replicas-2.yaml similarity index 81% rename from internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml rename to scenarios/stateful-scale/deployah-replicas-2.yaml index a006825..ae02865 100644 --- a/internal/e2e/testdata/stateful-scale/deployah-replicas-2.yaml +++ b/scenarios/stateful-scale/deployah-replicas-2.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: stateful-scale components: diff --git a/internal/e2e/testdata/stateful-scale/deployah.yaml b/scenarios/stateful-scale/deployah.yaml similarity index 81% rename from internal/e2e/testdata/stateful-scale/deployah.yaml rename to scenarios/stateful-scale/deployah.yaml index c9f0970..1f4ccd4 100644 --- a/internal/e2e/testdata/stateful-scale/deployah.yaml +++ b/scenarios/stateful-scale/deployah.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: stateful-scale components: diff --git a/scenarios/stateful-scale/e2e.yaml b/scenarios/stateful-scale/e2e.yaml new file mode 100644 index 0000000..9a82d38 --- /dev/null +++ b/scenarios/stateful-scale/e2e.yaml @@ -0,0 +1,34 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +steps: + - deploy: {} + resources: + - match: + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: stateful-scale-dev-cache + status: + readyReplicas: 1 + - deploy: + spec: deployah-replicas-2.yaml + timeout: 5m + resources: + - match: + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: stateful-scale-dev-cache + spec: + replicas: 2 + status: + readyReplicas: 2 + - minCount: 2 + match: + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + labels: + deployah.dev/component: cache + status: + phase: Bound diff --git a/internal/e2e/testdata/task-migrate-smoke/deployah.yaml b/scenarios/task-migrate-smoke/deployah.yaml similarity index 89% rename from internal/e2e/testdata/task-migrate-smoke/deployah.yaml rename to scenarios/task-migrate-smoke/deployah.yaml index ad69493..ec642fd 100644 --- a/internal/e2e/testdata/task-migrate-smoke/deployah.yaml +++ b/scenarios/task-migrate-smoke/deployah.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: taskdemo components: diff --git a/scenarios/task-migrate-smoke/e2e.yaml b/scenarios/task-migrate-smoke/e2e.yaml new file mode 100644 index 0000000..06e2288 --- /dev/null +++ b/scenarios/task-migrate-smoke/e2e.yaml @@ -0,0 +1,61 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +steps: + - deploy: {} + resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: taskdemo-dev-api + labels: + deployah.dev/project: taskdemo + deployah.dev/environment: dev + deployah.dev/component: api + spec: + replicas: 1 + template: + spec: + containers: + - name: api + image: docker.io/library/nginx:latest + status: + readyReplicas: 1 + - match: + apiVersion: v1 + kind: Service + metadata: + name: taskdemo-dev-api + spec: + ports: + - name: http + port: 80 + targetPort: http + - run: + task: backfill + - run: + task: backfill + resources: + - minCount: 2 + match: + apiVersion: batch/v1 + kind: Job + metadata: + labels: + deployah.dev/project: taskdemo + deployah.dev/component: backfill + status: + succeeded: 1 + - logs: + component: backfill + contains: backfill-ok + - delete: {} + resources: + - minCount: 0 + match: + apiVersion: batch/v1 + kind: Job + metadata: + labels: + deployah.dev/project: taskdemo + deployah.dev/environment: dev diff --git a/internal/e2e/testdata/task-schedule/deployah.yaml b/scenarios/task-schedule/deployah.yaml similarity index 82% rename from internal/e2e/testdata/task-schedule/deployah.yaml rename to scenarios/task-schedule/deployah.yaml index 29987ac..cdc83a6 100644 --- a/internal/e2e/testdata/task-schedule/deployah.yaml +++ b/scenarios/task-schedule/deployah.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: taskcron components: diff --git a/scenarios/task-schedule/e2e.yaml b/scenarios/task-schedule/e2e.yaml new file mode 100644 index 0000000..73b6cf9 --- /dev/null +++ b/scenarios/task-schedule/e2e.yaml @@ -0,0 +1,47 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +steps: + - deploy: {} + resources: + - match: + apiVersion: batch/v1 + kind: CronJob + metadata: + name: taskcron-dev-cleanup + labels: + deployah.dev/project: taskcron + deployah.dev/component: cleanup + annotations: + helm.sh/hook: null + spec: + schedule: "@every 1h" + timeZone: Etc/UTC + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + startingDeadlineSeconds: null + jobTemplate: + spec: + completionMode: Indexed + activeDeadlineSeconds: 3600 + template: + spec: + restartPolicy: OnFailure + containers: + - name: cleanup + command: ["echo", "cleanup-ok"] + - run: + task: cleanup + resources: + - match: + apiVersion: batch/v1 + kind: Job + metadata: + labels: + deployah.dev/project: taskcron + deployah.dev/component: cleanup + deployah.dev/managed-by: deployah + spec: + activeDeadlineSeconds: null + status: + succeeded: 1 diff --git a/internal/e2e/testdata/worker-basic/deployah.yaml b/scenarios/worker-basic/deployah.yaml similarity index 78% rename from internal/e2e/testdata/worker-basic/deployah.yaml rename to scenarios/worker-basic/deployah.yaml index 0be213a..99f23df 100644 --- a/internal/e2e/testdata/worker-basic/deployah.yaml +++ b/scenarios/worker-basic/deployah.yaml @@ -1,3 +1,4 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json apiVersion: v1-alpha.5 project: worker-basic components: diff --git a/scenarios/worker-basic/e2e.yaml b/scenarios/worker-basic/e2e.yaml new file mode 100644 index 0000000..348b3d0 --- /dev/null +++ b/scenarios/worker-basic/e2e.yaml @@ -0,0 +1,32 @@ +# $schema: ../../internal/testing/e2e.schema.json +env: dev +resources: + - match: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: worker-basic-dev + labels: + deployah.dev/project: worker-basic + deployah.dev/environment: dev + deployah.dev/component: worker + spec: + replicas: 1 + template: + spec: + containers: + - name: worker + image: docker.io/library/busybox:1.36 + command: ["sleep"] + args: ["infinity"] + status: + readyReplicas: 1 + - match: + apiVersion: v1 + kind: Pod + metadata: + labels: + deployah.dev/project: worker-basic + deployah.dev/environment: dev + status: + phase: Running diff --git a/scenarios/worker-basic/expected/deployment-worker-basic-dev.yaml b/scenarios/worker-basic/expected/deployment-worker-basic-dev.yaml new file mode 100644 index 0000000..5de8649 --- /dev/null +++ b/scenarios/worker-basic/expected/deployment-worker-basic-dev.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: worker-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: worker-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-basic + helm.sh/chart: worker-0.1.0 + name: worker-basic-dev + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: worker-basic-dev + app.kubernetes.io/name: worker + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: worker-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: worker + deployah.dev/component: worker + deployah.dev/environment: dev + deployah.dev/project: worker-basic + helm.sh/chart: worker-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: worker-basic-dev + app.kubernetes.io/name: worker + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - infinity + command: + - sleep + image: docker.io/library/busybox:1.36 + imagePullPolicy: IfNotPresent + name: worker + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 60