From 513584e3a975f49a45122d8f1c12c7c27b864f57 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Tue, 28 Jul 2026 14:08:57 +0200 Subject: [PATCH] add Kind-based e2e test suite - drive deployah against a live Kind cluster via nabattest - wire nix test-e2e app and CI job with Codecov coverage - track e2e fixtures and allow NewApp option overrides --- .github/workflows/ci.yml | 56 ++- .gitignore | 9 +- README.md | 20 +- flake.nix | 2 +- go.mod | 1 + go.sum | 4 + internal/cmd/root.go | 8 +- internal/e2e/doc.go | 21 + internal/e2e/e2e_test.go | 397 ++++++++++++++++++ .../testdata/basic-web-service/deployah.yaml | 10 + .../testdata/basic-web-service/expect.yaml | 22 + nix/apps/testing.nix | 12 +- nix/lib.nix | 7 +- 13 files changed, 555 insertions(+), 14 deletions(-) create mode 100644 internal/e2e/doc.go create mode 100644 internal/e2e/e2e_test.go create mode 100644 internal/e2e/testdata/basic-web-service/deployah.yaml create mode 100644 internal/e2e/testdata/basic-web-service/expect.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9200d..ea45ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,11 +192,48 @@ jobs: path: coverage-integration.out retention-days: 1 + e2e-test: + name: E2E Tests + runs-on: ubuntu-latest + needs: [validate] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - uses: nix-community/cache-nix-action@v7 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size-linux: 3221225472 + purge: true + purge-prefixes: nix-${{ runner.os }}- + purge-created: 0 + purge-last-accessed: P7D + purge-primary-key: never + + # Kind node-image preload/cache is tracked in #41; omit until wired. + # Race is off in test-e2e on purpose (Kind + client-go; see nix/apps/testing.nix). + - name: Run e2e tests + run: nix run .#test-e2e + env: + CI: "true" + DEPLOYAH_E2E_FORCE: "1" + + - name: Upload coverage artifact + uses: actions/upload-artifact@v7 + with: + name: coverage-e2e + path: coverage-e2e.out + retention-days: 1 + report: name: Coverage Report runs-on: ubuntu-latest - needs: [unit-test, integration-test] - if: always() && needs.unit-test.result == 'success' && needs.integration-test.result == 'success' + needs: [unit-test, integration-test, e2e-test] + if: always() && needs.unit-test.result == 'success' && needs.integration-test.result == 'success' && needs.e2e-test.result == 'success' permissions: contents: read id-token: write @@ -213,6 +250,11 @@ jobs: with: name: coverage-integration + - name: Download e2e test coverage + uses: actions/download-artifact@v8 + with: + name: coverage-e2e + - name: Upload unit coverage to Codecov uses: codecov/codecov-action@v7 with: @@ -232,3 +274,13 @@ jobs: flags: integration name: deployah-integration disable_search: true + + - name: Upload e2e coverage to Codecov + uses: codecov/codecov-action@v7 + with: + use_oidc: true + fail_ci_if_error: true + files: ./coverage-e2e.out + flags: e2e + name: deployah-e2e + disable_search: true diff --git a/.gitignore b/.gitignore index 85e270e..543486d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,19 @@ -# Deployah config (keep scenario fixtures tracked for CI) +# Deployah config (keep scenario and e2e fixtures tracked for CI) deployah.platform.yaml deployah.yaml .env.* .env +# Only the repo-root scaffold; fixture trees under scenarios/ and +# internal/e2e/testdata/ keep their .deployah/ contents tracked. +/.deployah/ !scenarios/**/deployah.platform.yaml !scenarios/**/deployah.yaml !scenarios/**/.env.* !scenarios/**/.env +!internal/e2e/testdata/**/deployah.platform.yaml +!internal/e2e/testdata/**/deployah.yaml +!internal/e2e/testdata/**/.env.* +!internal/e2e/testdata/**/.env # Go workspace file go.work diff --git a/README.md b/README.md index 51a8ea0..ac07122 100644 --- a/README.md +++ b/README.md @@ -1530,6 +1530,17 @@ nix run .#test-integration # scenario tests in internal/testing Coverage profiles are written to `coverage-unit.out` and `coverage-integration.out`. +### Running e2e tests locally + +Requires Docker. Creates and destroys a Kind cluster named `deployah`. + +```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. + ### Build and run ```sh @@ -1540,10 +1551,11 @@ nix run . -- --help # run without installing ### CI GitHub Actions runs flake validation, lint/fmt/tidy checks, `nix run .#test-unit`, -and `nix run .#test-integration` on every pull request and push to `main`. -Scenario fixtures under `scenarios/` (including `deployah.yaml` and -`deployah.platform.yaml`) are tracked so integration tests can run on a clean -checkout. +`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. ```sh nix flake check # runs the pre-commit hooks (lint, markdownlint, tidy, nixfmt) diff --git a/flake.nix b/flake.nix index 5a32b3d..840940a 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-zEzSRm+uowbh0hwsUO0RFlIBOhI0EdRUnF7UMwIGIps="; + deployahVendorHash = "sha256-gtK9utTbZWrWCYtCGbz3SXtF3uFDnpqzF1tyOs+Ijro="; deployah = import ./nix/deployah.nix { buildGoModule = buildGoModule'; diff --git a/go.mod b/go.mod index dd8c252..cdeaaaa 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 nabat.dev v0.6.3 + 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 5228524..315e3b1 100644 --- a/go.sum +++ b/go.sum @@ -501,6 +501,8 @@ github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqa github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= +github.com/vladimirvivien/gexe v0.5.0 h1:AWBVaYnrTsGYBktXvcO0DfWPeSiZxn6mnQ5nvL+A1/A= +github.com/vladimirvivien/gexe v0.5.0/go.mod h1:3gjgTqE2c0VyHnU5UOIwk7gyNzZDGulPb/DJPgcw64E= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -706,6 +708,8 @@ pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/e2e-framework v0.7.0 h1:AHkySTC6MvnnMbVSxaO4z1m2MhQKNFP+2Ihs5pRNLlM= +sigs.k8s.io/e2e-framework v0.7.0/go.mod h1:1ZgXkUSjmnf18/JgHZNEATWjv48O5lJm9aI1QIsRdbw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.32.0 h1:p9hscbj98u/qyrjVpjId86LI70nQmbSsipV7wCG10Xk= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7d3a050..cb495f4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,12 +48,14 @@ import ( var version = "dev" // NewApp creates a new Nabat application with all subcommands registered. -func NewApp() *nabat.App { +// Caller options are appended after the defaults so nabat's last-wins +// semantics let tests override construction-time settings such as IO. +func NewApp(opts ...nabat.Option) *nabat.App { // app is referenced by the WithErrorHandler closure below, which only // runs later (inside app.Run), by which point this assignment has // completed; see the "self-referential closure" note there. var app *nabat.App - app = nabat.MustNew("deployah", + app = nabat.MustNew("deployah", append([]nabat.Option{ nabat.WithTheme("gruvbox"), nabat.WithVersion(version), nabat.WithDescription("Deployah turns a spec into a running release on Kubernetes (Spec-to-Release)"), @@ -78,7 +80,7 @@ func NewApp() *nabat.App { errStyle := app.Theme().Style(theme.StatusError) fmt.Fprintf(app.IO().ErrOut, "%s %s\n", errStyle.Render("error:"), err) //nolint:errcheck // best-effort diagnostic write }), - ) + }, opts...)...) // Build runtime once from global flags and store in context for all commands. if err := app.OnPreRun(func(c *nabat.Context) error { diff --git a/internal/e2e/doc.go b/internal/e2e/doc.go new file mode 100644 index 0000000..95aa5aa --- /dev/null +++ b/internal/e2e/doc.go @@ -0,0 +1,21 @@ +// 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 e2e holds Deployah's end-to-end suite, which drives the 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. +package e2e diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go new file mode 100644 index 0000000..f259945 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,397 @@ +// 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 ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "go.yaml.in/yaml/v3" + "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/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" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// 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 + created bool // true once the suite attempts cluster up (teardown if partial) +} + +type scenario struct { + Name string + Dir string // absolute + Project string // from deployah.yaml, needed by the delete cleanup +} + +// clusterStatusView mirrors the JSON tags on the unexported status view in +// internal/cmd/cluster/status.go. +type clusterStatusView struct { + Name string `json:"name"` + Status string `json:"status"` + Context string `json:"context"` + Kubeconfig string `json:"kubeconfig"` + CloudProviderRunning bool `json:"cloudProviderRunning"` +} + +type expectations struct { + Env string `yaml:"env"` + Namespace string `yaml:"namespace"` + Deployments []expectedDeployment `yaml:"deployments"` + Services []expectedService `yaml:"services"` + Pods expectedPods `yaml:"pods"` +} + +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 expectedService struct { + Name string `yaml:"name"` + Port int32 `yaml:"port"` + TargetPortName string `yaml:"targetPortName"` + Selector map[string]string `yaml:"selector"` +} + +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. +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") + s.Require().NoError(err) + s.scenarios = discoverScenarios(t, testdataDir) + + // 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. + t.Chdir(t.TempDir()) + + requireNoCollision(t) + // Mark before up so TearDownSuite cleans up if create succeeds but a + // later step in cluster up fails. down --force is a no-op when missing. + s.created = true + run(t, "cluster", "up") + + 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("running", status.Status) + s.Require().Equal("kind-deployah", 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") +} + +// TearDownSuite destroys the Kind cluster only when this suite created it. +func (s *E2ESuite) TearDownSuite() { + if !s.created { + return + } + if err := runErr(s.T(), "cluster", "down", "--force"); err != nil { + s.T().Errorf("cluster down failed: %v", err) + } +} + +// 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) + } + }) + + run(t, "deploy", exp.Env, "--context", "kind-deployah", "--yes") + s.assertExpectations(t, exp) + }) + } +} + +func (s *E2ESuite) assertExpectations(t testing.TB, exp expectations) { + t.Helper() + res := s.client.Resources(exp.Namespace) + ctx := t.Context() + + for _, dep := range exp.Deployments { + target := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{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(t, err, "deployment %s/%s never became Available", + exp.Namespace, dep.Name) + + var live appsv1.Deployment + require.NoError(t, res.Get(ctx, dep.Name, exp.Namespace, &live)) + dumpActual(t, &live) + + for key, val := range dep.Labels { // subset match + assert.Equalf(t, val, live.Labels[key], + "deployment %s label %s", dep.Name, key) + } + + containers := live.Spec.Template.Spec.Containers + require.NotEmptyf(t, containers, "deployment %s has no containers", dep.Name) + assert.Equalf(t, dep.Image, containers[0].Image, + "deployment %s image", dep.Name) + + if dep.PortName != "" { + require.NotEmptyf(t, containers[0].Ports, + "deployment %s has no ports", dep.Name) + assert.Equalf(t, dep.PortName, containers[0].Ports[0].Name, + "deployment %s port name", dep.Name) + } + if dep.Replicas > 0 { + require.NotNil(t, live.Spec.Replicas) + assert.Equalf(t, dep.Replicas, *live.Spec.Replicas, + "deployment %s replicas", dep.Name) + } + } + + for _, svc := range exp.Services { + var live corev1.Service + require.NoError(t, res.Get(ctx, svc.Name, exp.Namespace, &live)) + dumpActual(t, &live) + + require.NotEmptyf(t, live.Spec.Ports, "service %s has no ports", svc.Name) + assert.Equalf(t, 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(t, svc.TargetPortName, live.Spec.Ports[0].TargetPort.StrVal, + "service %s targetPort name", svc.Name) + } + for key, val := range svc.Selector { + assert.Equalf(t, val, live.Spec.Selector[key], + "service %s selector %s", svc.Name, key) + } + } + + if exp.Pods.LabelSelector != "" { + var pods corev1.PodList + require.NoError(t, res.List(ctx, &pods, + resources.WithLabelSelector(exp.Pods.LabelSelector))) + assert.GreaterOrEqualf(t, len(pods.Items), exp.Pods.MinCount, + "pods matching %s", exp.Pods.LabelSelector) + for _, pod := range pods.Items { + assert.Equalf(t, exp.Pods.Phase, string(pod.Status.Phase), + "pod %s phase", pod.Name) + } + } +} + +func newKlient(t testing.TB, kubeconfigPath, contextName string) klient.Client { + t.Helper() + rules := clientcmd.NewDefaultClientConfigLoadingRules() + rules.ExplicitPath = kubeconfigPath + overrides := &clientcmd.ConfigOverrides{CurrentContext: contextName} + + restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + rules, overrides).ClientConfig() + require.NoErrorf(t, err, "rest config from %s (context %s)", + kubeconfigPath, contextName) + + c, err := klient.New(restCfg) + require.NoError(t, err, "build klient") + return c +} + +func discoverScenarios(t testing.TB, testdataDir string) []scenario { + t.Helper() + entries, err := os.ReadDir(testdataDir) + require.NoErrorf(t, 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(t, readErr) + var spec struct { + Project string `yaml:"project"` + } + require.NoError(t, yaml.Unmarshal(raw, &spec)) + require.NotEmptyf(t, spec.Project, "%s has no project field", specPath) + + found = append(found, scenario{ + Name: entry.Name(), Dir: dir, Project: spec.Project, + }) + } + require.NotEmptyf(t, found, "no scenarios found in %s", testdataDir) + return found +} + +func regularFileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func loadExpectations(t testing.TB, dir string) expectations { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, "expect.yaml")) // #nosec G304 -- path under testdata/ + require.NoError(t, err) + + var exp expectations + require.NoError(t, yaml.Unmarshal(raw, &exp)) + require.NotEmptyf(t, exp.Env, "%s/expect.yaml has no env field", dir) + if exp.Namespace == "" { + exp.Namespace = "default" + } + return exp +} + +func run(t testing.TB, args ...string) string { + t.Helper() + io, _, out, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(io)) + err := nabattest.Run(t, app, args) + require.NoErrorf(t, err, "deployah %s\nstderr:\n%s", + strings.Join(args, " "), errOut.String()) + return out.String() +} + +func runErr(t testing.TB, args ...string) error { + t.Helper() + io, _, _, errOut := nabattest.NewIO() + app := cmd.NewApp(nabat.WithIO(io)) + err := nabattest.Run(t, app, args) + if err == nil { + return nil + } + if stderr := strings.TrimSpace(errOut.String()); stderr != "" { + return fmt.Errorf("%w\nstderr:\n%s", err, stderr) + } + return err +} + +func requireEngine(t testing.TB) { + t.Helper() + if err := exec.Command("docker", "info").Run(); err != nil { + if os.Getenv("CI") == "true" { + t.Fatalf("container engine required in CI: %v", err) + } + t.Skipf("no container engine: %v", err) + } +} + +func requireNoCollision(t testing.TB) { + t.Helper() + m, err := localkube.New() + require.NoError(t, err) + defer m.Close() //nolint:errcheck // best-effort cleanup of provider resources + + _, getErr := m.Get(t.Context(), "deployah") + if errors.Is(getErr, localkube.ErrNotFound) { + return // no existing cluster, nothing to do + } + require.NoError(t, getErr) + + if os.Getenv("DEPLOYAH_E2E_FORCE") != "1" { + t.Fatal("cluster 'deployah' already exists; " + + "set DEPLOYAH_E2E_FORCE=1 to destroy and recreate it") + } + t.Log("DEPLOYAH_E2E_FORCE=1: destroying the existing cluster") + require.NoError(t, runErr(t, "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(t testing.TB, obj any) { + t.Helper() + if os.Getenv("DEPLOYAH_E2E_DUMP") != "1" { + return + } + out, err := yaml.Marshal(obj) + if err != nil { + t.Logf("dump failed: %v", err) + return + } + t.Logf("ACTUAL:\n%s", out) +} diff --git a/internal/e2e/testdata/basic-web-service/deployah.yaml b/internal/e2e/testdata/basic-web-service/deployah.yaml new file mode 100644 index 0000000..5fe5da8 --- /dev/null +++ b/internal/e2e/testdata/basic-web-service/deployah.yaml @@ -0,0 +1,10 @@ +apiVersion: v1-alpha.2 +project: basic-web-service +components: + web: + image: nginx:latest + port: 80 + environments: [dev] + resourcePreset: small +environments: + dev: {} diff --git a/internal/e2e/testdata/basic-web-service/expect.yaml b/internal/e2e/testdata/basic-web-service/expect.yaml new file mode 100644 index 0000000..d93351b --- /dev/null +++ b/internal/e2e/testdata/basic-web-service/expect.yaml @@ -0,0 +1,22 @@ +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/nix/apps/testing.nix b/nix/apps/testing.nix index 95778cd..693eddb 100644 --- a/nix/apps/testing.nix +++ b/nix/apps/testing.nix @@ -1,4 +1,4 @@ -# Test runner apps: test-unit, test-integration +# Test runner apps: test-unit, test-integration, test-e2e { lib }: { @@ -16,4 +16,14 @@ coverProfile = "coverage-integration.out"; testPackages = "./internal/testing"; }; + + test-e2e = lib.mkTaggedRaceTest { + name = "test-e2e"; + description = "Run e2e tests against a live Kind cluster; write coverage-e2e.out"; + tags = "e2e"; + coverProfile = "coverage-e2e.out"; + testPackages = "./internal/e2e"; + timeout = "15m"; + race = false; # the work is a live cluster, not concurrent Go + }; } diff --git a/nix/lib.nix b/nix/lib.nix index ef7ae8f..3dc7acc 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -31,6 +31,8 @@ rec { tags, coverProfile, testPackages ? "./...", + timeout ? "10m", + race ? true, }: mkApp { inherit name description; @@ -49,8 +51,9 @@ rec { echo "go list: no test packages after filters (tags=${tags})" >&2 exit 1 fi - exec "$go/bin/go" test -tags=${tags} -race -shuffle=on -covermode=atomic \ - -coverpkg=./... -coverprofile=${coverProfile} -timeout 10m "''${testpkgs[@]}" + exec "$go/bin/go" test -tags=${tags} ${pkgs.lib.optionalString race "-race"} \ + -shuffle=on -covermode=atomic \ + -coverpkg=./... -coverprofile=${coverProfile} -timeout ${timeout} "''${testpkgs[@]}" ''; }; }