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..6c46589 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,9 @@ 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 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..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= @@ -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{})