From ef390f5f3f7475c435fb87466c8c95b5cec6cdbc Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 22 Aug 2026 13:19:20 +0200 Subject: [PATCH 1/2] feat: wire Domain CR through the CQRS engine Builds out the Domain CR follow-up to Route: internal/domains/domain was an empty stub, internal/controller/networks/domain.go's Reconciler was a genuine no-op (fetch, return -- no finalizer, no engine dispatch, no Runtime field), and internal/cache/domain didn't exist. All three are now wired the same way Route is: finalizer-gated reconciler, Engine.Execute dispatch, DomainDomain handling create/update/delete via DomainService. Domain's KnativeProvider needs an ACMEConfig (server, account email, private key secret name) that nothing in this controller previously supplied -- there was no existing flag or config surface for it. Added three flags to cmd/main.go and split Domain registration out of RegisterControllers into its own RegisterDomain(mgr, rt, acmeConfig), mirroring how RegisterBuild is already split out for its own dependency (a Shipwright client). Also fixes a more severe, pre-existing gap this surfaced: neither Knative (serving.knative.dev, networking.internal.knative.dev) nor cert-manager.io schemes were ever registered anywhere in this controller -- Route's own KnativeProvider (DomainMapping) would have hit "no kind is registered for the type ... in scheme" the first time it tried to create one, the same class of bug the missing networksv1alpha1 registration was before it. Added all three to both RegisterSchemes (production) and testsupport.NewScheme (tests). Left DomainCache.PublishStatus unwired -- DomainService.Reconcile doesn't return the values it needs (domainReady, certRef, mappingRef), and fabricating them would be worse than leaving the gap for whichever follow-up builds the Route mediator's Domain integration. Draft: blocked on an environments release containing #316 (DomainService.Teardown) -- go.mod stays pinned at v0.8.0, which doesn't have it yet. Confirmed the exact failure: internal/domains/domain/domain.go:165:29: d.domainService.Teardown undefined (type *application.DomainService has no field or method Teardown) everything else in this change compiles and tests cleanly against the released v0.8.0. Co-Authored-By: Claude Sonnet 5 --- cmd/main.go | 21 +++ go.mod | 8 +- go.sum | 8 +- internal/bootstrap/register.go | 45 ++++-- internal/cache/domain/domain.go | 31 ++++ internal/controller/networks/domain.go | 173 ++++++++++++++++++--- internal/domains/domain/domain.go | 204 +++++++++++++++++++++++-- internal/domains/domain/domain_test.go | 198 ++++++++++++++++++++++++ internal/testsupport/testsupport.go | 6 + 9 files changed, 649 insertions(+), 45 deletions(-) create mode 100644 internal/cache/domain/domain.go create mode 100644 internal/domains/domain/domain_test.go diff --git a/cmd/main.go b/cmd/main.go index f3b8659..0a751b2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,7 @@ import ( "github.com/blanketops/environments/core/engine" "github.com/blanketops/environments/core/events" "github.com/blanketops/environments/core/registry" + domainapi "github.com/blanketops/environments/pkg/apis/domain/api" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" @@ -62,9 +63,19 @@ type Runtime interface { func main() { var enableLeaderElection bool var probeAddr string + var acmeServer string + var acmeEmail string + var acmePrivateKeySecretName string flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Probe bind address") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election") + flag.StringVar(&acmeServer, "acme-server", + "https://acme-v02.api.letsencrypt.org/directory", + "ACME directory URL for custom-strategy Domain certificates") + flag.StringVar(&acmeEmail, "acme-email", "", + "ACME account email for custom-strategy Domain certificates (required for custom-strategy Domains)") + flag.StringVar(&acmePrivateKeySecretName, "acme-private-key-secret-name", "acme-account-key", + "Name of the Secret cert-manager stores the ACME account private key in") flag.Parse() ctrl.SetLogger(zap.New(zap.UseDevMode(true))) @@ -102,6 +113,16 @@ func main() { os.Exit(1) } + acmeConfig := domainapi.ACMEConfig{ + Server: acmeServer, + Email: acmeEmail, + PrivateKeySecretName: acmePrivateKeySecretName, + } + if err := bootstrap.RegisterDomain(mgr, rt, acmeConfig); err != nil { + setupLog.Error(err, "failed to register domain controller") + os.Exit(1) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to add health check") os.Exit(1) diff --git a/go.mod b/go.mod index 1f69b73..06ccc38 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/argoproj/argo-events v1.9.11 github.com/blanketops/environments v0.8.0 github.com/blanketops/environments-api v0.2.7 + github.com/cert-manager/cert-manager v1.21.1 github.com/fluxcd/kustomize-controller/api v1.9.4 github.com/fluxcd/source-controller/api v1.9.4 github.com/go-logr/logr v1.4.4 @@ -20,6 +21,8 @@ require ( k8s.io/api v0.36.3 k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 + knative.dev/networking v0.0.0-20260727162500-c7a7b772cac9 + knative.dev/serving v0.50.0 sigs.k8s.io/controller-runtime v0.24.1 ) @@ -58,7 +61,7 @@ require ( github.com/go-openapi/swag/typeutils v0.26.1 // indirect github.com/go-openapi/swag/yamlutils v0.26.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/cel-go v0.28.1 // indirect + github.com/google/cel-go v0.29.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.21.7 // indirect @@ -115,9 +118,8 @@ require ( k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 // indirect k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect - knative.dev/networking v0.0.0-20260727162500-c7a7b772cac9 // indirect knative.dev/pkg v0.0.0-20260727151759-521cb33b33dd // indirect - knative.dev/serving v0.50.0 // indirect + sigs.k8s.io/gateway-api v1.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.1 // indirect diff --git a/go.sum b/go.sum index 3249797..7ad2726 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cert-manager/cert-manager v1.21.1 h1:0LttV37Q5c2CBNoHkjuI8sLKTXWZDC2SwQkxrBMKV9w= +github.com/cert-manager/cert-manager v1.21.1/go.mod h1:sVwmLBWoiB1BRd0rJElBGQuiu94z4k7p3Kd0FRQyfgw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= @@ -107,8 +109,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= -github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= +github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -323,6 +325,8 @@ knative.dev/serving v0.50.0 h1:9XUQq2yqUPN1YZUuGH6nF8IMvuumNvGXzCrSTYu44bA= knative.dev/serving v0.50.0/go.mod h1:MP+yKsx/gLczU+agah1HgRrpU6KT0SV96a7bD+v5PKo= 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/gateway-api v1.6.0 h1:735YBRj5NXFrOGX0GoSjwzUIzbz8kiEOfADsqHFmHgE= +sigs.k8s.io/gateway-api v1.6.0/go.mod h1:FVfx3t389ybeXOqvDghLbdvJdSCfI/PReqCUI3lu3mY= 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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/bootstrap/register.go b/internal/bootstrap/register.go index ac08d27..1dbd4f7 100644 --- a/internal/bootstrap/register.go +++ b/internal/bootstrap/register.go @@ -39,7 +39,9 @@ import ( sourcesv1alpha1 "github.com/blanketops/environments-api/api/sources/v1alpha1" buildapi "github.com/blanketops/environments/pkg/apis/build/api" buildapp "github.com/blanketops/environments/pkg/apis/build/application" + domainapi "github.com/blanketops/environments/pkg/apis/domain/api" gitrepoapi "github.com/blanketops/environments/pkg/apis/gitrepository/api" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" fluxcdsourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/go-logr/logr" @@ -59,6 +61,8 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/events" + knnetworkingv1alpha1 "knative.dev/networking/pkg/apis/networking/v1alpha1" + knservingv1beta1 "knative.dev/serving/pkg/apis/serving/v1beta1" ctrl "sigs.k8s.io/controller-runtime" "github.com/blanketops/environments-controller/internal/controller/environments" @@ -78,8 +82,17 @@ import ( // providers need to the runtime scheme: this repo's own environments, // events, sources, and networks types from environments-api, plus the // external CRDs — Argo Events, Flux (source and kustomize controllers), -// Kapp Controller, Shipwright, and Tekton Pipelines — that the domains and -// mediators reconcile against. +// Kapp Controller, Shipwright, Tekton Pipelines, cert-manager, and Knative +// serving/networking — that the domains and mediators reconcile against. +// +// The Knative and cert-manager registrations were missing entirely until +// the Domain CR follow-up added them: Route's KnativeProvider (DomainMapping, +// serving.knative.dev) and Domain's KnativeProvider (ClusterDomainClaim, +// networking.internal.knative.dev; Issuer/Certificate, cert-manager.io) +// would otherwise fail the first time either tried to create a resource +// through this manager's client, with "no kind is registered for the type +// ... in scheme" — the same class of bug the missing networksv1alpha1 +// registration was before it. func RegisterSchemes(scheme *runtime.Scheme) { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(environmentsv1alpha1.AddToScheme(scheme)) @@ -93,6 +106,9 @@ func RegisterSchemes(scheme *runtime.Scheme) { utilruntime.Must(fluxcdsourcev1.AddToScheme(scheme)) utilruntime.Must(kustomizev1.AddToScheme(scheme)) utilruntime.Must(networksv1alpha1.AddToScheme(scheme)) + utilruntime.Must(certmanagerv1.AddToScheme(scheme)) + utilruntime.Must(knservingv1beta1.AddToScheme(scheme)) + utilruntime.Must(knnetworkingv1alpha1.AddToScheme(scheme)) } // EnsureServiceAccount creates the manager's ServiceAccount if it does not @@ -213,7 +229,9 @@ func RegisterObservers(mgr ctrl.Manager) error { } // RegisterControllers wires up the primary CQRS reconcilers: GitRepository, -// GitHubEvent, Deployment, ServiceUnit, Package, Environment, Route, Domain. +// GitHubEvent, Deployment, ServiceUnit, Package, Environment, Route. +// Domain is registered separately by RegisterDomain — it needs an ACME +// config the manager doesn't otherwise construct. func RegisterControllers(mgr ctrl.Manager, rt *runtimeinfra.Runtime) error { if err := (&sources.GitRepositoryReconciler{ Client: mgr.GetClient(), @@ -251,13 +269,6 @@ func RegisterControllers(mgr ctrl.Manager, rt *runtimeinfra.Runtime) error { }).SetupWithManager(mgr); err != nil { return err } - // Domain.Reconcile is a stub -- safe, but inert until it has logic. - if err := (&networks.DomainReconciler{ - Client: mgr.GetClient(), - }).SetupWithManager(mgr); err != nil { - return err - } - if err := (&environments.PackageReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -314,3 +325,17 @@ func RegisterBuild( BuildService: buildService, }).SetupWithManager(mgr) } + +// RegisterDomain wires up and registers the DomainReconciler. It's separate +// from RegisterControllers because Domain needs an ACME config (server, +// account email, private key secret name) that the manager doesn't +// otherwise construct — cmd/main.go sources it from flags/env and has no +// safe hardcoded default for the account email. +func RegisterDomain(mgr ctrl.Manager, rt *runtimeinfra.Runtime, acmeConfig domainapi.ACMEConfig) error { + return (&networks.DomainReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Runtime: rt, + ACME: acmeConfig, + }).SetupWithManager(mgr) +} diff --git a/internal/cache/domain/domain.go b/internal/cache/domain/domain.go new file mode 100644 index 0000000..690df1f --- /dev/null +++ b/internal/cache/domain/domain.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 The BlanketOps 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. +*/ + +// domain.go constructs this controller's Domain domain cache: a thin +// wrapper around blanketops-environments-core's cache/domain package. +// +// The cache itself, and the write path that populates it, live in the +// external core library; this file owns only the constructor. +package domain + +import ( + libdomain "github.com/blanketops/environments/cache/domain" + "github.com/blanketops/environments/core/cache" +) + +// New constructs a Domain domain cache backed by c. +func New(c *cache.Cache) *libdomain.DomainCache { + return libdomain.NewDomainCache(c) +} diff --git a/internal/controller/networks/domain.go b/internal/controller/networks/domain.go index bb19fc8..3b1aeb8 100644 --- a/internal/controller/networks/domain.go +++ b/internal/controller/networks/domain.go @@ -22,29 +22,59 @@ The reconciler is deliberately thin. It owns three responsibilities only: 3. Hand the ResolvedDomain to DomainService, which maps, selects, dispatches, and writes status. -All business logic lives in pkg/Domains/application. The reconciler does not -build conditions, select providers, or touch the runtime resource directly. -A resolution failure is terminal for this generation — it is logged and the -request is dropped (no requeue) because re-running the same bad contract will -fail identically. Service errors are returned for controller-runtime to requeue -with backoff. +All business logic lives in pkg/apis/domain/application. The reconciler does +not build conditions, select providers, or touch the runtime resource +directly. A resolution failure is terminal for this generation — it is +logged and the request is dropped (no requeue) because re-running the same +bad contract will fail identically. Service errors are returned for +controller-runtime to requeue with backoff. + +ACME is set by RegisterDomain (internal/bootstrap) before SetupWithManager +runs — it configures the cert-manager Issuer the Knative provider creates +for custom-strategy domains, and has no safe hardcoded default. */ package networks import ( "context" + "time" networksv1alpha1 "github.com/blanketops/environments-api/api/networks/v1alpha1" + "github.com/blanketops/environments/core/command" + "github.com/blanketops/environments/core/predicates" + domainapi "github.com/blanketops/environments/pkg/apis/domain/api" + domainapp "github.com/blanketops/environments/pkg/apis/domain/application" "github.com/go-logr/logr" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/events" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + domaindomain "github.com/blanketops/environments-controller/internal/domains/domain" + runtimeinfra "github.com/blanketops/environments-controller/internal/runtime" ) -// DomainReconciler reconciles a Domain CR by resolving its contract and handing -// it to the Domain application service. +// domainFinalizer gates deletion of a Domain CR until DomainService.Teardown +// has run successfully. See Reconcile for the add/check/remove lifecycle. +const domainFinalizer = "networks.blanketops.dev/domain-finalizer" + +// DomainReconciler reconciles a Domain CR by resolving its contract and +// handing it to the domain application service. type DomainReconciler struct { - Client client.Client - Log logr.Logger + client.Client + Log logr.Logger + Scheme *runtime.Scheme + DomainService *domainapp.DomainService + Runtime *runtimeinfra.Runtime + Recorder events.EventRecorder + + // ACME configures the cert-manager Issuer the Knative provider creates + // for custom-strategy domains. Must be set before SetupWithManager runs. + ACME domainapi.ACMEConfig } // +kubebuilder:rbac:groups=networks.blanketops.dev,resources=domains,verbs=get;list;watch;create;update;patch;delete @@ -54,25 +84,128 @@ type DomainReconciler struct { // +kubebuilder:rbac:groups=networking.internal.knative.dev,resources=clusterdomainclaims,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete -// Reconcile fetches the Domain, resolves its contract, and delegates to the -// application service. See file header for the responsibility split. +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. func (r *DomainReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - // log := log.FromContext(ctx).WithValues("Domain", req.NamespacedName) + log := ctrl.LoggerFrom(ctx).WithValues("controller", "domain", "namespace", req.Namespace, "name", req.Name) + ctx = logr.NewContext(ctx, log) + + log.Info("reconcile start") + + // Fetch Domain + var domainCR networksv1alpha1.Domain + if err := r.Get(ctx, req.NamespacedName, &domainCR); err != nil { + if client.IgnoreNotFound(err) == nil { + log.Info("reconcile exit: domain not found (deleted)") + return ctrl.Result{}, nil + } + log.Error(err, "failed to fetch domain") + return ctrl.Result{}, err + } + log.Info("domain fetched", "generation", domainCR.Generation, "resourceVersion", domainCR.ResourceVersion) + + // Finalizer gate — determines cmd.Type + cmdType := command.CmdUpdate + if !domainCR.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(&domainCR, domainFinalizer) { + log.Info("reconcile exit: deletion in progress, finalizer already removed") + return ctrl.Result{}, nil + } + cmdType = command.CmdDelete + } else if !controllerutil.ContainsFinalizer(&domainCR, domainFinalizer) { + controllerutil.AddFinalizer(&domainCR, domainFinalizer) + if err := r.Update(ctx, &domainCR); err != nil { + log.Error(err, "failed to add finalizer") + return ctrl.Result{}, err + } + log.Info("finalizer added") + return ctrl.Result{RequeueAfter: time.Second}, nil + } + + // Construct core command + cmd := command.Command{ + GVK: networksv1alpha1.GroupVersion.WithKind("Domain"), + Type: cmdType, + Obj: &domainCR, + } + + log.Info("routing domain to core engine", "gvk", cmd.GVK.String(), "command", cmd.Type) + + // Execute domain logic via engine + if err := r.Runtime.Engine.Execute(ctx, cmd); err != nil { + log.Error(err, "engine execution failed") + r.Recorder.Eventf(&domainCR, nil, corev1.EventTypeWarning, "EngineFailure", "Execute", "%v", err) + log.Info("reconcile exit: engine error") + return ctrl.Result{}, err + } - var domain networksv1alpha1.Domain - if err := r.Client.Get(ctx, req.NamespacedName, &domain); err != nil { - // NotFound: the CR was deleted. Owned runtime resources are garbage - // collected via ownerReference — nothing to do here. - return ctrl.Result{}, client.IgnoreNotFound(err) + log.Info("engine execution completed") + // Deletion path: remove finalizer now that the engine returned nil. + // Status is intentionally NOT written here — the object is about to be + // removed, and racing a status update against finalizer removal serves + // no purpose. + if cmdType == command.CmdDelete { + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var latest networksv1alpha1.Domain + if err := r.Get(ctx, req.NamespacedName, &latest); err != nil { + return client.IgnoreNotFound(err) + } + controllerutil.RemoveFinalizer(&latest, domainFinalizer) + return r.Update(ctx, &latest) + }); err != nil { + log.Error(err, "failed to remove finalizer") + return ctrl.Result{}, err + } + log.Info("finalizer removed, deletion will proceed") + return ctrl.Result{}, nil + } + // Persist status (retry-on-conflict) — create/update path only + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var latest networksv1alpha1.Domain + if err := r.Get(ctx, req.NamespacedName, &latest); err != nil { + return err + } + latest.Status = domainCR.Status + return r.Status().Update(ctx, &latest) + }); err != nil { + log.Error(err, "failed to update domain status") + return ctrl.Result{}, err } + log.Info("domain status updated successfully") + log.Info("reconcile done") + return ctrl.Result{}, nil } -// SetupWithManager registers the reconciler with the controller manager and -// declares the Domain CR as the primary watched resource. +// SetupWithManager sets up the controller with the Manager. func (r *DomainReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Logging & events + r.Log = ctrl.Log.WithName("controllers").WithName("Domain") + r.Recorder = mgr.GetEventRecorder("domain-controller") + + // Runtime Infrastructure + cache := r.Runtime.Cache + eventsRecorder := r.Runtime.Events + registry := r.Runtime.Registry + + // Provider (runtime backend). Domain has no cross-cutting prerequisites + // (no mediator) — DomainService dispatches to this directly. + knativeBackend := domainapi.NewKnativeProvider(mgr.GetClient(), r.Log.WithName("backend.knative"), r.ACME) + backendSelector := domainapp.NewBackendSelector(knativeBackend) + + // Service Layer (Mapper and StatusWriter, domain service for orchestration) + mapper := domainapp.NewMapper() + statusWriter := domainapp.NewStatusWriter(mgr.GetClient(), r.Log.WithName("domain-status-writer")) + r.DomainService = domainapp.NewDomainService(mapper, statusWriter, backendSelector) + + // Registry (Domain Registration, domain orchestrates service + cache) + domainDomain := domaindomain.New(r.DomainService, cache, eventsRecorder, r.Log.WithName("domain.domain")) + registry.RegisterDomain(networksv1alpha1.GroupVersion.WithKind("Domain"), domainDomain) + return ctrl.NewControllerManagedBy(mgr). For(&networksv1alpha1.Domain{}). + Named("networks-domain"). + WithEventFilter(predicates.MeaningfulChangePredicate()). Complete(r) } diff --git a/internal/domains/domain/domain.go b/internal/domains/domain/domain.go index 9c969ac..e68ae36 100644 --- a/internal/domains/domain/domain.go +++ b/internal/domains/domain/domain.go @@ -14,15 +14,199 @@ limitations under the License. */ /* -Package domain is reserved for the Domain CR's CQRS Domain implementation -(core/domain.Domain) — the piece that would let Domain CR reconciliation -route through the shared Engine the way Build, Deployment, GitHubEvent, -GitRepository, and Package already do. - -Not yet implemented: this package is currently empty, and nothing imports -it. The Domain CR is instead reconciled directly by -internal/controller/networks.DomainReconciler, whose Reconcile is itself a -no-op stub (fetches the CR, returns immediately) — Domain CR support has -no real behavior anywhere in this controller yet. +Package domain implements the Domain resource domain. + +The Domain domain is responsible for managing the lifecycle of Domain +resources. It receives commands from the Engine, resolves resource +specifications into validated contracts, delegates processing to the +application layer, and records reconciliation outcomes through conditions +and events. + +Like Route, Domain has no cross-cutting prerequisites of its own (no +secrets, no ServiceAccounts, no RBAC to provision) — DomainService +dispatches directly to the Knative provider with no mediator stage in +between. + +Domain publishes only its resolved spec to the cache (PublishResolved). +DomainCache also exposes PublishStatus for scalar status fields +(domainReady, certificateRef, domainMappingRef) that the Route mediator +will eventually read to gate DomainMapping dispatch — but DomainService's +Reconcile does not yet return the values PublishStatus needs, so that call +is left for whichever follow-up builds the Route mediator's Domain +integration rather than wired here with fabricated inputs. */ package domain + +import ( + "context" + "fmt" + "reflect" + + networksv1alpha1 "github.com/blanketops/environments-api/api/networks/v1alpha1" + libdomain "github.com/blanketops/environments/cache/domain" + "github.com/blanketops/environments/core/cache" + "github.com/blanketops/environments/core/command" + "github.com/blanketops/environments/core/conditions" + "github.com/blanketops/environments/core/events" + "github.com/blanketops/environments/pkg/apis/domain/application" + domainResolution "github.com/blanketops/environments/resolution/domain/resolve" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DomainDomain implements the Domain resource domain logic. +type DomainDomain struct { + // domainService handles business logic for domain operations: mapping, + // backend selection, provider dispatch, and status persistence. + domainService *application.DomainService + + // domainCache provides generation-scoped, field-level caching for + // Domain resources. Advisory only: misses and errors fall through + // to full computation; correctness never depends on a hit. + domainCache *libdomain.DomainCache + + // events handles logging of Kubernetes events. + events *events.EventRecorder + + // log is the logger instance for this domain. + log logr.Logger +} + +// New returns a new DomainDomain instance configured with the necessary dependencies. +func New(domainService *application.DomainService, domainCache *cache.Cache, eventRecorder *events.EventRecorder, log logr.Logger) *DomainDomain { + return &DomainDomain{ + domainService: domainService, + domainCache: libdomain.NewDomainCache(domainCache), + events: eventRecorder, + log: log, + } +} + +// GVK tells the engine which CRD this domain handles. +func (d *DomainDomain) GVK() schema.GroupVersionKind { + return networksv1alpha1.GroupVersion.WithKind("Domain") +} + +// Handle executes command.Command operations routed by the Engine. +func (d *DomainDomain) Handle(ctx context.Context, cmd command.Command) error { + + domainCR, ok := cmd.Obj.(*networksv1alpha1.Domain) + if !ok || domainCR == nil { + return fmt.Errorf("invalid object passed to DomainDomain: %T", cmd.Obj) + } + + log := d.log.WithValues("domain", "domain", "name", domainCR.Name, "namespace", domainCR.Namespace) + log.Info("handling domain command", "type", cmd.Type) + + nn := client.ObjectKeyFromObject(domainCR) + gen := domainCR.GetGeneration() + + switch cmd.Type { + case command.CmdCreate, command.CmdUpdate: + + // Stage 0: Resolve Domain contract + log.Info("resolving domain contract") + resolved, err := domainResolution.ResolveDomain(domainCR) + if err != nil { + log.Error(err, "domain resolution failed") + d.events.FromError(domainCR, "DomainResolveFailed", err) + conditions.SetCondition(&domainCR.Status.Conditions, "DomainResolveFailed", conditions.ConditionFalse, "DomainResolve", err.Error()) + return err + } + + // Stage 1: Publish resolved contract to cache for observability and potential reuse within the same generation. + if cerr := d.domainCache.PublishResolved(ctx, nn, gen, resolved); cerr != nil { + log.V(1).Info("resolved projection publish incomplete", "error", cerr.Error()) + d.events.FromError(domainCR, "DomainCacheFailed", cerr) + conditions.SetCondition(&domainCR.Status.Conditions, "DomainCacheFailed", conditions.ConditionFalse, "resolved projection publish incomplete", cerr.Error()) + } + + log.Info("domain resolved successfully") + d.events.Normal(domainCR, "DomainResolve", "Domain specification resolved successfully") + conditions.SetCondition(&domainCR.Status.Conditions, "DomainResolved", conditions.ConditionTrue, "DomainSpecResolved", "Domain specification resolved successfully") + + log.Info("domain cached successfully") + d.events.Normal(domainCR, "DomainCache", "Domain specification cached successfully") + conditions.SetCondition(&domainCR.Status.Conditions, "DomainCached", conditions.ConditionTrue, "DomainSpecCached", "Domain specification cached successfully") + + // Stage 2: Map, select backend, dispatch, and persist status. + // DomainService owns condition derivation and status persistence for + // the reconciliation outcome itself (Ready/DomainClaimReady/ + // CertificateReady) — separate from the domain-level conditions this + // Handle sets above. ErrCertProvisioning is returned unwrapped so the + // controller requeues while ACME issuance is in progress. + log.Info("dispatching domain to provider") + if err := d.domainService.Reconcile(ctx, resolved); err != nil { + log.Error(err, "domain reconciliation failed") + d.events.FromError(domainCR, "DomainFailed", err) + return err + } + + log.Info("domain domain handling complete") + d.events.Normal(domainCR, "DomainSucceeded", "domain reconciliation completed successfully") + + case command.CmdDelete: + // Real teardown, gated by finalizer at the controller level. + // Handle() must return nil ONLY if it is safe for the + // controller to remove the finalizer and let K8s finish + // deleting the object. Any error here keeps the finalizer + // in place and the controller will retry on next reconcile. + log.Info("domain teardown requested") + + resolved, err := domainResolution.ResolveDomain(domainCR) + if err != nil { + log.Error(err, "resolution failed during teardown") + d.events.FromError(domainCR, "DomainTeardownResolveFailed", err) + conditions.SetCondition(&domainCR.Status.Conditions, "DomainDeleted", conditions.ConditionFalse, "DomainTeardownResolveFailed", err.Error()) + return err + } + + if err := d.domainService.Teardown(ctx, resolved); err != nil { + log.Error(err, "domain teardown failed") + d.events.FromError(domainCR, "DomainTeardownFailed", err) + conditions.SetCondition(&domainCR.Status.Conditions, "DomainDeleted", conditions.ConditionFalse, "DomainTeardownFailed", err.Error()) + return err + } + + // Drop the projection for this object (all generations). No-op on + // backends without key enumeration; generation scoping + TTL + // covers correctness there. + if cerr := d.domainCache.Invalidate(ctx, nn); cerr != nil { + log.V(1).Info("projection invalidation failed", "error", cerr.Error()) + } + + log.Info("domain teardown complete") + d.events.Normal(domainCR, "DomainDeleted", "domain and owned resources cleaned up successfully") + conditions.SetCondition(&domainCR.Status.Conditions, "DomainDeleted", conditions.ConditionTrue, "DomainCleanupComplete", "domain and owned resources cleaned up successfully") + } + return nil +} + +// CanCreate reports whether the supplied object can be processed as a Domain create operation. +func (d *DomainDomain) CanCreate(obj client.Object) bool { + _, ok := obj.(*networksv1alpha1.Domain) + return ok +} + +// CanUpdate reports whether the supplied update should trigger Domain reconciliation +// by comparing the specifications of the old and new objects. +func (d *DomainDomain) CanUpdate(oldObj, newObj client.Object) bool { + + oldD, okOld := oldObj.(*networksv1alpha1.Domain) + newD, okNew := newObj.(*networksv1alpha1.Domain) + + // If either object is not a Domain, we cannot process the update + if !okOld || !okNew { + return false + } + + // Reconcile only on spec changes + return !reflect.DeepEqual(oldD.Spec, newD.Spec) +} + +// CanDelete reports whether the supplied object can be processed as a Domain delete operation. +func (d *DomainDomain) CanDelete(obj client.Object) bool { + _, ok := obj.(*networksv1alpha1.Domain) + return ok +} diff --git a/internal/domains/domain/domain_test.go b/internal/domains/domain/domain_test.go new file mode 100644 index 0000000..5034fe5 --- /dev/null +++ b/internal/domains/domain/domain_test.go @@ -0,0 +1,198 @@ +/* +Copyright 2026 The BlanketOps 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 domain + +import ( + "context" + "testing" + + environmentsv1alpha1 "github.com/blanketops/environments-api/api/environments/v1alpha1" + networksv1alpha1 "github.com/blanketops/environments-api/api/networks/v1alpha1" + corecache "github.com/blanketops/environments/core/cache" + "github.com/blanketops/environments/core/command" + "github.com/blanketops/environments/core/events" + domainapi "github.com/blanketops/environments/pkg/apis/domain/api" + domainapp "github.com/blanketops/environments/pkg/apis/domain/application" + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + knnetworkingv1alpha1 "knative.dev/networking/pkg/apis/networking/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/blanketops/environments-controller/internal/testsupport" +) + +// validDomainContract uses tlsStrategy: platform — the simplest path through +// KnativeProvider (ClusterDomainClaim only, no cert-manager Issuer/ +// Certificate/ACME involved) — for the happy-path create/delete tests. +const validDomainContract = `{"host":"api.dev.blanketops.online","routeRef":{"name":"my-route"},"tlsStrategy":"platform"}` + +func newDomainCR(t *testing.T, raw string) *networksv1alpha1.Domain { + t.Helper() + d := &networksv1alpha1.Domain{ + ObjectMeta: metav1.ObjectMeta{Name: "my-domain", Namespace: "default"}, + } + if raw != "" { + d.Spec.Contract = runtime.RawExtension{Raw: []byte(raw)} + } + return d +} + +func newTestDomainDomain(t *testing.T, objs ...client.Object) (*DomainDomain, client.Client) { + t.Helper() + c := testsupport.NewFakeClient(objs...) + log := logr.Discard() + + knative := domainapi.NewKnativeProvider(c, log, domainapi.ACMEConfig{}) + selector := domainapp.NewBackendSelector(knative) + mapper := domainapp.NewMapper() + statusWriter := domainapp.NewStatusWriter(c, log) + svc := domainapp.NewDomainService(mapper, statusWriter, selector) + + domainCache := &corecache.Cache{External: corecache.NoopExternalCache{}} + recorder := events.NewEventRecorder(nil) + + return New(svc, domainCache, recorder, log), c +} + +func TestDomainDomain_GVK(t *testing.T) { + d := &DomainDomain{} + gvk := d.GVK() + if gvk.Kind != "Domain" { + t.Errorf("GVK().Kind = %q, want %q", gvk.Kind, "Domain") + } +} + +func TestDomainDomain_CanCreate(t *testing.T) { + d := &DomainDomain{} + if !d.CanCreate(&networksv1alpha1.Domain{}) { + t.Error("CanCreate(*Domain) = false, want true") + } + if d.CanCreate(&environmentsv1alpha1.Build{}) { + t.Error("CanCreate(*Build) = true, want false") + } +} + +func TestDomainDomain_CanDelete(t *testing.T) { + d := &DomainDomain{} + if !d.CanDelete(&networksv1alpha1.Domain{}) { + t.Error("CanDelete(*Domain) = false, want true") + } + if d.CanDelete(&environmentsv1alpha1.Build{}) { + t.Error("CanDelete(*Build) = true, want false") + } +} + +func TestDomainDomain_CanUpdate(t *testing.T) { + tests := []struct { + name string + oldObj client.Object + newObj client.Object + want bool + }{ + { + name: "spec changed", + oldObj: newDomainCR(t, `{"host":"a.example.com","routeRef":{"name":"my-route"},"tlsStrategy":"platform"}`), + newObj: newDomainCR(t, `{"host":"b.example.com","routeRef":{"name":"my-route"},"tlsStrategy":"platform"}`), + want: true, + }, + { + name: "spec unchanged", + oldObj: newDomainCR(t, validDomainContract), + newObj: newDomainCR(t, validDomainContract), + want: false, + }, + { + name: "wrong type", + oldObj: &environmentsv1alpha1.Build{}, + newObj: newDomainCR(t, validDomainContract), + want: false, + }, + } + + d := &DomainDomain{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := d.CanUpdate(tt.oldObj, tt.newObj); got != tt.want { + t.Errorf("CanUpdate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDomainDomain_Handle_InvalidObject(t *testing.T) { + d := &DomainDomain{} + if err := d.Handle(context.Background(), command.Command{Obj: &environmentsv1alpha1.Build{}}); err == nil { + t.Fatal("Handle() with non-Domain object = nil error, want error") + } +} + +func TestDomainDomain_Handle_Create_ResolutionFailure(t *testing.T) { + domainCR := newDomainCR(t, "") + d, _ := newTestDomainDomain(t, domainCR) + + err := d.Handle(context.Background(), command.Command{Type: command.CmdCreate, Obj: domainCR}) + if err == nil { + t.Fatal("Handle() with empty contract = nil error, want error") + } +} + +func TestDomainDomain_Handle_Create_Succeeds(t *testing.T) { + domainCR := newDomainCR(t, validDomainContract) + d, c := newTestDomainDomain(t, domainCR) + + if err := d.Handle(context.Background(), command.Command{Type: command.CmdCreate, Obj: domainCR}); err != nil { + t.Fatalf("Handle() create = %v, want nil", err) + } + + var claims knnetworkingv1alpha1.ClusterDomainClaimList + if err := c.List(context.Background(), &claims); err != nil { + t.Fatalf("list clusterdomainclaims: %v", err) + } + if len(claims.Items) != 1 { + t.Fatalf("expected exactly one ClusterDomainClaim to have been applied, got %d", len(claims.Items)) + } +} + +func TestDomainDomain_Handle_Delete_RemovesWhatCreateApplied(t *testing.T) { + domainCR := newDomainCR(t, validDomainContract) + d, c := newTestDomainDomain(t, domainCR) + ctx := context.Background() + + if err := d.Handle(ctx, command.Command{Type: command.CmdCreate, Obj: domainCR}); err != nil { + t.Fatalf("Handle() create (setup) = %v, want nil", err) + } + + var before knnetworkingv1alpha1.ClusterDomainClaimList + if err := c.List(ctx, &before); err != nil { + t.Fatalf("list clusterdomainclaims (setup): %v", err) + } + if len(before.Items) != 1 { + t.Fatalf("expected the setup create to have applied one ClusterDomainClaim, got %d", len(before.Items)) + } + + if err := d.Handle(ctx, command.Command{Type: command.CmdDelete, Obj: domainCR}); err != nil { + t.Fatalf("Handle() delete = %v, want nil", err) + } + + var after knnetworkingv1alpha1.ClusterDomainClaimList + if err := c.List(ctx, &after); err != nil { + t.Fatalf("list clusterdomainclaims: %v", err) + } + if len(after.Items) != 0 { + t.Fatalf("expected Teardown to have deleted the ClusterDomainClaim, got %d remaining", len(after.Items)) + } +} diff --git a/internal/testsupport/testsupport.go b/internal/testsupport/testsupport.go index a2ee3fa..1f309ec 100644 --- a/internal/testsupport/testsupport.go +++ b/internal/testsupport/testsupport.go @@ -34,11 +34,14 @@ import ( sourcesv1alpha1 "github.com/blanketops/environments-api/api/sources/v1alpha1" "github.com/blanketops/environments/core/events" gitrepoapi "github.com/blanketops/environments/pkg/apis/gitrepository/api" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" fluxcdsourcev1 "github.com/fluxcd/source-controller/api/v1" shipwrightv1alpha1 "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" pipelinev1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + knnetworkingv1alpha1 "knative.dev/networking/pkg/apis/networking/v1alpha1" + knservingv1beta1 "knative.dev/serving/pkg/apis/serving/v1beta1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -98,6 +101,9 @@ func NewScheme() *runtime.Scheme { utilruntime.Must(fluxcdsourcev1.AddToScheme(scheme)) utilruntime.Must(kustomizev1.AddToScheme(scheme)) utilruntime.Must(networksv1alpha1.AddToScheme(scheme)) + utilruntime.Must(certmanagerv1.AddToScheme(scheme)) + utilruntime.Must(knservingv1beta1.AddToScheme(scheme)) + utilruntime.Must(knnetworkingv1alpha1.AddToScheme(scheme)) scheme.AddKnownTypeWithName(externalSecretGVK, &unstructured.Unstructured{}) scheme.AddKnownTypeWithName(externalSecretListGVK, &unstructured.UnstructuredList{}) From b1b921cde8986cfef18d50b1670d46084d1d0561 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 22 Aug 2026 14:06:07 +0200 Subject: [PATCH 2/2] chore: bump environments to v0.8.1 Unblocks DomainService.Teardown, which the Domain domain wiring in this PR depends on. Co-Authored-By: Claude Sonnet 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 06ccc38..6c46589 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.4 require ( carvel.dev/kapp-controller v0.60.4 github.com/argoproj/argo-events v1.9.11 - github.com/blanketops/environments v0.8.0 + github.com/blanketops/environments v0.8.1 github.com/blanketops/environments-api v0.2.7 github.com/cert-manager/cert-manager v1.21.1 github.com/fluxcd/kustomize-controller/api v1.9.4 diff --git a/go.sum b/go.sum index 7ad2726..8c9732b 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/argoproj/argo-events v1.9.11 h1:lDRu5E8ReFN1RJxGIibNOHuNEIPwzGleEkP3D github.com/argoproj/argo-events v1.9.11/go.mod h1:x5sq01KlghEBJlzDSN61KYnsap6THAugoI9Cpynq+DY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blanketops/environments v0.8.0 h1:zG73FF62//96G4epdqk6eWdLuBU8hzVUTPNYmMxv7bA= -github.com/blanketops/environments v0.8.0/go.mod h1:6jeVM0I9+j5hfWDPlKFq21KbyFtlgXfCks0dHPexnRk= +github.com/blanketops/environments v0.8.1 h1:/zw9Bhwdr4zBeA1RWpkRkYbHbrGRu+K32Mdc8Oaves0= +github.com/blanketops/environments v0.8.1/go.mod h1:6jeVM0I9+j5hfWDPlKFq21KbyFtlgXfCks0dHPexnRk= github.com/blanketops/environments-api v0.2.7 h1:oXWg2u35RX/niWIHq+wLR6/npWgFPukFeU5EyzVlui8= github.com/blanketops/environments-api v0.2.7/go.mod h1:xleSeb93JWxFyQv/3aWpY/HKPedXUnpKPVdFoK1dYQ8= github.com/blanketops/environments-contract v0.5.1 h1:PrALNSpG0ahtpvAHqNsY70+3J8axW9QsUrsurzin3x4=