diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go index d9865a7..74a403c 100644 --- a/api/v1alpha1/deco_types.go +++ b/api/v1alpha1/deco_types.go @@ -1,6 +1,9 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // DecoSpec defines the desired state of a Deco workload. type DecoSpec struct { @@ -82,6 +85,15 @@ type DecoSpecBuild struct { // +optional // +kubebuilder:validation:Minimum=0 TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + + // NodeSelector constrains the build Job pods to nodes matching all the specified labels. + // Use this to target a specific node pool (e.g. cloud.google.com/gke-nodepool: build-pool). + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // Tolerations are applied to the build Job pods, allowing them to be scheduled on tainted nodes. + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` } // DecoSpecBuildSource identifies the code revision to build. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a0d6a79..7fcf805 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -228,6 +229,20 @@ func (in *DecoSpecBuild) DeepCopyInto(out *DecoSpecBuild) { *out = new(int32) **out = **in } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecBuild. diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index 79c3477..4955b35 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -42,6 +42,13 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - get - apiGroups: - batch resources: diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index 3bb2657..e6e2265 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -77,6 +77,13 @@ spec: - name type: object type: array + nodeSelector: + additionalProperties: + type: string + description: |- + NodeSelector constrains the build Job pods to nodes matching all the specified labels. + Use this to target a specific node pool (e.g. cloud.google.com/gke-nodepool: build-pool). + type: object secrets: description: |- Secrets are K8s Secrets whose keys are mounted as environment variables in the build Job. @@ -118,6 +125,46 @@ spec: required: - commitSha type: object + tolerations: + description: Tolerations are applied to the build Job pods, allowing + them to be scheduled on tainted nodes. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array ttlSecondsAfterFinished: description: |- TTLSecondsAfterFinished controls how long the Job is kept after completion. diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 72eeadf..8a1ba4d 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.existingSecret .Values.s3.region .Values.s3.logsBucket }} + {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -84,18 +84,6 @@ spec: {{- end }} {{- end }} {{- with .Values.s3 }} - {{- if .existingSecret }} - - name: S3_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-access-key-id - - name: S3_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-secret-access-key - {{- end }} {{- if .region }} - name: S3_REGION value: {{ .region | quote }} @@ -104,6 +92,28 @@ spec: - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} + {{- if .stateBucket }} + - name: S3_STATE_BUCKET + value: {{ .stateBucket | quote }} + {{- end }} + {{- end }} + {{- with .Values.build }} + {{- if .serviceAccount }} + - name: BUILD_SERVICE_ACCOUNT + value: {{ .serviceAccount | quote }} + {{- end }} + {{- if .roleArn }} + - name: BUILD_ROLE_ARN + value: {{ .roleArn | quote }} + {{- end }} + {{- if .nodeSelector }} + - name: BUILD_NODE_SELECTOR + value: {{ .nodeSelector | toJson | quote }} + {{- end }} + {{- if .tolerations }} + - name: BUILD_TOLERATIONS + value: {{ .tolerations | toJson | quote }} + {{- end }} {{- end }} {{- end }} livenessProbe: diff --git a/chart/values.yaml b/chart/values.yaml index 54f5f05..4ae2820 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -101,11 +101,18 @@ cfworkers: builderImage: "" # Builder container image (repository:tag) artifactsBucket: "" # S3 bucket for build artifacts (cfworkers-specific) -# S3 credentials and config — shared across all build platforms +# S3 config — shared across all build platforms (credentials via Pod Identity) s3: - existingSecret: "" # Secret with s3-access-key-id, s3-secret-access-key region: "" # AWS region (e.g. us-east-1) logsBucket: "" # S3 bucket for build logs + stateBucket: "" # S3 bucket for site state + +# Build job config — shared across all build platforms +build: + serviceAccount: "" # K8s ServiceAccount for builder pods (IRSA) + roleArn: "" # IAM role ARN for IRSA annotation on the ServiceAccount + nodeSelector: {} # Default nodeSelector for build pods (overridable per-site via spec.build.nodeSelector) + tolerations: [] # Default tolerations for build pods (overridable per-site via spec.build.tolerations) # Name overrides nameOverride: "" diff --git a/cmd/main.go b/cmd/main.go index 5661e13..699153c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -332,10 +332,15 @@ func main() { registry := build.NewBuilderRegistry() registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + builderSAAnnotations := map[string]string{} + if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { + builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn + } if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Builder: registry, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: registry, + BuilderSAAnnotations: builderSAAnnotations, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index c84c3f4..d2b912e 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -78,6 +78,13 @@ spec: - name type: object type: array + nodeSelector: + additionalProperties: + type: string + description: |- + NodeSelector constrains the build Job pods to nodes matching all the specified labels. + Use this to target a specific node pool (e.g. cloud.google.com/gke-nodepool: build-pool). + type: object secrets: description: |- Secrets are K8s Secrets whose keys are mounted as environment variables in the build Job. @@ -119,6 +126,46 @@ spec: required: - commitSha type: object + tolerations: + description: Tolerations are applied to the build Job pods, allowing + them to be scheduled on tainted nodes. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array ttlSecondsAfterFinished: description: |- TTLSecondsAfterFinished controls how long the Job is kept after completion. diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2439d35..728eecd 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -43,6 +43,13 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - get - apiGroups: - batch resources: diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 7fa0078..a30b296 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,7 @@ func addEnvVarsToDeployment(templatesDir string) error { contentStr := string(content) // Find the image line and add env vars after it - envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.existingSecret .Values.s3.region .Values.s3.logsBucket }} + envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -241,18 +241,6 @@ func addEnvVarsToDeployment(templatesDir string) error { {{- end }} {{- end }} {{- with .Values.s3 }} - {{- if .existingSecret }} - - name: S3_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-access-key-id - - name: S3_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-secret-access-key - {{- end }} {{- if .region }} - name: S3_REGION value: {{ .region | quote }} @@ -261,11 +249,33 @@ func addEnvVarsToDeployment(templatesDir string) error { - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} + {{- if .stateBucket }} + - name: S3_STATE_BUCKET + value: {{ .stateBucket | quote }} + {{- end }} + {{- end }} + {{- with .Values.build }} + {{- if .serviceAccount }} + - name: BUILD_SERVICE_ACCOUNT + value: {{ .serviceAccount | quote }} + {{- end }} + {{- if .roleArn }} + - name: BUILD_ROLE_ARN + value: {{ .roleArn | quote }} + {{- end }} + {{- if .nodeSelector }} + - name: BUILD_NODE_SELECTOR + value: {{ .nodeSelector | toJson | quote }} + {{- end }} + {{- if .tolerations }} + - name: BUILD_TOLERATIONS + value: {{ .tolerations | toJson | quote }} + {{- end }} {{- end }} {{- end }}` - re := regexp.MustCompile(`(?m)( image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}")`) - contentStr = re.ReplaceAllString(contentStr, "$1\n"+envBlock) + imageLine := ` image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"` + contentStr = strings.Replace(contentStr, imageLine, imageLine+"\n"+envBlock, 1) return os.WriteFile(deploymentFile, []byte(contentStr), 0644) } diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index db89b94..217799d 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -4,6 +4,7 @@ package build import ( "context" "crypto/sha256" + "encoding/json" "fmt" "os" @@ -21,27 +22,35 @@ func JobName(commitSha, site string) string { return fmt.Sprintf("build-%x", h[:4]) } -// presignedURLs are the S3 presigned URLs the build job needs. -type presignedURLs struct { - LogsUpload string - CacheDownload string - CacheUpload string +// S3Config holds the bucket names and region for the build job. +// Credentials are provided via Pod Identity (no static keys needed). +type S3Config struct { + Region string + LogsBucket string + ArtifactsBucket string + StateBucket string } // cfWorkersJobOpts are the inputs for NewJob. type cfWorkersJobOpts struct { - Deco *decositesv1alpha1.Deco - JobName string - GithubToken string - CfApiToken string - CfAccountId string - presignedURLs presignedURLs + Deco *decositesv1alpha1.Deco + JobName string + GithubToken string + CfApiToken string + CfAccountId string + S3 S3Config // SourceOverride replaces spec.build.source when set (used for preview builds). SourceOverride *decositesv1alpha1.DecoSpecBuildSource // BuilderImage is the platform default. spec.build.builder in the CR takes precedence when set. BuilderImage string + // BuilderServiceAccount is the K8s ServiceAccount the job pod runs as (for Pod Identity). + BuilderServiceAccount string // TTLSeconds controls how long the Job is kept after completion. TTLSeconds int32 + // NodeSelector constrains build pods to nodes matching these labels. + NodeSelector map[string]string + // Tolerations applied to build pods. + Tolerations []corev1.Toleration } // newCfWorkersJob builds the batchv1.Job spec for a cfworkers build. @@ -76,9 +85,10 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { {Name: "IS_PRODUCTION", Value: isProduction}, {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, {Name: "CLOUDFLARE_API_TOKEN", Value: opts.CfApiToken}, - {Name: "LOGS_UPLOAD_URL", Value: opts.presignedURLs.LogsUpload}, - {Name: "CACHE_DOWNLOAD_URL", Value: opts.presignedURLs.CacheDownload}, - {Name: "CACHE_UPLOAD_URL", Value: opts.presignedURLs.CacheUpload}, + {Name: "S3_LOGS_BUCKET", Value: opts.S3.LogsBucket}, + {Name: "S3_ARTIFACTS_BUCKET", Value: opts.S3.ArtifactsBucket}, + {Name: "S3_STATE_BUCKET", Value: opts.S3.StateBucket}, + {Name: "S3_REGION", Value: opts.S3.Region}, } if src.BranchRef != "" { env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: src.BranchRef}) @@ -112,6 +122,16 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { ttl = *spec.Build.TTLSecondsAfterFinished } + nodeSelector := opts.NodeSelector + if spec.Build != nil && len(spec.Build.NodeSelector) > 0 { + nodeSelector = spec.Build.NodeSelector + } + + tolerations := opts.Tolerations + if spec.Build != nil && len(spec.Build.Tolerations) > 0 { + tolerations = spec.Build.Tolerations + } + return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: opts.JobName, @@ -127,7 +147,10 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { TTLSecondsAfterFinished: &ttl, Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: opts.BuilderServiceAccount, + NodeSelector: nodeSelector, + Tolerations: tolerations, Containers: []corev1.Container{ { Name: "builder", @@ -155,56 +178,92 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { // CfWorkersConfig holds all configuration the Cloudflare Workers builder needs. type CfWorkersConfig struct { - CfApiToken string - CfAccountId string - GithubToken string - BuilderImage string - TTLSeconds int32 - S3 S3Config + CfApiToken string + CfAccountId string + GithubToken string + BuilderImage string + BuilderServiceAccount string + TTLSeconds int32 + S3 S3Config + // NodeSelector is the default nodeSelector applied to all build pods. + // spec.build.nodeSelector in the CR overrides this per-site. + NodeSelector map[string]string + // Tolerations is the default list of tolerations applied to all build pods. + // spec.build.tolerations in the CR overrides this per-site. + Tolerations []corev1.Toleration } // CfWorkersConfigFromEnv reads CfWorkersConfig from standard environment variables. func CfWorkersConfigFromEnv() CfWorkersConfig { return CfWorkersConfig{ - CfApiToken: os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), - CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), - GithubToken: os.Getenv("GITHUB_TOKEN"), - BuilderImage: os.Getenv("CFWORKERS_BUILDER_IMAGE"), - TTLSeconds: 10 * 60, + CfApiToken: os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), + CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), + GithubToken: os.Getenv("GITHUB_TOKEN"), + BuilderImage: os.Getenv("CFWORKERS_BUILDER_IMAGE"), + BuilderServiceAccount: os.Getenv("BUILD_SERVICE_ACCOUNT"), + TTLSeconds: 10 * 60, + NodeSelector: parseNodeSelector(os.Getenv("BUILD_NODE_SELECTOR")), + Tolerations: parseTolerations(os.Getenv("BUILD_TOLERATIONS")), S3: S3Config{ Region: os.Getenv("S3_REGION"), - AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), - SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), LogsBucket: os.Getenv("S3_LOGS_BUCKET"), ArtifactsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), + StateBucket: os.Getenv("S3_STATE_BUCKET"), }, } } +// parseNodeSelector parses a JSON object string into a map. +// Returns nil on empty input or parse error. +func parseNodeSelector(s string) map[string]string { + if s == "" { + return nil + } + m := map[string]string{} + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil + } + if len(m) == 0 { + return nil + } + return m +} + +// parseTolerations unmarshals a JSON array of Toleration objects. +// Returns nil on empty input or parse error. +func parseTolerations(s string) []corev1.Toleration { + if s == "" { + return nil + } + var t []corev1.Toleration + if err := json.Unmarshal([]byte(s), &t); err != nil { + return nil + } + return t +} + type cfWorkersBuilder struct { - cfg CfWorkersConfig - presignFn func(ctx context.Context, cfg S3Config, site, jobName string) (presignedURLs, error) + cfg CfWorkersConfig } // NewCloudflareFactory returns a Builder for spec.serving.type = "cloudflare-worker". func NewCloudflareFactory(cfg CfWorkersConfig) Builder { - return &cfWorkersBuilder{cfg: cfg, presignFn: generatePresignedURLs} + return &cfWorkersBuilder{cfg: cfg} } -func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { - urls, err := b.presignFn(ctx, b.cfg.S3, deco.Spec.Site, jobName) - if err != nil { - return nil, fmt.Errorf("generating presigned URLs: %w", err) - } +func (b *cfWorkersBuilder) NewJob(_ context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { return newCfWorkersJob(cfWorkersJobOpts{ - Deco: deco, - JobName: jobName, - GithubToken: b.cfg.GithubToken, - CfApiToken: b.cfg.CfApiToken, - CfAccountId: b.cfg.CfAccountId, - presignedURLs: urls, - SourceOverride: &source, - BuilderImage: b.cfg.BuilderImage, - TTLSeconds: b.cfg.TTLSeconds, + Deco: deco, + JobName: jobName, + GithubToken: b.cfg.GithubToken, + CfApiToken: b.cfg.CfApiToken, + CfAccountId: b.cfg.CfAccountId, + S3: b.cfg.S3, + SourceOverride: &source, + BuilderImage: b.cfg.BuilderImage, + BuilderServiceAccount: b.cfg.BuilderServiceAccount, + TTLSeconds: b.cfg.TTLSeconds, + NodeSelector: b.cfg.NodeSelector, + Tolerations: b.cfg.Tolerations, }), nil } diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go deleted file mode 100644 index 4d83c8d..0000000 --- a/internal/build/s3presign.go +++ /dev/null @@ -1,71 +0,0 @@ -package build - -import ( - "context" - "fmt" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -const presignExpiry = time.Hour - -// S3Config holds the AWS credentials and bucket names used to generate presigned URLs. -type S3Config struct { - Region string - AccessKeyID string - SecretAccessKey string - LogsBucket string // bucket for build logs - ArtifactsBucket string // bucket for build artifacts -} - -// GeneratepresignedURLs generates all presigned URLs the build job needs. -// Mirrors generatePresignedUrls() in the admin's build.ts. -func generatePresignedURLs(ctx context.Context, cfg S3Config, site, jobName string) (presignedURLs, error) { - awsCfg, err := config.LoadDefaultConfig(ctx, - config.WithRegion(cfg.Region), - config.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), - ), - ) - if err != nil { - return presignedURLs{}, fmt.Errorf("loading aws config: %w", err) - } - - presigner := s3.NewPresignClient(s3.NewFromConfig(awsCfg)) - - logsUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(cfg.LogsBucket), - Key: aws.String(fmt.Sprintf("%s/%s.log", site, jobName)), - }, s3.WithPresignExpires(presignExpiry)) - if err != nil { - return presignedURLs{}, fmt.Errorf("presigning logs upload: %w", err) - } - - cacheKey := fmt.Sprintf("%s/npm-cache.tar.zst", site) - - cacheDownload, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(cfg.ArtifactsBucket), - Key: aws.String(cacheKey), - }, s3.WithPresignExpires(presignExpiry)) - if err != nil { - return presignedURLs{}, fmt.Errorf("presigning cache download: %w", err) - } - - cacheUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(cfg.ArtifactsBucket), - Key: aws.String(cacheKey), - }, s3.WithPresignExpires(presignExpiry)) - if err != nil { - return presignedURLs{}, fmt.Errorf("presigning cache upload: %w", err) - } - - return presignedURLs{ - LogsUpload: logsUpload.URL, - CacheDownload: cacheDownload.URL, - CacheUpload: cacheUpload.URL, - }, nil -} diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 9c89ebf..172d4b5 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -30,14 +30,16 @@ const ( // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client - Scheme *runtime.Scheme - Builder build.Builder + Scheme *runtime.Scheme + Builder build.Builder + BuilderSAAnnotations map[string]string } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=deco.sites,resources=decos/status,verbs=get;update;patch // +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;create func (r *DecoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) @@ -249,6 +251,12 @@ func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1. return fmt.Errorf("building job spec: %w", err) } + if sa := job.Spec.Template.Spec.ServiceAccountName; sa != "" { + if err := r.ensureServiceAccount(ctx, deco.Namespace, sa, r.BuilderSAAnnotations); err != nil { + return fmt.Errorf("ensuring service account %q: %w", sa, err) + } + } + if err := controllerutil.SetControllerReference(deco, job, r.Scheme); err != nil { return fmt.Errorf("setting owner reference: %w", err) } @@ -259,6 +267,25 @@ func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1. return nil } +// ensureServiceAccount creates or updates the ServiceAccount merging the provided annotations. +func (r *DecoReconciler) ensureServiceAccount(ctx context.Context, namespace, name string, annotations map[string]string) error { + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, sa, func() error { + if len(annotations) > 0 { + if sa.Annotations == nil { + sa.Annotations = map[string]string{} + } + for k, v := range annotations { + sa.Annotations[k] = v + } + } + return nil + }) + return err +} + func buildPhaseFromJob(job *batchv1.Job) string { for _, c := range job.Status.Conditions { if c.Status != corev1.ConditionTrue {