Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- `gen circleci --image-native-arm`: builds the image with one job per architecture on native hardware
instead of one job that emulates the non-host architecture under QEMU. Emits an `architect/build-image-arch`
leg for `linux/amd64` and `linux/arm64` on the branch and tag paths, plus an `architect/merge-image-manifests`
job on the tag path. Off by default. Mutually exclusive with `--image-platforms`. Renames the branch job from
`build-image` to `build-image-amd64`/`build-image-arm64`, so a repo whose `.circleci/custom.yml` has
`requires: build-image` must update it in the same PR.
- `release create`: records the containerd version as a `containerd` component and links it in the release
notes. It is derived from the release's `os-tooling` version, since that is the version nodes run rather
than the one Flatcar embeds.
Expand Down
9 changes: 9 additions & 0 deletions cmd/gen/circleci/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
flagImagePreBuildJob = "image-pre-build-job"
flagImagePrivateOnly = "image-private-only"
flagImageName = "image-name"
flagImageNativeArm = "image-native-arm"
flagImagePlatforms = "image-platforms"
flagImageDockerfile = "image-dockerfile"
flagResourceClass = "resource-class"
Expand All @@ -44,6 +45,7 @@ type flag struct {
ImagePreBuildJob string
ImagePrivateOnly bool
ImageName string
ImageNativeArm bool
ImagePlatforms string
ImageDockerfile string
ResourceClass string
Expand All @@ -68,6 +70,7 @@ func (f *flag) Init(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.ImagePreBuildJob, flagImagePreBuildJob, "", "Name of a repo-owned job (defined in .circleci/custom.yml) the release image build must wait on. Adds a `requires` entry to push-to-registries-release, which the append-only custom.yml merge cannot inject into a generated job. Used for workspace-handoff pre-steps. Empty for the common case.")
cmd.Flags().BoolVar(&f.ImagePrivateOnly, flagImagePrivateOnly, false, "Ship the image to the private registry only (gsociprivate), replacing split-china-push and omitting the sync-china-registry job. Set it for private repos whose image must not land in the public catalog.")
cmd.Flags().StringVar(&f.ImageName, flagImageName, "", "Override the `giantswarm/<repo>` default image name on the image jobs (push-to-registries / sync-china-registry `image` param). Set it for repos whose published image differs from the repo name (e.g. kserve -> giantswarm/kserve-controller). The append-only custom.yml merge cannot rename a generated job's image. Empty keeps the orb default.")
cmd.Flags().BoolVar(&f.ImageNativeArm, flagImageNativeArm, false, "Build the image with one job per architecture on native hardware, instead of one job that emulates the non-host architecture under QEMU. Emits an architect/build-image-arch job for linux/amd64 and linux/arm64 plus an architect/merge-image-manifests job that assembles the index and signs it. Worth it when the arm64 leg's RUN steps are the critical path -- an apt-heavy image can spend 7-15x longer emulated -- and not worth it when the Dockerfile already avoids emulation (a prebuilt binary plus COPY, or a $BUILDPLATFORM cross-compile), where the split only adds job spin-up. Mutually exclusive with --image-platforms, since the split is fixed to those two architectures. NOTE: this renames the branch job from build-image to build-image-amd64/build-image-arm64, so a repo whose .circleci/custom.yml has `requires: build-image` must update it in the same PR or its pipeline will not resolve.")
cmd.Flags().StringVar(&f.ImagePlatforms, flagImagePlatforms, "", "Override the buildx platform list on the image jobs (push-to-registries `platforms` param). Empty lets the orb default apply (linux/amd64,linux/arm64 when no go-build .platforms file). Set it for single-architecture images (e.g. vllm -> linux/arm64, whose amd64 build has no prebuilt wheels).")
cmd.Flags().StringVar(&f.ImageDockerfile, flagImageDockerfile, "", "Override the Dockerfile path on the image jobs (push-to-registries `dockerfile` param). Set it for repos whose Dockerfile is not at the repo root (e.g. backstage -> packages/backend/Dockerfile); a non-empty value also turns the image pipeline on, since the root-Dockerfile derivation misses a nested Dockerfile. The append-only custom.yml merge cannot set this on a generated job. Empty keeps the orb default.")
cmd.Flags().StringVar(&f.ResourceClass, flagResourceClass, "", `Override the CircleCI resource_class on the cli-flavour go-build job. Empty defaults to "large". Raise it (e.g. "xlarge") for repos that need more RAM/CPU headroom for the cold cross-compile. Only applies to the cli flavour.`)
Expand All @@ -89,6 +92,12 @@ func (f *flag) Validate() error {
if f.ForcePublic && f.ImagePrivateOnly {
return microerror.Maskf(invalidFlagError, "--%s and --%s are mutually exclusive", flagForcePublic, flagImagePrivateOnly)
}
// The split emits exactly one leg per architecture, hardcoded to amd64 and
// arm64, so an overridden platform list has nowhere to go. Failing here is
// better than silently ignoring one of the two flags.
if f.ImageNativeArm && f.ImagePlatforms != "" {
return microerror.Maskf(invalidFlagError, "--%s and --%s are mutually exclusive", flagImageNativeArm, flagImagePlatforms)
}

return nil
}
1 change: 1 addition & 0 deletions cmd/gen/circleci/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func (r *runner) run(ctx context.Context, _ *cobra.Command, _ []string) error {
ImagePreBuildJob: r.flag.ImagePreBuildJob,
ImagePrivateOnly: r.flag.ImagePrivateOnly,
ImageName: r.flag.ImageName,
ImageNativeArm: r.flag.ImageNativeArm,
ImagePlatforms: r.flag.ImagePlatforms,
ImageDockerfile: r.flag.ImageDockerfile,
ResourceClass: r.flag.ResourceClass,
Expand Down
16 changes: 16 additions & 0 deletions pkg/gen/input/circleci/circleci.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ type Config struct {
// name (e.g. kserve -> giantswarm/kserve-controller). Empty keeps the orb
// default.
ImageName string
// ImageNativeArm splits the image build into one job per architecture, each
// on a resource class of that architecture, joined by a manifest-merge job.
// The default single job builds every platform on one machine, so the
// non-host architecture runs its RUN steps under QEMU -- 7-15x slower for an
// apt-heavy image, and the whole critical path. Not worth setting when the
// Dockerfile already avoids emulation (prebuilt binary plus COPY, or a
// $BUILDPLATFORM cross-compile): the emulated leg costs seconds there and the
// split only adds job spin-up. Mutually exclusive with ImagePlatforms.
ImageNativeArm bool
// ImagePlatforms overrides the buildx platform list on the image jobs.
// Empty lets the orb default apply. Set it for single-architecture images
// (e.g. vllm -> linux/arm64).
Expand Down Expand Up @@ -336,6 +345,12 @@ func New(config Config) (*CircleCI, error) {
return nil, microerror.Maskf(invalidConfigError, "ForcePublic and ImagePrivateOnly are mutually exclusive")
}

// The split emits one leg per architecture, hardcoded to amd64 and arm64, so
// an overridden platform list has nowhere to go.
if config.ImageNativeArm && config.ImagePlatforms != "" {
return nil, microerror.Maskf(invalidConfigError, "ImageNativeArm and ImagePlatforms are mutually exclusive")
}

appCatalog := config.AppCatalog
if appCatalog == "" {
appCatalog = DefaultAppCatalog
Expand Down Expand Up @@ -477,6 +492,7 @@ func New(config Config) (*CircleCI, error) {
ImagePreBuildJob: config.ImagePreBuildJob,
ImagePrivateOnly: config.ImagePrivateOnly,
ImageName: config.ImageName,
ImageNativeArm: config.ImageNativeArm,
ImagePlatforms: config.ImagePlatforms,
ImageDockerfile: config.ImageDockerfile,
ReleaseBinaries: config.shipsBinaries(),
Expand Down
84 changes: 84 additions & 0 deletions pkg/gen/input/circleci/circleci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,90 @@ func Test_ImagePlatforms(t *testing.T) {
}
}

// Test_ImageNativeArm verifies the split image build: the single
// push-to-registries job on each of the branch and tag paths is replaced by one
// build-image-arch leg per architecture plus, on the tag path, a
// merge-image-manifests job. The branch path gets no merge -- with push: false
// there is no digest to join.
//
// The merge job must keep the name push-to-registries-release, because
// sync-china-registry and any repo-owned custom.yml job `requires` it by that
// name and the append-only merge cannot rewrite a generated job's dependency.
func Test_ImageNativeArm(t *testing.T) {
got := render(t, Config{
RepoName: "claudebox-image",
Language: gen.Language(""),
Flavours: gen.FlavourSlice{},
HasDockerfile: true,
ImageNativeArm: true,
})

// Two branch legs and two tag legs, each on a class of its own architecture.
for _, want := range []string{
"name: build-image-amd64",
"name: build-image-arm64",
"name: push-to-registries-release-amd64",
"name: push-to-registries-release-arm64",
"name: push-to-registries-release",
"platform: linux/amd64",
"platform: linux/arm64",
"resource_class: arm.medium",
} {
if !contains(got, want) {
t.Errorf("expected %q in the split shape:\n%s", want, got)
}
}

// Four legs total: two branch, two tag.
if n := strings.Count(got, "architect/build-image-arch:"); n != 4 {
t.Errorf("expected 4 build-image-arch legs, found %d:\n%s", n, got)
}
// Exactly one merge, on the tag path only.
if n := strings.Count(got, "architect/merge-image-manifests:"); n != 1 {
t.Errorf("expected 1 merge-image-manifests job, found %d:\n%s", n, got)
}
// The single-job path must be gone, or the image would be built twice.
if contains(got, "architect/push-to-registries:") {
t.Errorf("push-to-registries should not be emitted alongside the split:\n%s", got)
}
// sync-china-registry still gates on the merge by its original name.
if !contains(got, "- push-to-registries-release\n") {
t.Errorf("sync-china-registry lost its requires edge to the merge job:\n%s", got)
}

// Default off: the single-job shape is untouched.
def := render(t, Config{
RepoName: "claudebox-image",
Language: gen.Language(""),
Flavours: gen.FlavourSlice{},
HasDockerfile: true,
})
if contains(def, "build-image-arch") || contains(def, "merge-image-manifests") {
t.Errorf("the split must not be emitted without ImageNativeArm:\n%s", def)
}
if !contains(def, "name: build-image") || !contains(def, "architect/push-to-registries:") {
t.Errorf("the default single-job shape regressed:\n%s", def)
}
}

// Test_ImageNativeArmRejectsPlatforms verifies the two flags are mutually
// exclusive. The split emits one leg per architecture, hardcoded to amd64 and
// arm64, so an overridden platform list has nowhere to go -- failing is better
// than silently honouring one flag and dropping the other.
func Test_ImageNativeArmRejectsPlatforms(t *testing.T) {
_, err := New(Config{
RepoName: "vllm",
Language: gen.Language(""),
Flavours: gen.FlavourSlice{},
HasDockerfile: true,
ImageNativeArm: true,
ImagePlatforms: "linux/arm64",
})
if err == nil {
t.Error("expected ImageNativeArm + ImagePlatforms to be rejected")
}
}

// Test_ImageDockerfile verifies the dockerfile-path override turns the image
// pipeline on even when no root Dockerfile is detected (HasDockerfile false)
// and applies the path to the build jobs (build-image and
Expand Down
1 change: 1 addition & 0 deletions pkg/gen/input/circleci/internal/file/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func NewWorkflowsInput(p params.Params) input.Input {
"ImagePreBuildJob": p.ImagePreBuildJob,
"ImagePrivateOnly": p.ImagePrivateOnly,
"ImageName": p.ImageName,
"ImageNativeArm": p.ImageNativeArm,
"ImagePlatforms": p.ImagePlatforms,
"ImageDockerfile": p.ImageDockerfile,
"ReleaseBinaries": p.ReleaseBinaries,
Expand Down
180 changes: 180 additions & 0 deletions pkg/gen/input/circleci/internal/file/workflows.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,72 @@ workflows:
# job, so the generator carries the dependency.
- {{ .ImagePreBuildJob }}
{{- end }}
{{- end }}
filters:
branches:
ignore:
- main
{{- else if .ImageNativeArm }}

# Branches: validate the image build without pushing anything, one job per
# architecture. Each leg runs on a machine of its own architecture, so
# neither is emulated -- the point of the split. No merge job: with
# push: false there is no digest to join and nothing leaves the BuildKit
# cache.
#
# amd64 sits on `small` rather than `medium` because a small docker executor
# is already given a medium remote-docker VM, which is where the build
# actually runs. arm.medium is the floor on the Arm side; there is no
# arm.small.
#
# These two jobs replace `build-image`. A repo whose .circleci/custom.yml
# has `requires: build-image` must point it at one of these names instead.
- architect/build-image-arch:
context: architect
name: build-image-amd64
platform: linux/amd64
resource_class: small
push: false
{{- if .ImageDockerfile }}
dockerfile: {{ .ImageDockerfile }}
{{- end }}
{{- if .ImageName }}
image: {{ .ImageName }}
{{- end }}
{{- if or .BuildJobName .ImagePreBuildJob }}
requires:
{{- if .BuildJobName }}
- {{ .BuildJobName }}
{{- end }}
{{- if .ImagePreBuildJob }}
- {{ .ImagePreBuildJob }}
{{- end }}
{{- end }}
filters:
branches:
ignore:
- main

- architect/build-image-arch:
context: architect
name: build-image-arm64
platform: linux/arm64
resource_class: arm.medium
push: false
{{- if .ImageDockerfile }}
dockerfile: {{ .ImageDockerfile }}
{{- end }}
{{- if .ImageName }}
image: {{ .ImageName }}
{{- end }}
{{- if or .BuildJobName .ImagePreBuildJob }}
requires:
{{- if .BuildJobName }}
- {{ .BuildJobName }}
{{- end }}
{{- if .ImagePreBuildJob }}
- {{ .ImagePreBuildJob }}
{{- end }}
{{- end }}
filters:
branches:
Expand Down Expand Up @@ -243,6 +309,119 @@ workflows:
- main
{{- end }}

{{- if .ImageNativeArm }}

# Tag builds, split across architectures: one native leg per platform, then a
# merge that assembles the tagged index and signs it. Both legs build on a
# machine of their own architecture, in parallel, so nothing runs under QEMU
# and wall clock is the slower single leg rather than the sum.
#
# Each leg pushes its platform BY DIGEST to every eligible registry, setting
# no tag -- a single-architecture manifest is not independently useful, and
# tagging one would publish something consumers could pull by accident. The
# merge job creates the tags.
#
# The merge keeps the name push-to-registries-release, so the `requires`
# edges below (sync-china-registry, and any repo-owned custom.yml job) keep
# resolving without knowing the build was split.
- architect/build-image-arch:
context: architect
name: push-to-registries-release-amd64
platform: linux/amd64
resource_class: small
{{- if .ForcePublic }}
force-public: true
{{- end }}
{{- if .ImageDockerfile }}
dockerfile: {{ .ImageDockerfile }}
{{- end }}
{{- if .ImageName }}
image: {{ .ImageName }}
{{- end }}
{{- if .ImagePrivateOnly }}
registries-data: |-
private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true
{{- else }}
# Must match the merge job, or the legs and the merge disagree about
# which registries hold the per-architecture digests.
split-china-push: true
{{- end }}
{{- if or .BuildJobName .ImagePreBuildJob }}
requires:
{{- if .BuildJobName }}
- {{ .BuildJobName }}
{{- end }}
{{- if .ImagePreBuildJob }}
- {{ .ImagePreBuildJob }}
{{- end }}
{{- end }}
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/

- architect/build-image-arch:
context: architect
name: push-to-registries-release-arm64
platform: linux/arm64
resource_class: arm.medium
{{- if .ForcePublic }}
force-public: true
{{- end }}
{{- if .ImageDockerfile }}
dockerfile: {{ .ImageDockerfile }}
{{- end }}
{{- if .ImageName }}
image: {{ .ImageName }}
{{- end }}
{{- if .ImagePrivateOnly }}
registries-data: |-
private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true
{{- else }}
split-china-push: true
{{- end }}
{{- if or .BuildJobName .ImagePreBuildJob }}
requires:
{{- if .BuildJobName }}
- {{ .BuildJobName }}
{{- end }}
{{- if .ImagePreBuildJob }}
- {{ .ImagePreBuildJob }}
{{- end }}
{{- end }}
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/

- architect/merge-image-manifests:
context: architect
name: push-to-registries-release
platforms: "linux/amd64,linux/arm64"
{{- if .ForcePublic }}
force-public: true
{{- end }}
{{- if .ImageName }}
image: {{ .ImageName }}
{{- end }}
{{- if .ImagePrivateOnly }}
registries-data: |-
private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true
{{- else }}
split-china-push: true
{{- end }}
requires:
- push-to-registries-release-amd64
- push-to-registries-release-arm64
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
{{- else }}

# Tag builds: push multi-arch image to gsoci + gsociprivate. Aliyun is
# handled by the sync-china-registry job below (orb's split-china-push
# mechanism), so the buildx push no longer crosses the Pacific.
Expand Down Expand Up @@ -295,6 +474,7 @@ workflows:
only: /^v.*/
branches:
ignore: /.*/
{{- end }}
{{- if not .ImagePrivateOnly }}

# Mirror gsoci -> Aliyun via the in-China giantswarm/galaxy-runner. Runs
Expand Down
Loading