From c36be93a4218d19c7dd40384cc189248b9657ce1 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Sat, 10 Oct 2026 00:59:03 +0200 Subject: [PATCH] run e2e scenarios from a private copy - copy each scenario into a temp dir before creating a namespace - reject symbolic links so a later open stays inside the copy - replace the e2e copyTree helper with that copy --- internal/e2e/doc.go | 9 +- internal/e2e/e2e_crd_test.go | 47 +----- internal/e2e/e2e_plan_cli_test.go | 5 +- internal/e2e/e2e_test.go | 10 +- internal/e2e/e2e_wildcard_test.go | 4 +- internal/testing/doc.go | 3 +- internal/testing/workspace.go | 71 +++++++++ internal/testing/workspace_test.go | 239 +++++++++++++++++++++++++++++ 8 files changed, 335 insertions(+), 53 deletions(-) create mode 100644 internal/testing/workspace.go create mode 100644 internal/testing/workspace_test.go diff --git a/internal/e2e/doc.go b/internal/e2e/doc.go index 7bb4daf..cf7d3be 100644 --- a/internal/e2e/doc.go +++ b/internal/e2e/doc.go @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package e2e drives the Deployah CLI in-process against a live Kind cluster. +// Package e2e drives the Deployah CLI against a live Kind cluster. // -// 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. +// Scenarios run from a private directory copy. Use `nix run .#test-e2e`. +// This file has no build tag so go list can still see the package. package e2e diff --git a/internal/e2e/e2e_crd_test.go b/internal/e2e/e2e_crd_test.go index a8e8a2a..6e9119b 100644 --- a/internal/e2e/e2e_crd_test.go +++ b/internal/e2e/e2e_crd_test.go @@ -19,7 +19,6 @@ package e2e_test import ( "context" "encoding/json" - "io" "os" "path/filepath" "strings" @@ -36,6 +35,7 @@ import ( "deployah.dev/deployah/internal/spec" + inttest "deployah.dev/deployah/internal/testing" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -60,8 +60,8 @@ func (s *E2ESuite) TestCRDLifecycle() { src := filepath.Join(s.scenariosDir, "crd-lifecycle") require.DirExists(t, src) - dir := t.TempDir() - copyTree(t, src, dir) + dir, err := inttest.NewScenarioWorkspace(t, src) + require.NoError(t, err) ns := fixtureNamespace("crd-lifecycle") s.createNamespace(t, ns) @@ -117,7 +117,7 @@ func (s *E2ESuite) TestCRDLifecycle() { runIn(t, dir, "delete", "crd-lifecycle", "dev", "--yes", "--wait", "--allow-missing-platform", "--context", kindContext, "--namespace", ns) - _, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( + _, err = ext.ApiextensionsV1().CustomResourceDefinitions().Get( t.Context(), crdLifecycleName, metav1.GetOptions{}) require.NoError(t, err, "CRD must survive deployah delete") } @@ -152,8 +152,8 @@ func (s *E2ESuite) TestCRDNewlyAddedOnUpgrade() { src := filepath.Join(s.scenariosDir, "crd-lifecycle") require.DirExists(t, src) - dir := t.TempDir() - copyTree(t, src, dir) + dir, err := inttest.NewScenarioWorkspace(t, src) + require.NoError(t, err) ns := fixtureNamespace("crd-added") s.createNamespace(t, ns) @@ -189,7 +189,7 @@ func (s *E2ESuite) TestCRDNewlyAddedOnUpgrade() { runIn(t, dir, "deploy", "dev", "--context", kindContext, "--namespace", ns) - _, err := ext.ApiextensionsV1().CustomResourceDefinitions().Get( + _, err = ext.ApiextensionsV1().CustomResourceDefinitions().Get( t.Context(), crdAddedName, metav1.GetOptions{}) require.True(t, apierrors.IsNotFound(err), "Helm upgrade must not install a CRD added after first install, got: %v", err) @@ -228,39 +228,6 @@ func hideExtrasManifest(t *testing.T, dir, name string) string { return raw } -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 diff --git a/internal/e2e/e2e_plan_cli_test.go b/internal/e2e/e2e_plan_cli_test.go index 4efc0c1..f925103 100644 --- a/internal/e2e/e2e_plan_cli_test.go +++ b/internal/e2e/e2e_plan_cli_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/require" planCmd "deployah.dev/deployah/internal/cmd/plan" + inttest "deployah.dev/deployah/internal/testing" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -36,8 +37,8 @@ func (s *E2ESuite) TestPlanCLI() { t := s.T() src := filepath.Join(s.scenariosDir, "basic-web-service") require.DirExists(t, src) - dir := t.TempDir() - copyTree(t, src, dir) + dir, err := inttest.NewScenarioWorkspace(t, src) + require.NoError(t, err) ns := fixtureNamespace("plan-cli") t.Cleanup(func() { diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 3037025..8d4e034 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -213,6 +213,12 @@ func (s *E2ESuite) loadE2ECases(t *testing.T) []e2eCase { func (s *E2ESuite) runE2ECase(t *testing.T, c e2eCase) { t.Helper() + // Copy before createNamespace so a bad scenario fails before the + // cluster is touched. TempDir cleanup is registered first, so it runs + // after the Kubernetes cleanup below. + dir, err := inttest.NewScenarioWorkspace(t, c.Dir) + require.NoError(t, err) + ns := fixtureNamespace(c.Name) s.createNamespace(t, ns) t.Cleanup(func() { @@ -224,14 +230,14 @@ func (s *E2ESuite) runE2ECase(t *testing.T, c e2eCase) { // 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, + if _, _, delErr := runInErrContext(t, cleanupCtx, dir, "delete", c.Project, c.Fixture.Env, "--yes", "--wait", "--allow-missing-platform", "--context", kindContext, "--namespace", ns); delErr != nil { t.Logf("cleanup deployah delete failed (non-fatal): %v", delErr) } s.deleteNamespace(t, ns) }) - s.assertE2EFixture(t, c.Dir, c.Project, ns, c.Fixture) + s.assertE2EFixture(t, dir, c.Project, ns, c.Fixture) } func (s *E2ESuite) preloadImages(t *testing.T) { diff --git a/internal/e2e/e2e_wildcard_test.go b/internal/e2e/e2e_wildcard_test.go index fe21008..d18acd2 100644 --- a/internal/e2e/e2e_wildcard_test.go +++ b/internal/e2e/e2e_wildcard_test.go @@ -49,8 +49,8 @@ func (s *E2ESuite) TestWildcardInstances() { src := filepath.Join(s.scenariosDir, "wildcard-review") require.DirExists(t, src) - dir := t.TempDir() - copyTree(t, src, dir) + dir, err := inttest.NewScenarioWorkspace(t, src) + require.NoError(t, err) ns := fixtureNamespace("wildcard-review") s.createNamespace(t, ns) diff --git a/internal/testing/doc.go b/internal/testing/doc.go index 6c3de74..e45d524 100644 --- a/internal/testing/doc.go +++ b/internal/testing/doc.go @@ -18,5 +18,6 @@ // 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. +// files. [LoadE2EFixture] decodes e2e.yaml. [NewScenarioWorkspace] copies a +// scenario into a temporary directory for end-to-end tests. package testing diff --git a/internal/testing/workspace.go b/internal/testing/workspace.go new file mode 100644 index 0000000..17248e0 --- /dev/null +++ b/internal/testing/workspace.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testing + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" +) + +// NewScenarioWorkspace copies sourceDir into a temporary directory +// and returns its absolute path. The directory is removed when tb ends. +func NewScenarioWorkspace(tb testing.TB, sourceDir string) (string, error) { + tb.Helper() + if err := validateScenarioSource(sourceDir); err != nil { + return "", err + } + dst := tb.TempDir() + if err := os.CopyFS(dst, os.DirFS(sourceDir)); err != nil { + return "", fmt.Errorf("copy scenario %s: %w", sourceDir, err) + } + return dst, nil +} + +// validateScenarioSource rejects a path that is not a real directory. +func validateScenarioSource(sourceDir string) error { + // Lstat before WalkDir. WalkDir follows a symlink root, and CopyFS would + // recreate a link that a later open could follow outside the workspace. + info, err := os.Lstat(sourceDir) + if err != nil { + return fmt.Errorf("stat scenario %s: %w", sourceDir, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("scenario %s is a symbolic link", sourceDir) + } + if !info.IsDir() { + return fmt.Errorf("scenario %s is not a directory", sourceDir) + } + + walkErr := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + entry, infoErr := d.Info() + if infoErr != nil { + return infoErr + } + if entry.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symbolic link %s", path) + } + return nil + }) + if walkErr != nil { + return fmt.Errorf("validate scenario %s: %w", sourceDir, walkErr) + } + return nil +} diff --git a/internal/testing/workspace_test.go b/internal/testing/workspace_test.go new file mode 100644 index 0000000..6678b5d --- /dev/null +++ b/internal/testing/workspace_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testing + +import ( + "os" + "path/filepath" + "sync" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func scenarioFiles() map[string]string { + return map[string]string{ + "deployah.yaml": "apiVersion: v1-alpha.5\nproject: demo\n", + "deployah.platform.yaml": "apiVersion: platform/v1-alpha.3\n", + "deployah-replicas-2.yaml": "replicas: 2\n", + "e2e.yaml": "env: dev\n", + ".env": "REGION=eu\n", + ".env.production": "REGION=us\n", + filepath.Join(".deployah", "crds", "example.yaml"): "kind: CustomResourceDefinition\n", + filepath.Join(".deployah", "manifests", "example.yaml"): "kind: ConfigMap\n", + filepath.Join(".deployah", "manifests", "nested", "config.yaml"): "kind: ConfigMap\nname: nested\n", + } +} + +func writeScenarioFixtureAt(tb testing.TB, root string) { + tb.Helper() + for rel, body := range scenarioFiles() { + path := filepath.Join(root, rel) + require.NoError(tb, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(tb, os.WriteFile(path, []byte(body), 0o600)) + } +} + +func TestNewScenarioWorkspace_CopiesFixture(t *testing.T) { + t.Parallel() + + src := t.TempDir() + writeScenarioFixtureAt(t, src) + first, err := NewScenarioWorkspace(t, src) + require.NoError(t, err) + second, err := NewScenarioWorkspace(t, src) + require.NoError(t, err) + + require.True(t, filepath.IsAbs(first)) + require.NotEqual(t, src, first) + require.NotEqual(t, first, second) + for _, dst := range []string{first, second} { + for rel, body := range scenarioFiles() { + got, readErr := os.ReadFile(filepath.Join(dst, rel)) // #nosec G304 -- path under t.TempDir + require.NoError(t, readErr) + assert.Equal(t, body, string(got), rel) + } + f, openErr := os.OpenFile(filepath.Join(dst, "deployah.yaml"), os.O_RDWR, 0) // #nosec G304 -- path under t.TempDir + require.NoError(t, openErr) + require.NoError(t, f.Close()) + } +} + +func TestNewScenarioWorkspace_IsolatesCopies(t *testing.T) { + t.Parallel() + + src := t.TempDir() + writeScenarioFixtureAt(t, src) + first, err := NewScenarioWorkspace(t, src) + require.NoError(t, err) + second, err := NewScenarioWorkspace(t, src) + require.NoError(t, err) + + spec := filepath.Join(first, "deployah.yaml") + require.NoError(t, os.WriteFile(spec, []byte("changed\n"), 0o600)) + got, err := os.ReadFile(filepath.Join(src, "deployah.yaml")) // #nosec G304 -- path under t.TempDir + require.NoError(t, err) + assert.Equal(t, scenarioFiles()["deployah.yaml"], string(got)) + other, err := os.ReadFile(filepath.Join(second, "deployah.yaml")) // #nosec G304 -- path under t.TempDir + require.NoError(t, err) + assert.Equal(t, scenarioFiles()["deployah.yaml"], string(other)) + + require.NoError(t, os.Remove(filepath.Join(first, ".env"))) + _, err = os.Stat(filepath.Join(src, ".env")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(second, ".env")) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(first, "extra.txt"), []byte("only-first\n"), 0o600)) + _, err = os.Stat(filepath.Join(second, "extra.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(src, "extra.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestNewScenarioWorkspace_Parallel(t *testing.T) { + t.Parallel() + + src := t.TempDir() + writeScenarioFixtureAt(t, src) + var mu sync.Mutex + dirs := map[string]string{} + + for _, name := range []string{"one", "two"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir, err := NewScenarioWorkspace(t, src) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600)) + got, readErr := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- path under t.TempDir + require.NoError(t, readErr) + assert.Equal(t, name, string(got)) + _, statErr := os.Stat(filepath.Join(src, name)) + require.ErrorIs(t, statErr, os.ErrNotExist) + + mu.Lock() + dirs[name] = dir + mu.Unlock() + }) + } + + t.Cleanup(func() { + require.Len(t, dirs, 2) + assert.NotEqual(t, dirs["one"], dirs["two"]) + }) +} + +func TestNewScenarioWorkspace_RejectsSource(t *testing.T) { + t.Parallel() + + t.Run("missing", func(t *testing.T) { + t.Parallel() + missing := filepath.Join(t.TempDir(), "missing") + dir, err := NewScenarioWorkspace(t, missing) + require.Empty(t, dir) + require.ErrorIs(t, err, os.ErrNotExist) + require.ErrorContains(t, err, missing) + }) + + t.Run("file", func(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "deployah.yaml") + require.NoError(t, os.WriteFile(file, []byte("x\n"), 0o600)) + dir, err := NewScenarioWorkspace(t, file) + require.Empty(t, dir) + require.ErrorContains(t, err, "not a directory") + require.ErrorContains(t, err, file) + }) +} + +func TestNewScenarioWorkspace_RejectsSymlink(t *testing.T) { + t.Parallel() + + const secret = "secret\n" + outside := filepath.Join(t.TempDir(), "secret.txt") + require.NoError(t, os.WriteFile(outside, []byte(secret), 0o600)) + + tests := []struct { + name string + srcRel string + linkRel string + target string + }{ + { + name: "source directory", + srcRel: "link", + linkRel: "link", + target: "real", + }, + { + name: "relative file inside", + srcRel: "real", + linkRel: filepath.Join("real", "alias.yaml"), + target: "deployah.yaml", + }, + { + name: "directory inside", + srcRel: "real", + linkRel: filepath.Join("real", "linked-manifests"), + target: filepath.Join(".deployah", "manifests"), + }, + { + name: "absolute file outside", + srcRel: "real", + linkRel: filepath.Join("real", "escape.yaml"), + target: outside, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + real := filepath.Join(root, "real") + writeScenarioFixtureAt(t, real) + link := filepath.Join(root, tt.linkRel) + require.NoError(t, os.Symlink(tt.target, link)) + + dir, err := NewScenarioWorkspace(t, filepath.Join(root, tt.srcRel)) + require.Empty(t, dir) + require.ErrorContains(t, err, "symbolic link") + + got, err := os.ReadFile(filepath.Join(real, "deployah.yaml")) // #nosec G304 -- path under t.TempDir + require.NoError(t, err) + assert.Equal(t, scenarioFiles()["deployah.yaml"], string(got)) + info, err := os.Lstat(link) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + outsideGot, err := os.ReadFile(outside) // #nosec G304 -- path under t.TempDir + require.NoError(t, err) + assert.Equal(t, secret, string(outsideGot)) + }) + } +} + +func TestNewScenarioWorkspace_RejectsSpecialFile(t *testing.T) { + t.Parallel() + + src := t.TempDir() + writeScenarioFixtureAt(t, src) + pipe := filepath.Join(src, "pipe") + require.NoError(t, syscall.Mkfifo(pipe, 0o644)) + + dir, err := NewScenarioWorkspace(t, src) + require.Empty(t, dir) + require.ErrorIs(t, err, os.ErrInvalid) +}