Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions internal/e2e/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 7 additions & 40 deletions internal/e2e/e2e_crd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package e2e_test
import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions internal/e2e/e2e_plan_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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() {
Expand Down
10 changes: 8 additions & 2 deletions internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions internal/e2e/e2e_wildcard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion internal/testing/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 71 additions & 0 deletions internal/testing/workspace.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading