Skip to content
Open
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
2 changes: 1 addition & 1 deletion internal/controller/environments/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func (r *DeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request)

cmd := command.Command{
GVK: environmentsv1alpha1.GroupVersion.WithKind("Deployment"),
Type: command.CmdUpdate,
Type: cmdType,
Obj: &deploymentCR,
}

Expand Down
2 changes: 1 addition & 1 deletion internal/controller/events/githubevent.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (r *GitHubEventReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// ------------------------------------------------
cmd := command.Command{
GVK: eventsv1alpha1.GroupVersion.WithKind("GitHubEvent"),
Type: command.CmdUpdate,
Type: cmdType,
Obj: &gitHubEventCR,
}

Expand Down
2 changes: 1 addition & 1 deletion internal/controller/observers/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
func (r *Reconciler) applyTriggers(ctx context.Context, build *buildv1.Build, resolved *buildresolution.ResolvedBuild) error {
log := ctrl.LoggerFrom(ctx).WithValues("fn", "applyTriggers")

if len(resolved.Spec.Policy.Triggers) == 0 {
if resolved.Spec.Policy == nil || len(resolved.Spec.Policy.Triggers) == 0 {
log.Info("trigger scan: no triggers configured on policy, skipping")
return nil
}
Expand Down
13 changes: 9 additions & 4 deletions internal/domains/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,17 +298,22 @@ func TestBuildDomain_Handle_Delete_ResolutionFailure(t *testing.T) {
}
}

// TestBuildDomain_Handle_Delete_MissingEnvironment covers out-of-order
// deletion: the owning Environment is already gone (e.g. deleted alongside
// or ahead of its Build). Teardown must still succeed — erroring here would
// leave the Build's finalizer in place forever, since the Environment can
// never come back to satisfy a strict lookup.
func TestBuildDomain_Handle_Delete_MissingEnvironment(t *testing.T) {
buildCR := newBuildCR(validBuildContract())
d := newTestDomain(t, buildCR)

err := d.Handle(context.Background(), command.Command{Type: command.CmdDelete, Obj: buildCR})
if err == nil {
t.Fatal("Handle() delete with no owning Environment = nil error, want error")
if err != nil {
t.Fatalf("Handle() delete with no owning Environment = %v, want nil", err)
}

if status, ok := conditionStatus(buildCR.Status.Conditions, "BuildDeleted"); !ok || status != metav1.ConditionFalse {
t.Errorf("BuildDeleted condition = (%v, found=%v), want (False, true)", status, ok)
if status, ok := conditionStatus(buildCR.Status.Conditions, "BuildDeleted"); !ok || status != metav1.ConditionTrue {
t.Errorf("BuildDeleted condition = (%v, found=%v), want (True, true)", status, ok)
}
}

Expand Down
15 changes: 15 additions & 0 deletions internal/mediators/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import (
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"

mediatorenv "github.com/blanketops/environments-controller/internal/mediators"
)

// Mediator manages the prerequisite resources a Build depends on.
Expand Down Expand Up @@ -124,7 +126,20 @@ func (m *Mediator) CleanupPrerequisites(ctx context.Context, resolved *buildReso
// Step 0: Environment lookup
// Same store binding used at creation time — needed so the reconcilers
// target the correct ClusterSecretStore-scoped resources on teardown.
//
// If the Environment was already deleted (e.g. out-of-order deletion
// alongside its children), query.Lookup below fails permanently and
// would otherwise leave this Build's finalizer stuck forever. Skip
// store-bound cleanup in that case and let the finalizer proceed.
// ------------------------------------------------
gone, err := mediatorenv.EnvironmentGone(ctx, m.Client, resolved.Build.Namespace, resolved.Build.Labels)
if err != nil {
return fmt.Errorf("environment existence check: %w", err)
}
if gone {
m.Log.Info("environment already deleted, skipping store-bound cleanup", "namespace", resolved.Build.Namespace)
return nil
}
envCtx, err := query.Lookup(ctx, m.Client, resolved.Build.Namespace, resolved.Build.Labels)
if err != nil {
return fmt.Errorf("environment lookup: %w", err)
Expand Down
15 changes: 15 additions & 0 deletions internal/mediators/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import (
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"

mediatorenv "github.com/blanketops/environments-controller/internal/mediators"
)

// Mediator manages the prerequisite resources a Deployment depends on.
Expand Down Expand Up @@ -150,7 +152,20 @@ func (m *Mediator) CleanupPrerequisites(ctx context.Context, resolved *deploymen
// Step 0: Environment lookup
// Same store binding used at creation time — needed so the reconcilers
// target the correct ClusterSecretStore-scoped resources on teardown.
//
// If the Environment was already deleted (e.g. out-of-order deletion
// alongside its children), query.Lookup below fails permanently and
// would otherwise leave this Deployment's finalizer stuck forever. Skip
// store-bound cleanup in that case and let the finalizer proceed.
// ------------------------------------------------
gone, err := mediatorenv.EnvironmentGone(ctx, m.Client, deploy.Namespace, deploy.Labels)
if err != nil {
return fmt.Errorf("environment existence check: %w", err)
}
if gone {
log.Info("environment already deleted, skipping store-bound cleanup")
return nil
}
envCtx, err := query.Lookup(ctx, m.Client, deploy.Namespace, deploy.Labels)
if err != nil {
return fmt.Errorf("environment lookup: %w", err)
Expand Down
35 changes: 35 additions & 0 deletions internal/mediators/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,41 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
)

// LabelEnvironmentName mirrors query.LabelEnvironmentName. Duplicated here
// rather than imported to keep this package free of the engine module's
// query package — EnvironmentGone is a pre-check callers run before invoking
// query.Lookup, not a replacement for it.
const LabelEnvironmentName = "environments.blanketops.dev/name"

// EnvironmentGone reports whether the Environment CR named by the
// environments.blanketops.dev/name label no longer exists. Mediators call
// this at the top of CleanupPrerequisites before query.Lookup: if the
// Environment was deleted out of order (ahead of, or alongside, its
// children), query.Lookup fails permanently — "must pre-exist" — which
// would keep the child's finalizer in place forever with no way to clear
// it short of a manual patch. When the Environment is confirmed gone, the
// store binding it authorized is no longer resolvable, so store-bound
// cleanup is skipped and the caller lets the finalizer proceed.
//
// A missing name label returns false (not gone) — that's a distinct
// misconfiguration query.Lookup already reports clearly, not something
// this check should paper over.
func EnvironmentGone(ctx context.Context, c client.Client, namespace string, labels map[string]string) (bool, error) {
name := labels[LabelEnvironmentName]
if name == "" {
return false, nil
}
var env env1alpha1.Environment
err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &env)
if apierrors.IsNotFound(err) {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}

func EnsureEnvironment(
ctx context.Context,
c client.Client,
Expand Down
44 changes: 44 additions & 0 deletions internal/mediators/environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,50 @@ func newScopedObject(envName, envType string) client.Object {
}
}

func TestEnvironmentGone_TrueWhenDeleted(t *testing.T) {
c := testsupport.NewFakeClient()

gone, err := EnvironmentGone(context.Background(), c, testNamespace, map[string]string{
LabelEnvironmentName: testAppSampleName,
})
if err != nil {
t.Fatalf("EnvironmentGone() = %v, want nil error", err)
}
if !gone {
t.Error("EnvironmentGone() = false, want true for a nonexistent Environment")
}
}

func TestEnvironmentGone_FalseWhenPresent(t *testing.T) {
existing := &env1alpha1.Environment{
ObjectMeta: metav1.ObjectMeta{Name: testAppSampleName, Namespace: testNamespace},
Spec: env1alpha1.EnvironmentSpec{Contract: testsupport.RawContract(map[string]any{keyApplicationName: testAppSampleName})},
}
c := testsupport.NewFakeClient(existing)

gone, err := EnvironmentGone(context.Background(), c, testNamespace, map[string]string{
LabelEnvironmentName: testAppSampleName,
})
if err != nil {
t.Fatalf("EnvironmentGone() = %v, want nil error", err)
}
if gone {
t.Error("EnvironmentGone() = true, want false for an existing Environment")
}
}

func TestEnvironmentGone_FalseWhenLabelMissing(t *testing.T) {
c := testsupport.NewFakeClient()

gone, err := EnvironmentGone(context.Background(), c, testNamespace, map[string]string{})
if err != nil {
t.Fatalf("EnvironmentGone() = %v, want nil error", err)
}
if gone {
t.Error("EnvironmentGone() = true, want false when the name label is absent — that's query.Lookup's error to report, not this check's")
}
}

func TestEnsureEnvironment_NotEnvironmentScoped(t *testing.T) {
c := testsupport.NewFakeClient()
obj := &env1alpha1.Build{ObjectMeta: metav1.ObjectMeta{Name: testBuildName, Namespace: testNamespace}}
Expand Down
53 changes: 27 additions & 26 deletions internal/mediators/githubevent/githubevent.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ before the application layer may act: the GitHub webhook HMAC secret used
by the Argo Events sensor to verify payload signatures. It is invoked by
the GitHubEvent domain during command handling — after resolution, before
execution — and again during teardown.
Prerequisite provisioning is gated on the Environment: the Environment CR
must pre-exist as the root of the delivery chain, and it is the sole
authority for the ClusterSecretStore binding used by every store-dependent
secret this mediator reconciles.

Provisioning (EnsurePrerequisites) is gated on the Environment: the
Environment CR must pre-exist, and it is the sole authority for the
ClusterSecretStore binding the webhook secret is written through.

Teardown (CleanupPrerequisites) is deliberately NOT gated on the
Environment. GitHubEvent CRs are written by the Argo Events Sensor into the
fixed argo-events namespace, not the Environment's own namespace —
Environment is namespace-scoped and dynamic, and has no authority over
argo-events. Deleting the webhook secret only needs its name and namespace,
not a store binding, so teardown skips the lookup entirely rather than
depending on a relationship that doesn't hold for this CR.
*/
package githubevents

Expand All @@ -33,7 +41,6 @@ import (
githubeventResolution "github.com/blanketops/environments/resolution/githubevent/resolve"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/runtime"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
)
Expand Down Expand Up @@ -97,35 +104,29 @@ func (m *Mediator) EnsurePrerequisites(ctx context.Context, resolved *githubeven

// CleanupPrerequisites reverses EnsurePrerequisites — deletes the GitHub
// webhook HMAC secret this mediator provisioned. Called from the domain's
// CmdDelete branch, gated by the finalizer at the controller level. Teardown
// runs in reverse provisioning order. All teardown steps are attempted
// regardless of individual failures, and errors are aggregated. Any returned
// error keeps the finalizer in place for retry on next reconcile.
// CmdDelete branch, gated by the finalizer at the controller level. Any
// returned error keeps the finalizer in place for retry on next reconcile.
//
// Deliberately does NOT look up the owning Environment. GitHubEvent CRs are
// written by the Argo Events Sensor into the fixed argo-events namespace,
// not the Environment's own namespace — Environment is namespace-scoped and
// dynamic, and cannot own resources living in argo-events. Requiring the
// lookup here would make it permanently unsatisfiable (or, if the labels
// happen to line up, would tie teardown to Environment lifecycle it has no
// authority over). GitHubWebhookSecretReconciler.Delete only needs the
// secret's name and namespace to remove it — the store binding was only
// ever needed to create it, not to delete it — so no store context is
// needed here either.
func (m *Mediator) CleanupPrerequisites(ctx context.Context, resolved *githubeventResolution.ResolvedGitHubEvent) error {
if resolved == nil || resolved.Event == nil || resolved.Spec == nil {
return fmt.Errorf("nil ResolvedGitHubEvent provided to mediator")
}
event := resolved.Event
// ------------------------------------------------
// Step 0: Environment lookup
// Same store binding used at creation time — needed so the reconcilers
// target the correct ClusterSecretStore-scoped resources on teardown.
// ------------------------------------------------
envCtx, err := query.Lookup(ctx, m.Client, event.Namespace, event.Labels)
if err != nil {
return fmt.Errorf("environment lookup: %w", err)
}
m.Log.Info("environment context resolved for teardown", "environment", envCtx.Name, "type", envCtx.EnvironmentType, "store", envCtx.StoreName)
var errs []error
// ------------------------------------------------------------------------------------------------------------
// Stage 1: GitHub webhook secret
// ------------------------------------------------------------------------------------------------------------
webhookSecret := github.NewGitHubWebhookSecretReconciler(m.Client, m.Log, envCtx.StoreName, envCtx.StoreKind)
webhookSecret := github.NewGitHubWebhookSecretReconciler(m.Client, m.Log, "", "")
if err := webhookSecret.Delete(ctx, resolved); err != nil {
errs = append(errs, fmt.Errorf("delete github webhook secret: %w", err))
}
if len(errs) > 0 {
return utilerrors.NewAggregate(errs)
return fmt.Errorf("delete github webhook secret: %w", err)
}
return nil
}
18 changes: 18 additions & 0 deletions internal/mediators/githubevent/githubevent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ func TestMediator_EnsurePrerequisites_Idempotent(t *testing.T) {
}
}

// TestMediator_CleanupPrerequisites_NoEnvironment locks in the fact that
// teardown does not depend on the Environment lookup. In production,
// GitHubEvent CRs are written by the Argo Events Sensor into the fixed
// argo-events namespace — never the Environment's own namespace — so a
// lookup gated the same way as EnsurePrerequisites would make teardown
// permanently unsatisfiable. No Environment exists in the fake client at
// all here, which would fail EnsurePrerequisites; CleanupPrerequisites must
// still succeed.
func TestMediator_CleanupPrerequisites_NoEnvironment(t *testing.T) {
resolved := newResolvedGitHubEvent()
c := testsupport.NewFakeClient(resolved.Event)
m := New(c, testsupport.NewScheme(), logr.Discard(), testsupport.NoopRawRecorder())

if err := m.CleanupPrerequisites(context.Background(), resolved); err != nil {
t.Fatalf("CleanupPrerequisites() with no Environment = %v, want nil", err)
}
}

func TestMediator_CleanupPrerequisites_NilResolved(t *testing.T) {
m := New(testsupport.NewFakeClient(), testsupport.NewScheme(), logr.Discard(), testsupport.NoopRawRecorder())
if err := m.CleanupPrerequisites(context.Background(), nil); err == nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/mediators/gitrepository/gitrepository.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import (
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"

mediatorenv "github.com/blanketops/environments-controller/internal/mediators"
)

// Mediator manages the prerequisite resources a GitRepository depends on.
Expand Down Expand Up @@ -140,7 +142,20 @@ func (m *Mediator) CleanupPrerequisites(ctx context.Context, resolved *gitrepoRe
// Step 0: Environment lookup
// Same store binding used at creation time — needed so the reconcilers
// target the correct ClusterSecretStore-scoped resources on teardown.
//
// If the Environment was already deleted (e.g. out-of-order deletion
// alongside its children), query.Lookup below fails permanently and
// would otherwise leave this GitRepository's finalizer stuck forever.
// Skip store-bound cleanup in that case and let the finalizer proceed.
// ------------------------------------------------
gone, err := mediatorenv.EnvironmentGone(ctx, m.Client, repo.Namespace, repo.Labels)
if err != nil {
return fmt.Errorf("environment existence check: %w", err)
}
if gone {
m.Log.Info("environment already deleted, skipping store-bound cleanup", "namespace", repo.Namespace)
return nil
}
envCtx, err := query.Lookup(ctx, m.Client, repo.Namespace, repo.Labels)
if err != nil {
return fmt.Errorf("environment lookup: %w", err)
Expand Down
Loading