diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index c01f3ba..942e1ed 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -124,8 +124,18 @@ rules: - serving.knative.dev resources: - revisions + verbs: + - get + - list + - watch +- apiGroups: + - serving.knative.dev + resources: - services verbs: + - create - get - list + - patch + - update - watch \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 5fa10db..6237358 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -381,7 +381,12 @@ func main() { if enabled(controller.DecoControllerName) { registry := build.NewBuilderRegistry() - registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + // cloudflare-worker: framework-agnostic (hosting = CF Workers). + registry.Register("cloudflare-worker", "", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + // knative + tanstack: Node build → dist tar in S3, served by the node-runner. + // (knative is the hosting framework; tanstack is the stack framework — a + // future knative+deno builder registers under ("knative","deno").) + registry.Register("knative", "tanstack", build.NewKnativeFactory(build.KnativeConfigFromEnv())) builderSAAnnotations := map[string]string{} if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn @@ -391,6 +396,7 @@ func main() { Scheme: mgr.GetScheme(), Builder: registry, BuilderSAAnnotations: builderSAAnnotations, + KnativeServing: controller.KnativeServingConfigFromEnv(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9c8c108..fc92e60 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -125,8 +125,18 @@ rules: - serving.knative.dev resources: - revisions + verbs: + - get + - list + - watch +- apiGroups: + - serving.knative.dev + resources: - services verbs: + - create - get - list + - patch + - update - watch diff --git a/internal/build/knative.go b/internal/build/knative.go new file mode 100644 index 0000000..4bc45b1 --- /dev/null +++ b/internal/build/knative.go @@ -0,0 +1,195 @@ +// Package build — Knative (Node/TanStack) builder. +// +// Sibling of the Cloudflare Workers builder (cfworkers.go). Instead of building +// a Worker and deploying it with wrangler, this builder produces a Node build +// of a TanStack site and uploads a single self-contained dist tar to S3. A +// generic node-runner Knative Service then pulls that tar at boot and runs it. +// +// The build image is expected to: +// 1. clone {org}/{site} @ commitSha +// 2. npm ci +// 3. vite build --config vite.config.node.ts (DECO_TARGET=node; the config +// is generic and strips the Cloudflare plugin — see +// infra_applications/images/node-runner) +// 4. tar dist/ (zstd) and upload to s3://{ARTIFACTS_BUCKET}/{ARTIFACT_KEY} +// +// The site repo is NOT modified — the node build config is supplied by the +// builder image, so this scales across the whole *-tanstack fleet. +package build + +import ( + "context" + "fmt" + "os" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/envparse" +) + +// ArtifactKey is the deterministic S3 key of a site's Node build tar. +// The Knative Service passes this same key to the node-runner as SOURCE_ASSET_PATH. +func ArtifactKey(org, site, commitSha string) string { + return fmt.Sprintf("%s/%s/%s/dist.tar.zst", org, site, commitSha) +} + +// KnativeConfig holds configuration the Knative (Node) builder needs. +// Credentials are provided via Pod Identity (no static keys needed). +type KnativeConfig struct { + GithubToken string + BuilderImage string + BuilderServiceAccount string + TTLSeconds int32 + S3 S3Config + NodeSelector map[string]string + Tolerations []corev1.Toleration +} + +// KnativeConfigFromEnv reads KnativeConfig from standard environment variables. +func KnativeConfigFromEnv() KnativeConfig { + return KnativeConfig{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + BuilderImage: os.Getenv("KNATIVE_BUILDER_IMAGE"), + BuilderServiceAccount: os.Getenv("BUILD_SERVICE_ACCOUNT"), + TTLSeconds: 10 * 60, + NodeSelector: envparse.NodeSelector(os.Getenv("BUILD_NODE_SELECTOR")), + Tolerations: envparse.Tolerations(os.Getenv("BUILD_TOLERATIONS")), + S3: S3Config{ + Region: os.Getenv("S3_REGION"), + LogsBucket: os.Getenv("S3_LOGS_BUCKET"), + ArtifactsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), + StateBucket: os.Getenv("S3_STATE_BUCKET"), + }, + } +} + +type knativeBuilder struct { + cfg KnativeConfig +} + +// NewKnativeFactory returns a Builder for spec.serving.type = "knative". +func NewKnativeFactory(cfg KnativeConfig) Builder { + return &knativeBuilder{cfg: cfg} +} + +func (b *knativeBuilder) NewJob(_ context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + spec := deco.Spec + owner := spec.Org + repo := spec.Site + + isProduction := "false" + if source.Production { + isProduction = "true" + } + + // CR overrides the platform default builder image. + builderImage := b.cfg.BuilderImage + if spec.Build != nil && spec.Build.Builder != "" { + builderImage = spec.Build.Builder + } + + artifactKey := ArtifactKey(owner, repo, source.CommitSha) + + env := []corev1.EnvVar{ + {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", owner, repo)}, + {Name: "COMMIT_SHA", Value: source.CommitSha}, + {Name: "DECO_SITE_NAME", Value: repo}, + {Name: "BUILD_NAME", Value: jobName}, + {Name: "IS_PRODUCTION", Value: isProduction}, + // DECO_TARGET selects the Node build path in the builder image. + {Name: "DECO_TARGET", Value: "node"}, + // Where the dist tar is uploaded; the Knative Service reads the same key. + {Name: "ARTIFACT_KEY", Value: artifactKey}, + {Name: "S3_LOGS_BUCKET", Value: b.cfg.S3.LogsBucket}, + {Name: "S3_ARTIFACTS_BUCKET", Value: b.cfg.S3.ArtifactsBucket}, + {Name: "S3_REGION", Value: b.cfg.S3.Region}, + } + if source.BranchRef != "" { + env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: source.BranchRef}) + } + if b.cfg.GithubToken != "" { + env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: b.cfg.GithubToken}) + } + if spec.Build != nil { + for _, e := range spec.Build.Envs { + env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value}) + } + } + + var envFrom []corev1.EnvFromSource + if spec.Build != nil { + secrets := spec.Build.Secrets + envFrom = make([]corev1.EnvFromSource, len(secrets)) + for i, s := range secrets { + envFrom[i] = corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: s.Name}, + Optional: s.Optional, + }, + } + } + } + + backoffLimit := int32(0) + ttl := b.cfg.TTLSeconds + if spec.Build != nil && spec.Build.TTLSecondsAfterFinished != nil { + ttl = *spec.Build.TTLSecondsAfterFinished + } + + nodeSelector := b.cfg.NodeSelector + if spec.Build != nil && len(spec.Build.NodeSelector) > 0 { + nodeSelector = spec.Build.NodeSelector + } + + tolerations := b.cfg.Tolerations + if spec.Build != nil && len(spec.Build.Tolerations) > 0 { + tolerations = spec.Build.Tolerations + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: deco.Namespace, + Labels: map[string]string{ + "app.deco/site": repo, + "app.deco/org": owner, + "app.deco/serving": spec.Serving.Type, + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + TTLSecondsAfterFinished: &ttl, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: b.cfg.BuilderServiceAccount, + NodeSelector: nodeSelector, + Tolerations: tolerations, + Containers: []corev1.Container{ + { + Name: "builder", + Image: builderImage, + Env: env, + EnvFrom: envFrom, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceEphemeralStorage: resource.MustParse("2Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("4Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("3Gi"), + }, + }, + }, + }, + }, + }, + }, + }, nil +} diff --git a/internal/build/registry.go b/internal/build/registry.go index d3f19f4..b7b2cdc 100644 --- a/internal/build/registry.go +++ b/internal/build/registry.go @@ -17,8 +17,20 @@ type Builder interface { NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) } -// BuilderRegistry dispatches to the correct Builder by spec.serving.type. -// BuilderRegistry itself satisfies Builder. +// builderKey composes the two orthogonal dimensions that select a builder: +// the hosting framework (spec.serving.type, e.g. knative) and the stack +// framework (spec.framework, e.g. tanstack). The same hosting framework runs +// different stacks (knative+tanstack vs knative+deno), so dispatch keys on both. +// An empty framework registers a hosting-framework-agnostic builder (fallback). +func builderKey(servingType, framework string) string { + if framework == "" { + return servingType + } + return servingType + "/" + framework +} + +// BuilderRegistry dispatches to the correct Builder by (spec.serving.type, +// spec.framework). BuilderRegistry itself satisfies Builder. type BuilderRegistry struct { platforms map[string]Builder } @@ -27,14 +39,24 @@ func NewBuilderRegistry() *BuilderRegistry { return &BuilderRegistry{platforms: map[string]Builder{}} } -func (r *BuilderRegistry) Register(servingType string, b Builder) { - r.platforms[servingType] = b +// Register a builder for a (servingType, framework) pair. Pass framework="" to +// register a hosting-framework-agnostic builder (used as fallback when no +// stack-specific builder matches). +func (r *BuilderRegistry) Register(servingType, framework string, b Builder) { + r.platforms[builderKey(servingType, framework)] = b } func (r *BuilderRegistry) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { - b, ok := r.platforms[deco.Spec.Serving.Type] + st := deco.Spec.Serving.Type + fw := deco.Spec.Framework + // Prefer the stack-specific builder (e.g. knative/tanstack); fall back to + // the framework-agnostic one (e.g. cloudflare-worker). + b, ok := r.platforms[builderKey(st, fw)] + if !ok { + b, ok = r.platforms[st] + } if !ok { - return nil, fmt.Errorf("%w %q", errNoFactory, deco.Spec.Serving.Type) + return nil, fmt.Errorf("%w %q (framework %q)", errNoFactory, st, fw) } return b.NewJob(ctx, deco, jobName, source) } diff --git a/internal/build/registry_test.go b/internal/build/registry_test.go index 4268a59..9ff820e 100644 --- a/internal/build/registry_test.go +++ b/internal/build/registry_test.go @@ -20,23 +20,25 @@ func (s *stubBuilder) NewJob(_ context.Context, _ *decositesv1alpha1.Deco, _ str return s.job, nil } -func testDeco(servingType string) *decositesv1alpha1.Deco { +func testDeco(servingType, framework string) *decositesv1alpha1.Deco { return &decositesv1alpha1.Deco{ ObjectMeta: metav1.ObjectMeta{Name: "site", Namespace: "default"}, Spec: decositesv1alpha1.DecoSpec{ - Site: "site", - Org: "org", - Serving: &decositesv1alpha1.DecoSpecServing{Type: servingType}, + Site: "site", + Org: "org", + Framework: framework, + Serving: &decositesv1alpha1.DecoSpecServing{Type: servingType}, }, } } -func TestRegistry_DispatchesToRegisteredBuilder(t *testing.T) { +func TestRegistry_DispatchesToAgnosticBuilder(t *testing.T) { want := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "build-abc"}} r := NewBuilderRegistry() - r.Register("cloudflare-worker", &stubBuilder{job: want}) + r.Register("cloudflare-worker", "", &stubBuilder{job: want}) - got, err := r.NewJob(context.Background(), testDeco("cloudflare-worker"), "build-abc", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc"}) + // framework-agnostic: matches regardless of spec.framework + got, err := r.NewJob(context.Background(), testDeco("cloudflare-worker", "tanstack"), "build-abc", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -45,9 +47,29 @@ func TestRegistry_DispatchesToRegisteredBuilder(t *testing.T) { } } +func TestRegistry_DispatchesToStackSpecificBuilder(t *testing.T) { + tanstack := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "knative-tanstack"}} + r := NewBuilderRegistry() + r.Register("knative", "tanstack", &stubBuilder{job: tanstack}) + + // knative + tanstack → the stack-specific builder + got, err := r.NewJob(context.Background(), testDeco("knative", "tanstack"), "job", decositesv1alpha1.DecoSpecBuildSource{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tanstack { + t.Errorf("expected knative/tanstack builder, got %p", got) + } + + // knative + deno → no builder registered for that stack → error + if _, err := r.NewJob(context.Background(), testDeco("knative", "deno"), "job", decositesv1alpha1.DecoSpecBuildSource{}); err == nil { + t.Fatal("expected error for knative+deno (no builder registered)") + } +} + func TestRegistry_ErrorsOnUnknownServingType(t *testing.T) { r := NewBuilderRegistry() - _, err := r.NewJob(context.Background(), testDeco("unknown"), "job", decositesv1alpha1.DecoSpecBuildSource{}) + _, err := r.NewJob(context.Background(), testDeco("unknown", ""), "job", decositesv1alpha1.DecoSpecBuildSource{}) if err == nil { t.Fatal("expected error for unregistered serving type") } diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 8bcb983..01c5832 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -34,6 +34,9 @@ type DecoReconciler struct { Scheme *runtime.Scheme Builder build.Builder BuilderSAAnnotations map[string]string + // KnativeServing configures the generic node-runner Knative Service created + // for spec.serving.type=knative sites. Zero value disables Service creation. + KnativeServing KnativeServingConfig } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete @@ -41,6 +44,7 @@ type DecoReconciler struct { // +kubebuilder:rbac:groups=deco.sites,resources=decos/finalizers,verbs=update // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update +// +kubebuilder:rbac:groups=serving.knative.dev,resources=services,verbs=get;list;watch;create;update;patch func (r *DecoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) @@ -86,7 +90,9 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. commitSha := deco.Spec.Build.Source.CommitSha if deco.Status.Build != nil && deco.Status.Build.LastBuiltCommit == commitSha { - return false, nil + // Already built — ensure the Knative Service exists (self-healing, no-op + // for non-knative serving types). + return false, r.ensureKnativeService(ctx, deco) } jobName := build.JobName(commitSha, deco.Spec.Site) @@ -138,6 +144,10 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. } if phase == phaseSucceeded { patch.Status.Build.LastBuiltCommit = commitSha + // Build done → create/update the Knative Service (no-op for non-knative). + if err := r.ensureKnativeService(ctx, deco); err != nil { + return false, err + } } return true, nil } diff --git a/internal/controller/knative_service.go b/internal/controller/knative_service.go new file mode 100644 index 0000000..000ac36 --- /dev/null +++ b/internal/controller/knative_service.go @@ -0,0 +1,201 @@ +package controller + +import ( + "context" + "fmt" + "os" + "strconv" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/build" +) + +// KnativeServingConfig is the platform configuration for the generic node-runner +// Knative Service. Everything site-specific comes from the Deco CR (self-contained +// CR invariant); this is only the platform-level defaults. +type KnativeServingConfig struct { + // RunnerImages maps a stack framework (spec.framework, e.g. "tanstack") to + // its generic runner image. knative is the hosting framework; the stack + // framework picks the runtime (tanstack → node-runner; deno → the deno runner). + RunnerImages map[string]string + AssetsBucket string // S3 bucket holding the dist artifact + S3Region string + ServiceAccount string // runner SA name, bound (IRSA) to a read-only S3 role + // SAAnnotations is applied to the runner ServiceAccount (e.g. the IRSA + // eks.amazonaws.com/role-arn). The operator ensures the SA exists + annotated + // in the site namespace so aws-cli in the runner picks up the web-identity token. + SAAnnotations map[string]string + EnvName string // DECO_ENV_NAME (default "production") + MinScale int // 0 = scale-to-zero standby + MaxScale int + AppPort int32 // container port (default 8000) +} + +const knativeAppPort int32 = 8000 + +// roleARNAnnotation builds the IRSA annotation map for a runner ServiceAccount. +func roleARNAnnotation(roleARN string) map[string]string { + if roleARN == "" { + return nil + } + return map[string]string{"eks.amazonaws.com/role-arn": roleARN} +} + +// KnativeServingConfigFromEnv reads the platform Knative serving config from env. +func KnativeServingConfigFromEnv() KnativeServingConfig { + atoiOr := func(s string, d int) int { + if v, err := strconv.Atoi(s); err == nil { + return v + } + return d + } + return KnativeServingConfig{ + RunnerImages: map[string]string{ + // tanstack → the Node runner (node-runner image). + "tanstack": os.Getenv("NODE_RUNNER_IMAGE"), + }, + AssetsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), + S3Region: os.Getenv("S3_REGION"), + ServiceAccount: os.Getenv("RUNNER_SERVICE_ACCOUNT"), + SAAnnotations: roleARNAnnotation(os.Getenv("RUNNER_ROLE_ARN")), + EnvName: os.Getenv("DECO_ENV_NAME"), + MinScale: atoiOr(os.Getenv("NODE_RUNNER_MIN_SCALE"), 0), + MaxScale: atoiOr(os.Getenv("NODE_RUNNER_MAX_SCALE"), 5), + AppPort: int32(atoiOr(os.Getenv("NODE_RUNNER_PORT"), int(knativeAppPort))), + } +} + +// serviceName / revisionName mirror the admin's naming so the two creators are +// interchangeable during the admin→operator migration. +func serviceName(site string) string { return site + "-site" } + +func revisionName(site, commitSha string) string { + short := commitSha + if len(short) > 8 { + short = short[:8] + } + return fmt.Sprintf("%s-site-%s", site, short) +} + +// BuildKnativeService renders the Knative Service for a TanStack node-runner site. +// The site's code arrives via the S3 tar (SOURCE_ASSET_PATH) at boot — the image +// is generic and shared across the fleet. +func BuildKnativeService(deco *decositesv1alpha1.Deco, cfg KnativeServingConfig) *servingv1.Service { + site := deco.Spec.Site + org := deco.Spec.Org + commitSha := deco.Spec.Build.Source.CommitSha + + port := cfg.AppPort + if port == 0 { + port = knativeAppPort + } + envName := cfg.EnvName + if envName == "" { + envName = "production" + } + + env := []corev1.EnvVar{ + {Name: "SOURCE_ASSET_PATH", Value: build.ArtifactKey(org, site, commitSha)}, + {Name: "DECO_SITE_NAME", Value: site}, + {Name: "DECO_ENV_NAME", Value: envName}, + {Name: "PORT", Value: strconv.Itoa(int(port))}, + {Name: "ASSETS_BUCKET", Value: cfg.AssetsBucket}, + {Name: "AWS_REGION", Value: cfg.S3Region}, + {Name: "BUILD_HASH", Value: commitSha}, + } + // Site env from the CR (self-contained CR: config lives in the Deco, not + // injected by admin's runtime env). + if deco.Spec.Build != nil { + for _, e := range deco.Spec.Build.Envs { + env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value}) + } + } + + // Runner image is selected by the stack framework (knative hosts many stacks). + runnerImage := cfg.RunnerImages[deco.Spec.Framework] + + labels := map[string]string{ + "app.deco/site": site, + "app.deco/org": org, + "app.deco/serving": "knative", + "app.deco/framework": deco.Spec.Framework, + } + + return &servingv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName(site), + Namespace: deco.Namespace, + Labels: labels, + }, + Spec: servingv1.ServiceSpec{ + ConfigurationSpec: servingv1.ConfigurationSpec{ + Template: servingv1.RevisionTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: revisionName(site, commitSha), + Labels: labels, + Annotations: map[string]string{ + "autoscaling.knative.dev/min-scale": strconv.Itoa(cfg.MinScale), + "autoscaling.knative.dev/max-scale": strconv.Itoa(cfg.MaxScale), + }, + }, + Spec: servingv1.RevisionSpec{ + PodSpec: corev1.PodSpec{ + ServiceAccountName: cfg.ServiceAccount, + Containers: []corev1.Container{ + { + Name: "app", + Image: runnerImage, + Ports: []corev1.ContainerPort{{Name: "http1", ContainerPort: port}}, + Env: env, + }, + }, + }, + }, + }, + }, + }, + } +} + +// ensureKnativeService creates or updates the Knative Service for a knative-served +// Deco. Idempotent: safe to call on every successful reconcile. The Deco owns the +// Service (cascade delete). Only the mutable template is updated so unrelated +// Knative-managed fields are preserved. +func (r *DecoReconciler) ensureKnativeService(ctx context.Context, deco *decositesv1alpha1.Deco) error { + if deco.Spec.Serving == nil || deco.Spec.Serving.Type != "knative" { + return nil + } + if deco.Spec.Build == nil || deco.Spec.Build.Source.CommitSha == "" { + return nil + } + if r.KnativeServing.RunnerImages[deco.Spec.Framework] == "" { + return fmt.Errorf("knative serving: no runner image configured for framework %q", deco.Spec.Framework) + } + + // Ensure the runner ServiceAccount exists + is IRSA-annotated in the site + // namespace, so aws-cli in the runner picks up the read-only S3 web-identity. + if r.KnativeServing.ServiceAccount != "" { + if err := ensureServiceAccount(ctx, r.Client, deco.Namespace, r.KnativeServing.ServiceAccount, r.KnativeServing.SAAnnotations); err != nil { + return fmt.Errorf("knative serving: ensure runner service account: %w", err) + } + } + + desired := BuildKnativeService(deco, r.KnativeServing) + svc := &servingv1.Service{ObjectMeta: metav1.ObjectMeta{Name: desired.Name, Namespace: desired.Namespace}} + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error { + svc.Labels = desired.Labels + svc.Spec = desired.Spec + return controllerutil.SetControllerReference(deco, svc, r.Scheme) + }) + if err != nil && !errors.IsAlreadyExists(err) { + return fmt.Errorf("knative serving: upsert service: %w", err) + } + return nil +}