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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,9 @@ Put `e2e.yaml` in `scenarios/<name>/` to run that spec on Kind. Generate a
skeleton with:

```sh
go test ./internal/e2e/ -tags e2e -run TestE2EFixtures/<name> -e2e.scaffold
go test -tags e2e ./internal/e2e -run TestE2EFixtures/<name> -e2e.scaffold
```

`-e2e.preserve` keeps the namespace after a failure.

### Build and run

```sh
Expand Down
103 changes: 103 additions & 0 deletions internal/e2e/cluster_plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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 e2e

import (
"errors"
"sync"
)

var (
errClusterExists = errors.New("cluster already exists")
errClusterUnknown = errors.New("cluster lookup failed")
)

// ClusterIntent is what this process may do with a named Kind cluster.
type ClusterIntent int

const (
// ClusterCreate means the name is free. Register cleanup, then create.
ClusterCreate ClusterIntent = iota
// ClusterReplace means force was set. Delete the old cluster, then create.
ClusterReplace
// ClusterRefuse means the name is taken and this run must not delete it.
ClusterRefuse
// ClusterUncertain means the lookup failed. Leave the name alone.
ClusterUncertain
)

// PlanCluster decides whether this run may create or delete a Kind cluster.
func PlanCluster(found bool, getErr error, force bool) ClusterIntent {
// A lookup error stays uncertain even when found is also set.
if getErr != nil {
return ClusterUncertain
}
if found && !force {
return ClusterRefuse
}
if found && force {
return ClusterReplace
}
return ClusterCreate
}

// testCleanup is the part of a test used to register teardown.
type testCleanup interface {
Helper()
Cleanup(func())
Errorf(string, ...any)
}

// OwnCluster registers down once, before create.
func OwnCluster(tb testCleanup, down func() error) {
tb.Helper()
// down still runs when the test stops early.
var once sync.Once
tb.Cleanup(func() {
once.Do(func() {
if down == nil {
return
}
if err := down(); err != nil {
tb.Errorf("cluster down: %v", err)
}
})
})
}

// PrepareCluster runs a PlanCluster result.
func PrepareCluster(tb testCleanup, intent ClusterIntent, down, up func() error) error {
tb.Helper()
// Cleanup is registered before create. Refuse and uncertain do not delete.
switch intent {
case ClusterRefuse:
return errClusterExists
case ClusterUncertain:
return errClusterUnknown
case ClusterReplace:
if err := down(); err != nil {
return err
}
fallthrough
case ClusterCreate:
OwnCluster(tb, down)
if up == nil {
return nil
}
return up()
default:
return errClusterUnknown
}
}
107 changes: 107 additions & 0 deletions internal/e2e/cluster_plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 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 e2e

import (
"errors"
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPlanCluster(t *testing.T) {
t.Parallel()
lookupErr := errors.New("timeout")
tests := []struct {
name string
found bool
err error
force bool
want ClusterIntent
}{
{name: "absent", want: ClusterCreate},
{name: "exists without force", found: true, want: ClusterRefuse},
{name: "exists with force", found: true, force: true, want: ClusterReplace},
{name: "lookup error", err: lookupErr, want: ClusterUncertain},
{name: "lookup error ignores force", err: lookupErr, force: true, want: ClusterUncertain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, PlanCluster(tt.found, tt.err, tt.force))
})
}
}

type fakeCleanup struct {
fns []func()
errors []string
}

func (f *fakeCleanup) Helper() {}
func (f *fakeCleanup) Cleanup(fn func()) { f.fns = append(f.fns, fn) }
func (f *fakeCleanup) Errorf(format string, args ...any) {
f.errors = append(f.errors, fmt.Sprintf(format, args...))
}

func TestPrepareCluster(t *testing.T) {
t.Parallel()
boom := errors.New("boom")
tests := []struct {
name string
intent ClusterIntent
upErr error
downErr error
wantErr string
wantDown string
wantLog []string
cleanups int
}{
{name: "create then clean up", intent: ClusterCreate, wantLog: []string{"up", "down"}, cleanups: 1},
{name: "create failure still cleans up", intent: ClusterCreate, upErr: errors.New("status"), wantErr: "status", wantLog: []string{"up", "down"}, cleanups: 1},
{name: "cleanup reports a down error", intent: ClusterCreate, downErr: boom, wantDown: "cluster down: boom", wantLog: []string{"up", "down"}, cleanups: 1},
{name: "replace deletes before create", intent: ClusterReplace, wantLog: []string{"down", "up", "down"}, cleanups: 1},
{name: "refuse does not delete", intent: ClusterRefuse, wantErr: "cluster already exists", cleanups: 0},
{name: "uncertain does not delete", intent: ClusterUncertain, wantErr: "cluster lookup failed", cleanups: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var log []string
fake := &fakeCleanup{}
err := PrepareCluster(fake, tt.intent,
func() error { log = append(log, "down"); return tt.downErr },
func() error { log = append(log, "up"); return tt.upErr },
)
gotErr := ""
if err != nil {
gotErr = err.Error()
}
require.Len(t, fake.fns, tt.cleanups)
for _, fn := range fake.fns {
fn()
}
for _, fn := range fake.fns {
fn()
}
assert.Equal(t, tt.wantErr, gotErr)
assert.Equal(t, tt.wantLog, log)
assert.Equal(t, tt.wantDown, strings.Join(fake.errors, "\n"))
})
}
}
Loading
Loading