Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions chart/templates/clusterrole-operator-manager-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
195 changes: 195 additions & 0 deletions internal/build/knative.go
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 9 in internal/build/knative.go

View workflow job for this annotation

GitHub Actions / Lint Code

File is not properly formatted (gofmt)
// 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
}
34 changes: 28 additions & 6 deletions internal/build/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
38 changes: 30 additions & 8 deletions internal/build/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand Down
Loading
Loading