Repository: siderolabs/image-factory
Pull request #210: feat: support platform specific installers
Author: login: frezbo
Created: 2025-03-18T18:39:30Z | Merged: 2025-03-20T17:22:23Z

Support platform specific installers.

Fixes: https://github.com/siderolabs/talos/issues/10486


Diff:
diff --git a/Dockerfile b/Dockerfile
index e0a05ba5..73a21a81 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
-# syntax = docker/dockerfile-upstream:1.14.0-labs
+# syntax = docker/dockerfile-upstream:1.14.1-labs
 
 # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
 #
-# Generated on 2025-03-07T07:16:04Z by kres e2c7efe.
+# Generated on 2025-03-18T18:37:39Z by kres ec5ec04.
 
 ARG TOOLCHAIN
 ARG PKGS_PREFIX
@@ -81,7 +81,7 @@ FROM ${PKGS_PREFIX}/zstd:${PKGS} AS pkg-zstd
 FROM docker.io/oven/bun:1.2.4-alpine AS tailwind-base
 WORKDIR /src
 COPY package.json package-lock.json .
-RUN --mount=type=cache,target=/src/node_modules bun install
+RUN --mount=type=cache,target=/src/node_modules,id=image-factory/src/node_modules bun install
 
 # base toolchain image
 FROM --platform=${BUILDPLATFORM} ${TOOLCHAIN} AS toolchain
@@ -124,7 +124,7 @@ COPY --from=pkg-zstd / /
 FROM tailwind-base AS tailwind-update
 COPY tailwind.config.js .
 COPY internal/frontend/http internal/frontend/http
-RUN --mount=type=cache,target=/src/node_modules node_modules/.bin/tailwindcss -i internal/frontend/http/css/input.css -o internal/frontend/http/css/output.css --minify
+RUN --mount=type=cache,target=/src/node_modules,id=image-factory/src/node_modules node_modules/.bin/tailwindcss -i internal/frontend/http/css/input.css -o internal/frontend/http/css/output.css --minify
 
 # build tools
 FROM --platform=${BUILDPLATFORM} toolchain AS tools
@@ -137,12 +137,12 @@ ARG GOEXPERIMENT
 ENV GOEXPERIMENT=${GOEXPERIMENT}
 ENV GOPATH=/go
 ARG DEEPCOPY_VERSION
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg go install github.com/siderolabs/deep-copy@${DEEPCOPY_VERSION} \
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install github.com/siderolabs/deep-copy@${DEEPCOPY_VERSION} \
 	&& mv /go/bin/deep-copy /bin/deep-copy
 ARG GOLANGCILINT_VERSION
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCILINT_VERSION} \
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCILINT_VERSION} \
 	&& mv /go/bin/golangci-lint /bin/golangci-lint
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg go install golang.org/x/vuln/cmd/govulncheck@latest \
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install golang.org/x/vuln/cmd/govulncheck@latest \
 	&& mv /go/bin/govulncheck /bin/govulncheck
 ARG GOFUMPT_VERSION
 RUN go install mvdan.cc/gofumpt@${GOFUMPT_VERSION} \
@@ -158,12 +158,12 @@ WORKDIR /src
 COPY go.mod go.mod
 COPY go.sum go.sum
 RUN cd .
-RUN --mount=type=cache,target=/go/pkg go mod download
-RUN --mount=type=cache,target=/go/pkg go mod verify
+RUN --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go mod download
+RUN --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go mod verify
 COPY ./cmd ./cmd
 COPY ./internal ./internal
 COPY ./pkg ./pkg
-RUN --mount=type=cache,target=/go/pkg go list -mod=readonly all >/dev/null
+RUN --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go list -mod=readonly all >/dev/null
 
 FROM tools AS embed-generate
 ARG SHA
@@ -175,7 +175,7 @@ RUN mkdir -p internal/version/data && \
 
 # builds the integration test binary
 FROM base AS integration-build
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg go test -c -covermode=atomic -coverpkg=./... -tags integration ./internal/integration
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go test -c -covermode=atomic -coverpkg=./... -tags integration ./internal/integration
 
 # runs gofumpt
 FROM base AS lint-gofumpt
@@ -187,24 +187,24 @@ WORKDIR /src
 COPY .golangci.yml .
 ENV GOGC=50
 RUN golangci-lint config verify --config .golangci.yml
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/root/.cache/golangci-lint --mount=type=cache,target=/go/pkg golangci-lint run --config .golangci.yml
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/root/.cache/golangci-lint,id=image-factory/root/.cache/golangci-lint,sharing=locked --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg golangci-lint run --config .golangci.yml
 
 # runs govulncheck
 FROM base AS lint-govulncheck
 WORKDIR /src
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg govulncheck ./...
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg govulncheck ./...
 
 # runs unit-tests with race detector
 FROM base AS unit-tests-race
 WORKDIR /src
 ARG TESTPKGS
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg --mount=type=cache,target=/tmp CGO_ENABLED=1 go test -v -race -count 1 ${TESTPKGS}
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp CGO_ENABLED=1 go test -v -race -count 1 ${TESTPKGS}
 
 # runs unit-tests
 FROM base AS unit-tests-run
 WORKDIR /src
 ARG TESTPKGS
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg --mount=type=cache,target=/tmp go test -v -covermode=atomic -coverprofile=coverage.txt -coverpkg=${TESTPKGS} -count 1 ${TESTPKGS}
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp go test -v -covermode=atomic -coverprofile=coverage.txt -coverpkg=${TESTPKGS} -count 1 ${TESTPKGS}
 
 FROM embed-generate AS embed-abbrev-generate
 WORKDIR /src
@@ -233,7 +233,7 @@ ARG GO_LDFLAGS
 ARG VERSION_PKG="internal/version"
 ARG SHA
 ARG TAG
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg GOARCH=amd64 GOOS=linux go build ${GO_BUILDFLAGS} -ldflags "${GO_LDFLAGS} -X ${VERSION_PKG}.Name=image-factory -X ${VERSION_PKG}.SHA=${SHA} -X ${VERSION_PKG}.Tag=${TAG}" -o /image-factory-linux-amd64
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg GOARCH=amd64 GOOS=linux go build ${GO_BUILDFLAGS} -ldflags "${GO_LDFLAGS} -X ${VERSION_PKG}.Name=image-factory -X ${VERSION_PKG}.SHA=${SHA} -X ${VERSION_PKG}.Tag=${TAG}" -o /image-factory-linux-amd64
 
 # builds image-factory-linux-arm64
 FROM base AS image-factory-linux-arm64-build
@@ -245,7 +245,7 @@ ARG GO_LDFLAGS
 ARG VERSION_PKG="internal/version"
 ARG SHA
 ARG TAG
-RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg GOARCH=arm64 GOOS=linux go build ${GO_BUILDFLAGS} -ldflags "${GO_LDFLAGS} -X ${VERSION_PKG}.Name=image-factory -X ${VERSION_PKG}.SHA=${SHA} -X ${VERSION_PKG}.Tag=${TAG}" -o /image-factory-linux-arm64
+RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg GOARCH=arm64 GOOS=linux go build ${GO_BUILDFLAGS} -ldflags "${GO_LDFLAGS} -X ${VERSION_PKG}.Name=image-factory -X ${VERSION_PKG}.SHA=${SHA} -X ${VERSION_PKG}.Tag=${TAG}" -o /image-factory-linux-arm64
 
 FROM scratch AS image-factory-linux-amd64
 COPY --from=image-factory-linux-amd64-build /image-factory-linux-amd64 /image-factory-linux-amd64
diff --git a/Makefile b/Makefile
index fd979cfe..91c99499 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
 #
-# Generated on 2025-03-07T06:25:21Z by kres e2c7efe.
+# Generated on 2025-03-17T03:07:40Z by kres ec5ec04.
 
 # common variables
 
@@ -21,7 +21,7 @@ PROTOBUF_GO_VERSION ?= 1.36.5
 GRPC_GO_VERSION ?= 1.5.1
 GRPC_GATEWAY_VERSION ?= 2.26.3
 VTPROTOBUF_VERSION ?= 0.6.0
-GOIMPORTS_VERSION ?= 0.30.0
+GOIMPORTS_VERSION ?= 0.31.0
 DEEPCOPY_VERSION ?= v0.5.6
 GOLANGCILINT_VERSION ?= v1.64.6
 GOFUMPT_VERSION ?= v0.7.0
@@ -30,6 +30,7 @@ GO_BUILDFLAGS ?=
 GO_LDFLAGS ?=
 CGO_ENABLED ?= 0
 GOTOOLCHAIN ?= local
+GOEXPERIMENT ?= synctest
 TESTPKGS ?= ./...
 KRES_IMAGE ?= ghcr.io/siderolabs/kres:latest
 CONFORMANCE_IMAGE ?= ghcr.io/siderolabs/conform:latest
diff --git a/README.md b/README.md
index 95892ea3..f4550920 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,8 @@ Supported image paths:
 * `initramfs-<arch>.xz` (e.g. `initramfs-amd64.xz`) - initramfs image (including system extensions if configured)
 * `<platform>-<arch>[-secureboot].iso` (e.g. `metal-amd64.iso`) - ISO image
 * `<platform>-<arch>[-secureboot]-uki.efi` (e.g. `metal-amd64-secureboot-uki.efi`) UEFI UKI image (Secure Boot compatible)
-* `installer-<arch>[-secureboot].tar` (e.g. `installer-amd64.tar`) is a custom Talos Linux installer image (including system extensions if configured)
+* `installer-<arch>[-secureboot].tar` (e.g. `installer-amd64.tar`) is a custom Talos Linux installer image for `metal` platform (including system extensions if configured)
+* `<platform>-installer-<arch>[-secureboot].tar` (e.g. `aws-installer-amd64.tar`) is a custom Talos Linux installer image for specific platform (including system extensions if configured)
 * disk images in different formats (see Talos Linux documentation for a full list):
   * `metal-<arch>[-secureboot].raw.xz` (e.g. `metal-amd64.raw.xz`) - raw disk image for metal platform
   * `aws-<arch>.raw.xz` (e.g. `aws-amd64.raw.xz`) - raw disk image for AWS platform, that can be imported as an AMI
@@ -177,10 +178,21 @@ The Talos Linux `installer` image is used for the initial install and upgrades.
 It can be pulled from the Image Factory OCI registry.
 If the image hasn't been created yet, it will be built on demand automatically.
 
-### `docker pull <registry>/installer[-secureboot]/<schematic>:<version>`
+### Legacy `installer` Image
+
+#### `docker pull <registry>/installer[-secureboot]/<schematic>:<version>`
 
 Example: `docker pull factory.talos.dev/installer/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba:v1.5.0`
 
+### `installer` Image
+
+#### `docker pull <registry>/<platform>-installer[-secureboot]/<version>`
+
+Examples:
+
+* `docker pull factory.talos.dev/metal-installer/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba:v1.5.0`
+* `docker pull factory.talos.dev/aws-installer/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba:v1.5.0`
+
 Pulls the Talos Linux `installer` image with the specified schematic and Talos Linux version.
 The image platform (architecture) will be determined by the architecture of the Talos Linux Linux machine.
 
diff --git a/go.mod b/go.mod
index c4648377..0570dc4b 100644
--- a/go.mod
+++ b/go.mod
@@ -22,7 +22,7 @@ require (
 	github.com/u-root/u-root v0.14.0
 	github.com/ulikunitz/xz v0.5.12
 	go.uber.org/zap v1.27.0
-	golang.org/x/net v0.35.0
+	golang.org/x/net v0.36.0
 	golang.org/x/sync v0.11.0
 	golang.org/x/sys v0.30.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -100,7 +100,7 @@ require (
 	github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect
 	github.com/containerd/cgroups/v3 v3.0.5 // indirect
 	github.com/containerd/containerd/api v1.8.0 // indirect
-	github.com/containerd/containerd/v2 v2.0.3 // indirect
+	github.com/containerd/containerd/v2 v2.0.4 // indirect
 	github.com/containerd/continuity v0.4.4 // indirect
 	github.com/containerd/errdefs v1.0.0 // indirect
 	github.com/containerd/errdefs/pkg v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index 9d76affd..b64460e5 100644
--- a/go.sum
+++ b/go.sum
@@ -230,8 +230,8 @@ github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJ
 github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
 github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVMyTRdsD2bS0=
 github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc=
-github.com/containerd/containerd/v2 v2.0.3 h1:zBKgwgZsuu+LPCMzCLgA4sC4MiZzZ59ZT31XkmiISQM=
-github.com/containerd/containerd/v2 v2.0.3/go.mod h1:5j9QUUaV/cy9ZeAx4S+8n9ffpf+iYnEj4jiExgcbuLY=
+github.com/containerd/containerd/v2 v2.0.4 h1:+r7yJMwhTfMm3CDyiBjMBQO8a9CTBxL2Bg/JtqtIwB8=
+github.com/containerd/containerd/v2 v2.0.4/go.mod h1:5j9QUUaV/cy9ZeAx4S+8n9ffpf+iYnEj4jiExgcbuLY=
 github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII=
 github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -931,8 +931,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
+golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
 golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go
index e7b29fcf..baccc09b 100644
--- a/internal/artifacts/manager.go
+++ b/internal/artifacts/manager.go
@@ -257,7 +257,7 @@ func (m *Manager) GetOfficialOverlays(ctx context.Context, versionString string)
 	return overlays, nil
 }
 
-// GetInstallerImage pulls and stoers in OCI layout installer image.
+// GetInstallerImage pulls and stores in OCI layout installer-base image.
 func (m *Manager) GetInstallerImage(ctx context.Context, arch Arch, versionString string) (string, error) {
 	version, err := semver.ParseTolerant(versionString)
 	if err != nil {
diff --git a/internal/frontend/http/registry.go b/internal/frontend/http/registry.go
index fbba44b9..f9fdbd98 100644
--- a/internal/frontend/http/registry.go
+++ b/internal/frontend/http/registry.go
@@ -35,6 +35,8 @@ func (f *Frontend) handleHealth(_ context.Context, _ http.ResponseWriter, _ *htt
 }
 
 type requestedImage struct {
+	imageName  string
+	platform   string
 	secureboot bool
 }
 
@@ -43,24 +45,35 @@ func getRequestedImage(p httprouter.Params) (requestedImage, error) {
 
 	switch image {
 	case "installer":
-		return requestedImage{secureboot: false}, nil
+		// defaults to metal image
+		return requestedImage{
+			imageName:  image,
+			secureboot: false,
+		}, nil
 	case "installer-secureboot":
-		return requestedImage{secureboot: true}, nil
+		return requestedImage{
+			imageName:  image,
+			secureboot: true,
+		}, nil
 	default:
+		// newer installer has `-installer` as suffix
+		// Eg: metal-installer, metal-installer-secureboot, digital-ocean-installer etc
+		// first try `-installer-secureboot` and then `-installer`
+		platform, ok := strings.CutSuffix(image, "-installer-secureboot")
+		if ok {
+			return requestedImage{imageName: image, platform: platform, secureboot: true}, nil
+		}
+
+		if platform, ok = strings.CutSuffix(image, "-installer"); ok {
+			return requestedImage{imageName: image, platform: platform, secureboot: false}, nil
+		}
+
 		return requestedImage{}, fmt.Errorf("invalid image: %s", image)
 	}
 }
 
 func (img requestedImage) Name() string {
-	if img.secureboot {
-		return "installer-secureboot"
-	}
-
-	return "installer"
-}
-
-func (img requestedImage) SecureBoot() bool {
-	return img.secureboot
+	return img.imageName
 }
 
 // handleBlob handles image blob download.
@@ -228,7 +241,7 @@ func (f *Frontend) buildInstallImage(ctx context.Context, img requestedImage, sc
 	var imageIndex v1.ImageIndex = empty.Index
 
 	for _, arch := range []artifacts.Arch{artifacts.ArchAmd64, artifacts.ArchArm64} {
-		prof := profile.InstallerProfile(img.SecureBoot(), arch)
+		prof := profile.InstallerProfile(img.secureboot, arch, img.platform)
 
 		prof, err := profile.EnhanceFromSchematic(ctx, prof, schematic, f.artifactsManager, f.secureBootService, versionTag)
 		if err != nil {
diff --git a/internal/frontend/http/ui.go b/internal/frontend/http/ui.go
index 0c79add1..52116b0b 100644
--- a/internal/frontend/http/ui.go
+++ b/internal/frontend/http/ui.go
@@ -444,6 +444,14 @@ func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string
 
 	version := "v" + params.Version
 
+	installerImage := fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version)
+	secureBootInstallerImage := fmt.Sprintf("%s/installer-secureboot/%s:%s", f.options.ExternalURL.Host, schematicID, version)
+
+	if quirks.New(version).SupportsUnifiedInstaller() {
+		installerImage = fmt.Sprintf("%s/%s-installer/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version)
+		secureBootInstallerImage = fmt.Sprintf("%s/%s-installer-secureboot/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version)
+	}
+
 	return "wizard-final",
 		struct { //nolint:govet
 			WizardParams
@@ -467,8 +475,8 @@ func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string
 			ImageBaseURL: f.options.ExternalURL.JoinPath("image", schematicID, version),
 			PXEBaseURL:   f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
 
-			InstallerImage:           fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version),
-			SecureBootInstallerImage: fmt.Sprintf("%s/installer-secureboot/%s:%s", f.options.ExternalURL.Host, schematicID, version),
+			InstallerImage:           installerImage,
+			SecureBootInstallerImage: secureBootInstallerImage,
 
 			TroubleshootingGuideAvailable: talosVersion.GTE(semver.MustParse("1.6.0")),
 			ProductionGuideAvailable:      talosVersion.GTE(semver.MustParse("1.5.0")),
@@ -636,148 +644,6 @@ func (f *Frontend) handleUIVersionDoc(_ context.Context, w http.ResponseWriter,
 	return getTemplates().ExecuteTemplate(w, "version-doc.html", version)
 }
 
-// handleUIVersions handles '/ui/schematic-config'.
-func (f *Frontend) handleUISchematicConfig(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error { //nolint:unused // to be removed
-	if r.Method == http.MethodHead {
-		return nil
-	}
-
-	versionParam := r.URL.Query().Get("version")
-	if versionParam == "" {
-		return nil
-	}
-
-	version, err := semver.Parse(versionParam)
-	if err != nil {
-		return fmt.Errorf("error parsing version: %w", err)
-	}
-
-	extensions, err := f.getOfficialExtensions(ctx, version.String())
-	if err != nil {
-		return err
-	}
-
-	var overlays []artifacts.OverlayRef
-
-	if quirks.New(version.String()).SupportsOverlay() {
-		overlays, err = f.artifactsManager.GetOfficialOverlays(ctx, version.String())
-		if err != nil {
-			return err
-		}
-	}
-
-	return getTemplates().ExecuteTemplate(w, "schematic-config.html", struct {
-		Extensions []artifacts.ExtensionRef
-		Overlays   []artifacts.OverlayRef
-	}{
-		Extensions: extensions,
-		Overlays:   overlays,
-	})
-}
-
-// handleUISchematics handles '/ui/schematics'.
-func (f *Frontend) handleUISchematics(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error { //nolint:unused // to be removed
-	if err := r.ParseForm(); err != nil {
-		return err
-	}
-
-	versionParam := r.PostForm.Get("version")
-	extraArgsParam := r.PostForm.Get("extra-args")
-	extraArgsParam = strings.TrimSpace(extraArgsParam)
-
-	var extraArgs []string
-
-	if extraArgsParam != "" {
-		extraArgs = strings.Split(extraArgsParam, " ")
-	}
-
-	var (
-		overlayOptions string
-
-		extensions = make([]string, 0)
-	)
-
-	for name := range r.PostForm {
-		if !strings.HasPrefix(name, "ext-") {
-			continue
-		}
-
-		extensions = append(extensions, name[4:])
-	}
-
-	slices.Sort(extensions)
-
-	overlayData := r.PostForm.Get("overlay")
-
-	overlayName, overlayImage, _ := strings.Cut(overlayData, "@")
-
-	if overlayName != "" {
-		overlayOptions = r.PostForm.Get("extra-overlay-options")
-	}
-
-	overlay := schematic.Overlay{
-		Name:  overlayName,
-		Image: overlayImage,
-	}
-
-	if overlayOptions != "" {
-		var overlayOptsParsed map[string]any
-
-		if err := yaml.Unmarshal([]byte(overlayOptions), &overlayOptsParsed); err != nil {
-			return fmt.Errorf("error parsing overlay options: %w", err)
-		}
-
-		overlay.Options = overlayOptsParsed
-	}
-
-	requestedSchematic := schematic.Schematic{
-		Overlay: overlay,
-		Customization: schematic.Customization{
-			ExtraKernelArgs: extraArgs,
-			SystemExtensions: schematic.SystemExtensions{
-				OfficialExtensions: extensions,
-			},
-		},
-	}
-
-	schematicID, err := f.schematicFactory.Put(ctx, &requestedSchematic)
-	if err != nil {
-		return err
-	}
-
-	marshaled, err := requestedSchematic.Marshal()
-	if err != nil {
-		return err
-	}
-
-	version := "v" + versionParam
-
-	return getTemplates().ExecuteTemplate(w, "schematic.html", struct {
-		Version   string
-		Schematic string
-		Marshaled string
-
-		ImageBaseURL             *url.URL
-		PXEBaseURL               *url.URL
-		InstallerImage           string
-		SecureBootInstallerImage string
-
-		Architectures []string
-	}{
-		Version:                  version,
-		Schematic:                schematicID,
-		Marshaled:                string(marshaled),
-		ImageBaseURL:             f.options.ExternalURL.JoinPath("image", schematicID, version),
-		PXEBaseURL:               f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
-		InstallerImage:           fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version),
-		SecureBootInstallerImage: fmt.Sprintf("%s/installer-secureboot/%s:%s", f.options.ExternalURL.Host, schematicID, version),
-		Architectures: []string{
-			string(artifacts.ArchAmd64),
-			string(artifacts.ArchArm64),
-		},
-	})
-}
-
 func (f *Frontend) getOfficialExtensions(ctx context.Context, version string) ([]artifacts.ExtensionRef, error) {
 	extensions, err := f.artifactsManager.GetOfficialExtensions(ctx, version)
 	if err != nil {
diff --git a/internal/integration/download_test.go b/internal/integration/download_test.go
index 6ef675ab..11db2c5a 100644
--- a/internal/integration/download_test.go
+++ b/internal/integration/download_test.go
@@ -120,6 +120,54 @@ func downloadAssetAndValidateInitramfs(ctx context.Context, t *testing.T, baseUR
 	downloadAssetAssertCached(ctx, t, baseURL, schematicID, talosVersion, path, size)
 }
 
+func downloadAssetAndValidateUKI(ctx context.Context, t *testing.T, baseURL string, schematicID, talosVersion, path string, ukiSpec ukiSpec) {
+	t.Helper()
+
+	resp := downloadAsset(ctx, t, baseURL, schematicID, talosVersion, path)
+	body := resp.Body
+
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	d := t.TempDir()
+	ukiPath := filepath.Join(d, "uki.efi")
+
+	out, err := os.Create(ukiPath)
+	require.NoError(t, err)
+
+	size, err := io.Copy(out, body)
+	require.NoError(t, err)
+
+	require.NoError(t, out.Close())
+
+	assertUKI(t, ukiPath, ukiSpec)
+
+	downloadAssetAssertCached(ctx, t, baseURL, schematicID, talosVersion, path, size)
+}
+
+func downloadInstallerAndValidateUKI(ctx context.Context, t *testing.T, baseURL string, schematicID, talosVersion, path, arch string, ukiSpec ukiSpec) {
+	t.Helper()
+
+	resp := downloadAsset(ctx, t, baseURL, schematicID, talosVersion, path)
+	body := resp.Body
+
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	d := t.TempDir()
+	installerPath := filepath.Join(d, "installer.tar")
+
+	out, err := os.Create(installerPath)
+	require.NoError(t, err)
+
+	size, err := io.Copy(out, body)
+	require.NoError(t, err)
+
+	require.NoError(t, out.Close())
+
+	assertInstallerTarUKIArtifact(t, installerPath, arch, ukiSpec)
+
+	downloadAssetAssertCached(ctx, t, baseURL, schematicID, talosVersion, path, size)
+}
+
 func downloadCmdlineAndMatch(ctx context.Context, t *testing.T, baseURL string, schematicID, talosVersion, path string, expected string) {
 	t.Helper()
 
@@ -237,13 +285,72 @@ func testDownloadFrontend(ctx context.Context, t *testing.T, baseURL string) {
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "metal-arm64-secureboot-uki.efi", "application/vnd.microsoft.portable-executable", sizePicker(talosVersion, "1.5", 114564272, "1.8", 82733744, "1.9", 82733744, "1.10", 86*MiB))
 				})
 
-				t.Run("installer image", func(t *testing.T) {
+				t.Run("nocloud UKI", func(t *testing.T) {
+					t.Parallel()
+
+					expected := "talos.platform=nocloud console=tty1 console=ttyS0 net.ifnames=0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512"
+
+					if quirks.New(talosVersion).SupportsSELinux() {
+						expected += " selinux=1"
+					}
+
+					downloadAssetAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "nocloud-amd64-uki.efi", ukiSpec{
+						expectedCmdline: expected,
+					})
+				})
+
+				t.Run("legacy installer image", func(t *testing.T) {
+					if quirks.New(talosVersion).SupportsUnifiedInstaller() {
+						t.Skip()
+					}
+
 					t.Parallel()
 
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-amd64.tar", "application/x-tar", sizePicker(talosVersion, "1.5", 167482880, "1.8", 185155584, "1.9", 136*MiB, "1.10", 127*MiB))
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-arm64.tar", "application/x-tar", sizePicker(talosVersion, "1.5", 222793728, "1.8", 170119168, "1.9", 126*MiB, "1.10", 116*MiB))
 				})
 
+				t.Run("installer image", func(t *testing.T) {
+					if !quirks.New(talosVersion).SupportsUnifiedInstaller() {
+						t.Skip()
+					}
+
+					t.Parallel()
+
+					expectedMetalCmdlineAMD64 := "talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1"
+					expectedMetalCmdlineARM64 := "talos.platform=metal console=ttyAMA0 console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1"
+
+					expectedAWSCmdline := "talos.platform=aws console=tty1 console=ttyS0 net.ifnames=0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1"
+
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-amd64.tar", "amd64", ukiSpec{
+						expectedCmdline: expectedMetalCmdlineAMD64,
+					})
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "metal-installer-amd64.tar", "amd64", ukiSpec{
+						expectedCmdline: expectedMetalCmdlineAMD64,
+					})
+
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-arm64.tar", "arm64", ukiSpec{
+						expectedCmdline: expectedMetalCmdlineARM64,
+					})
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "metal-installer-arm64.tar", "arm64", ukiSpec{
+						expectedCmdline: expectedMetalCmdlineARM64,
+					})
+
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "aws-installer-amd64.tar", "amd64", ukiSpec{
+						expectedCmdline: expectedAWSCmdline,
+					})
+
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "aws-installer-arm64.tar", "arm64", ukiSpec{
+						expectedCmdline: expectedAWSCmdline,
+					})
+
+					expectedNoCloudCmdline := "talos.platform=nocloud console=tty1 console=ttyS0 net.ifnames=0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1"
+
+					downloadInstallerAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "nocloud-installer-amd64.tar", "amd64", ukiSpec{
+						expectedCmdline: expectedNoCloudCmdline,
+					})
+				})
+
 				t.Run("metal image", func(t *testing.T) {
 					t.Parallel()
 
@@ -519,6 +626,70 @@ func testDownloadFrontend(ctx context.Context, t *testing.T, baseURL string) {
 		})
 	})
 
+	t.Run("uki extra args", func(t *testing.T) {
+		t.Parallel()
+
+		for _, talosVersion := range talosVersions {
+			t.Run(talosVersion, func(t *testing.T) {
+				if !quirks.New(talosVersion).SupportsUnifiedInstaller() {
+					t.Skip()
+				}
+
+				t.Parallel()
+
+				expected := "talos.platform=metal console=tty0 console=ttyS0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512"
+
+				if !quirks.New(talosVersion).SupportsMetalPlatformConsoleTTYS0() {
+					expected = strings.ReplaceAll(expected, " console=ttyS0", "")
+				}
+
+				if quirks.New(talosVersion).SupportsSELinux() {
+					expected += " selinux=1"
+				}
+
+				expected += " nolapic nomodeset"
+
+				downloadAssetAndValidateUKI(ctx, t, baseURL, extraArgsSchematicID, talosVersion, "metal-amd64-uki.efi", ukiSpec{
+					expectedCmdline: expected,
+				})
+			})
+		}
+	})
+
+	t.Run("installer extra args", func(t *testing.T) {
+		t.Parallel()
+
+		for _, talosVersion := range talosVersions {
+			t.Run(talosVersion, func(t *testing.T) {
+				if !quirks.New(talosVersion).SupportsUnifiedInstaller() {
+					t.Skip()
+				}
+
+				t.Parallel()
+
+				expected := "talos.platform=metal console=tty0 console=ttyS0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512"
+
+				if !quirks.New(talosVersion).SupportsMetalPlatformConsoleTTYS0() {
+					expected = strings.ReplaceAll(expected, " console=ttyS0", "")
+				}
+
+				if quirks.New(talosVersion).SupportsSELinux() {
+					expected += " selinux=1"
+				}
+
+				expected += " nolapic nomodeset"
+
+				downloadInstallerAndValidateUKI(ctx, t, baseURL, extraArgsSchematicID, talosVersion, "installer-amd64.tar", "amd64", ukiSpec{
+					expectedCmdline: expected,
+				})
+
+				downloadInstallerAndValidateUKI(ctx, t, baseURL, extraArgsSchematicID, talosVersion, "metal-installer-amd64.tar", "amd64", ukiSpec{
+					expectedCmdline: expected,
+				})
+			})
+		}
+	})
+
 	t.Run("cmdline", func(t *testing.T) {
 		t.Parallel()
 
diff --git a/internal/integration/installer_tar_test.go b/internal/integration/installer_tar_test.go
new file mode 100644
index 00000000..2784e2aa
--- /dev/null
+++ b/internal/integration/installer_tar_test.go
@@ -0,0 +1,72 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+//go:build integration
+
+package integration_test
+
+import (
+	"archive/tar"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-containerregistry/pkg/crane"
+	"github.com/stretchr/testify/require"
+)
+
+func assertInstallerTarUKIArtifact(t *testing.T, path, arch string, ukiSpec ukiSpec) {
+	t.Helper()
+
+	img, err := crane.Load(path)
+	require.NoError(t, err)
+
+	layers, err := img.Layers()
+	require.NoError(t, err)
+
+	require.Len(t, layers, 2)
+
+	artifactsLayer := layers[1]
+
+	reader, err := artifactsLayer.Uncompressed()
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		require.NoError(t, reader.Close())
+	})
+
+	tarReader := tar.NewReader(reader)
+
+	ukiExtractDir := t.TempDir()
+	ukiFile := filepath.Join(ukiExtractDir, fmt.Sprintf("vmlinuz-%s.efi", arch))
+
+	for {
+		header, err := tarReader.Next()
+		if err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+
+			require.NoError(t, err)
+		}
+
+		switch header.Name {
+		case fmt.Sprintf("usr/install/%s/vmlinuz.efi", arch):
+			file, err := os.Create(ukiFile)
+			require.NoError(t, err)
+
+			_, err = io.Copy(file, tarReader)
+			require.NoError(t, err)
+
+			assertUKI(t, ukiFile, ukiSpec)
+		// nothing to do here
+		case fmt.Sprintf("usr/install/%s/systemd-boot.efi", arch):
+		default:
+			t.Fatalf("unexpected file in installer tar: %s", header.Name)
+		}
+	}
+}
diff --git a/internal/integration/uki_test.go b/internal/integration/uki_test.go
new file mode 100644
index 00000000..4f6d0a9e
--- /dev/null
+++ b/internal/integration/uki_test.go
@@ -0,0 +1,41 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+//go:build integration
+
+package integration_test
+
+import (
+	"debug/pe"
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+type ukiSpec struct {
+	expectedCmdline string
+}
+
+func assertUKI(t *testing.T, path string, expected ukiSpec) {
+	t.Helper()
+
+	peInfo, err := pe.Open(path)
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		require.NoError(t, peInfo.Close())
+	})
+
+	sectionInfo := peInfo.Section(".cmdline")
+
+	var cmdline strings.Builder
+
+	limitReader := io.LimitReader(sectionInfo.Open(), int64(sectionInfo.VirtualSize))
+	_, err = io.Copy(&cmdline, limitReader)
+	require.NoError(t, err)
+
+	require.Equal(t, expected.expectedCmdline, cmdline.String())
+}
diff --git a/internal/profile/profile.go b/internal/profile/profile.go
index fac6affd..4ec69d15 100644
--- a/internal/profile/profile.go
+++ b/internal/profile/profile.go
@@ -133,7 +133,7 @@ func ParseFromPath(path, version string) (profile.Profile, error) {
 		return prof, nil
 	}
 
-	// <platform>-<arch>-secureboot-uki.efi
+	// <platform>-<arch>[-secureboot]-uki.efi
 	if rest, ok := strings.CutSuffix(path, "-uki.efi"); ok {
 		prof.Output.Kind = profile.OutKindUKI
 		prof.Output.OutFormat = profile.OutFormatRaw
@@ -150,7 +150,7 @@ func ParseFromPath(path, version string) (profile.Profile, error) {
 		if rest, ok = strings.CutSuffix(rest, ".tar"); ok {
 			prof.Output.Kind = profile.OutKindInstaller
 			prof.Output.OutFormat = profile.OutFormatRaw
-			prof.Platform = constants.PlatformMetal // doesn't matter for installer output
+			prof.Platform = constants.PlatformMetal // default installer platform
 
 			rest, ok = strings.CutSuffix(rest, "-secureboot")
 			if ok {
@@ -165,6 +165,20 @@ func ParseFromPath(path, version string) (profile.Profile, error) {
 		}
 	}
 
+	// <platform>-installer-<arch>[-secureboot].tar
+	if platform, rest, ok := strings.Cut(path, "-installer-"); ok {
+		if rest, ok = strings.CutSuffix(rest, ".tar"); ok {
+			prof.Output.Kind = profile.OutKindInstaller
+			prof.Output.OutFormat = profile.OutFormatRaw
+
+			if err := parsePlatformArch(platform+"-"+rest, version, &prof); err != nil {
+				return prof, err
+			}
+
+			return prof, nil
+		}
+	}
+
 	// at this point, we assume that the path is a disk image, so we start parsing it from the end, cutting the output format suffixes
 	prof.Output.Kind = profile.OutKindImage
 	prof.Output.ImageOptions = &profile.ImageOptions{
@@ -233,13 +247,17 @@ func ParseFromPath(path, version string) (profile.Profile, error) {
 }
 
 // InstallerProfile returns a profile to be used for installer image.
-func InstallerProfile(secureboot bool, arch artifacts.Arch) profile.Profile {
+func InstallerProfile(secureboot bool, arch artifacts.Arch, platform string) profile.Profile {
 	var prof profile.Profile
 
+	if platform == "" {
+		platform = constants.PlatformMetal
+	}
+
 	prof.Output.Kind = profile.OutKindInstaller
 	prof.Output.OutFormat = profile.OutFormatRaw
 	prof.Arch = string(arch)
-	prof.Platform = constants.PlatformMetal // doesn't matter for installer output
+	prof.Platform = platform
 
 	if secureboot {
 		prof.SecureBoot = pointer.To(true)
@@ -431,25 +449,29 @@ func EnhanceFromSchematic(
 	}
 
 	// skip customizations for profile kinds which don't support it
-	//
-	// initramfs/kernel can't carry extra kernel args & META
-	// !secureboot (non-UKI) installer can't carry extra kernel args & META
 	// UKI installer has kernel args embedded in the UKI image
-	if !(prof.Output.Kind == profile.OutKindInitramfs || prof.Output.Kind == profile.OutKindKernel || (prof.Output.Kind == profile.OutKindInstaller && !prof.SecureBootEnabled())) {
+	switch prof.Output.Kind { //nolint:exhaustive
+	// skip customizations for initramfs/kernel completely since it cannot support extra kernel args & META
+	case profile.OutKindInitramfs, profile.OutKindKernel:
+	// installer and UKI support extra kernel args if either secure boot is enabled or the version supports unified installer
+	case profile.OutKindInstaller, profile.OutKindUKI:
+		if prof.SecureBootEnabled() || quirks.New(versionTag).SupportsUnifiedInstaller() {
+			prof.Customization.ExtraKernelArgs = append(prof.Customization.ExtraKernelArgs, schematic.Customization.ExtraKernelArgs...)
+		}
+	// all other output supports cmdline & META
+	default:
 		prof.Customization.ExtraKernelArgs = append(prof.Customization.ExtraKernelArgs, schematic.Customization.ExtraKernelArgs...)
 
-		if prof.Output.Kind != profile.OutKindInstaller {
-			prof.Customization.MetaContents = append(prof.Customization.MetaContents,
-				xslices.Map(schematic.Customization.Meta,
-					func(mv schematicpkg.MetaValue) meta.Value {
-						return meta.Value{
-							Key:   mv.Key,
-							Value: mv.Value,
-						}
-					},
-				)...,
-			)
-		}
+		prof.Customization.MetaContents = append(prof.Customization.MetaContents,
+			xslices.Map(schematic.Customization.Meta,
+				func(mv schematicpkg.MetaValue) meta.Value {
+					return meta.Value{
+						Key:   mv.Key,
+						Value: mv.Value,
+					}
+				},
+			)...,
+		)
 	}
 
 	prof.Version = versionTag
diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go
index ae7b31bf..d0517d36 100644
--- a/internal/profile/profile_test.go
+++ b/internal/profile/profile_test.go
@@ -23,6 +23,7 @@ import (
 	"github.com/siderolabs/image-factory/pkg/schematic"
 )
 
+//nolint:maintidx
 func TestParseFromPath(t *testing.T) {
 	t.Parallel()
 
@@ -173,6 +174,32 @@ func TestParseFromPath(t *testing.T) {
 				},
 			},
 		},
+		{
+			path:    "metal-installer-amd64.tar",
+			version: "v1.10.0",
+
+			expectedProfile: profile.Profile{
+				Platform: "metal",
+				Arch:     "amd64",
+				Output: profile.Output{
+					Kind:      profile.OutKindInstaller,
+					OutFormat: profile.OutFormatRaw,
+				},
+			},
+		},
+		{
+			path:    "digital-ocean-installer-amd64.tar",
+			version: "v1.10.0",
+
+			expectedProfile: profile.Profile{
+				Platform: "digital-ocean",
+				Arch:     "amd64",
+				Output: profile.Output{
+					Kind:      profile.OutKindInstaller,
+					OutFormat: profile.OutFormatRaw,
+				},
+			},
+		},
 		{
 			path:    "metal-arm64.raw.xz",
 			version: "v1.6.0",
@@ -899,12 +926,14 @@ func TestInstallerProfile(t *testing.T) {
 
 	for _, test := range []struct { //nolint:govet
 		arch       artifacts.Arch
+		platform   string
 		secureboot bool
 
 		expectedProfile profile.Profile
 	}{
 		{
 			arch:       artifacts.ArchAmd64,
+			platform:   "metal",
 			secureboot: false,
 
 			expectedProfile: profile.Profile{
@@ -918,6 +947,7 @@ func TestInstallerProfile(t *testing.T) {
 		},
 		{
 			arch:       artifacts.ArchArm64,
+			platform:   "metal",
 			secureboot: true,
 
 			expectedProfile: profile.Profile{
@@ -930,11 +960,25 @@ func TestInstallerProfile(t *testing.T) {
 				},
 			},
 		},
+		{
+			arch:       artifacts.ArchAmd64,
+			platform:   "hcloud",
+			secureboot: false,
+
+			expectedProfile: profile.Profile{
+				Platform: "hcloud",
+				Arch:     "amd64",
+				Output: profile.Output{
+					Kind:      profile.OutKindInstaller,
+					OutFormat: profile.OutFormatRaw,
+				},
+			},
+		},
 	} {
 		t.Run(fmt.Sprintf("%s-%v", string(test.arch), test.secureboot), func(t *testing.T) {
 			t.Parallel()
 
-			actualProfile := imageprofile.InstallerProfile(test.secureboot, test.arch)
+			actualProfile := imageprofile.InstallerProfile(test.secureboot, test.arch, test.platform)
 			require.Equal(t, test.expectedProfile, actualProfile)
 		})
 	}


Reviews:
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:20:05Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:22:07Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:22:47Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:30:11Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:32:47Z
user: login: smira
body: we also need to update https://github.com/siderolabs/image-factory/blob/e1d19dfbff074ccbec9751ed3c9a3eff8914bf47/internal/profile/profile.go#L433-L439

otherwise it should ignore extra kernel args for the installers with UKIs

and we need to add an integration test for it (generating installer image from a schematic with extra kernel args)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:35:45Z
user: login: frezbo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T11:38:49Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:19:22Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T09:40:30Z
user: login: smira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T09:42:48Z
user: login: smira
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T09:44:47Z

Review comments:
diff_hunk: @@ -467,7 +467,7 @@ func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string
 			ImageBaseURL: f.options.ExternalURL.JoinPath("image", schematicID, version),
 			PXEBaseURL:   f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
 
-			InstallerImage:           fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version),
+			InstallerImage:           fmt.Sprintf("%s/%s-installer/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version),
path: internal/frontend/http/ui.go
user: login: smira
body: 1. I think we should only prepend Platform for Talos 1.10+
2. we should update SecureBootInstallImage same way
created_at: 2025-03-19T10:20:05Z
author_association: MEMBER
diff_hunk: @@ -43,24 +44,39 @@ func getRequestedImage(p httprouter.Params) (requestedImage, error) {
 
 	switch image {
 	case "installer":
-		return requestedImage{secureboot: false}, nil
+		// defaults to metal image
+		return requestedImage{
+			platform:   "metal",
+			secureboot: false,
+		}, nil
 	case "installer-secureboot":
-		return requestedImage{secureboot: true}, nil
+		return requestedImage{
+			platform:   "metal",
+			secureboot: true,
+		}, nil
 	default:
+		// newer installer has `-installer` as suffix
+		// Eg: metal-installer, metal-installer-secureboot, digital-ocean-installer etc
+		// first try `-installer-secureboot` and then `-installer`
+		platform, ok := strings.CutSuffix(image, "-installer-secureboot")
+		if ok {
+			return requestedImage{platform: platform, secureboot: true}, nil
+		}
+
+		if platform, ok = strings.CutSuffix(image, "-installer"); ok {
+			return requestedImage{platform: platform, secureboot: false}, nil
+		}
+
 		return requestedImage{}, fmt.Errorf("invalid image: %s", image)
 	}
 }
 
 func (img requestedImage) Name() string {
path: internal/frontend/http/registry.go
user: login: smira
body: this method should match what was requested by the user, so if they asked for `installer`, it should return `installer` still, not `metal-installer`.

so I guess two options here:

* store the original name in the `requestedImage`
* store `platform` as empty if it was not specificed, and adjust the code accordingly
created_at: 2025-03-19T10:22:06Z
author_association: MEMBER
diff_hunk: @@ -636,148 +636,6 @@ func (f *Frontend) handleUIVersionDoc(_ context.Context, w http.ResponseWriter,
 	return getTemplates().ExecuteTemplate(w, "version-doc.html", version)
 }
 
-// handleUIVersions handles '/ui/schematic-config'.
-func (f *Frontend) handleUISchematicConfig(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error { //nolint:unused // to be removed
path: internal/frontend/http/ui.go
user: login: smira
body: nice catch
created_at: 2025-03-19T10:22:47Z
author_association: MEMBER
diff_hunk: @@ -237,11 +285,60 @@ func testDownloadFrontend(ctx context.Context, t *testing.T, baseURL string) {
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "metal-arm64-secureboot-uki.efi", "application/vnd.microsoft.portable-executable", sizePicker(talosVersion, "1.5", 114564272, "1.8", 82733744, "1.9", 82733744, "1.10", 86*MiB))
 				})
 
+				t.Run("nocloud UKI", func(t *testing.T) {
+					t.Parallel()
+
+					expected := "talos.platform=nocloud console=tty1 console=ttyS0 net.ifnames=0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512"
+
+					if quirks.New(talosVersion).SupportsSELinux() {
+						expected += " selinux=1"
+					}
+
+					downloadAssetAndValidateUKI(ctx, t, baseURL, emptySchematicID, talosVersion, "nocloud-amd64-uki.efi", ukiSpec{
+						expectedCmdline: expected,
+					})
+				})
+
 				t.Run("installer image", func(t *testing.T) {
 					t.Parallel()
 
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-amd64.tar", "application/x-tar", sizePicker(talosVersion, "1.5", 167482880, "1.8", 185155584, "1.9", 136*MiB, "1.10", 127*MiB))
 					downloadAssetAndMatchSize(ctx, t, baseURL, emptySchematicID, talosVersion, "installer-arm64.tar", "application/x-tar", sizePicker(talosVersion, "1.5", 222793728, "1.8", 170119168, "1.9", 126*MiB, "1.10", 116*MiB))
+
+					if quirks.New(talosVersion).SupportsUnifiedInstaller() {
path: internal/integration/download_test.go
user: login: smira
body: it _might_ be easier to flip this other way around, and do something like this:

```go
t.Run("legacy installer image", func(t *testing.T) {
    if quirks.New(talosVersion).SupportsUnifiedInstaller() {
         t.Skip()
    }

    downloadAssetAndMatchSize...
}

t.Run("uki installer image", func(t *testing.T) {
    if !quirks.New(talosVersion).SupportsUnifiedInstaller() {
         t.Skip()
    }

    downloadInstallerAndValidateUKI...
}
```
  
created_at: 2025-03-19T10:30:11Z
author_association: MEMBER
diff_hunk: @@ -165,6 +165,20 @@ func ParseFromPath(path, version string) (profile.Profile, error) {
 		}
 	}
 
+	// <platform>-installer-<arch>[-secureboot].tar
path: internal/profile/profile.go
user: login: smira
body: we should update `README.md` for the new API
created_at: 2025-03-19T10:32:47Z
author_association: MEMBER
diff_hunk: @@ -467,7 +467,7 @@ func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string
 			ImageBaseURL: f.options.ExternalURL.JoinPath("image", schematicID, version),
 			PXEBaseURL:   f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
 
-			InstallerImage:           fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version),
+			InstallerImage:           fmt.Sprintf("%s/%s-installer/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version),
path: internal/frontend/http/ui.go
user: login: frezbo
body: > I think we should only prepend Platform for Talos 1.10+

does it matter, since factory will still handle old `installer` and `installer-secureboot`
created_at: 2025-03-19T11:38:49Z
author_association: MEMBER
diff_hunk: @@ -467,7 +467,7 @@ func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string
 			ImageBaseURL: f.options.ExternalURL.JoinPath("image", schematicID, version),
 			PXEBaseURL:   f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
 
-			InstallerImage:           fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version),
+			InstallerImage:           fmt.Sprintf("%s/%s-installer/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version),
path: internal/frontend/http/ui.go
user: login: smira
body: > does it matter, since factory will still handle old installer and installer-secureboot

I think we shouldn't confuse users with a sudden change which wasn't there
created_at: 2025-03-19T12:19:21Z
author_association: MEMBER
diff_hunk: @@ -83,7 +83,8 @@ Supported image paths:
 * `initramfs-<arch>.xz` (e.g. `initramfs-amd64.xz`) - initramfs image (including system extensions if configured)
 * `<platform>-<arch>[-secureboot].iso` (e.g. `metal-amd64.iso`) - ISO image
 * `<platform>-<arch>[-secureboot]-uki.efi` (e.g. `metal-amd64-secureboot-uki.efi`) UEFI UKI image (Secure Boot compatible)
-* `installer-<arch>[-secureboot].tar` (e.g. `installer-amd64.tar`) is a custom Talos Linux installer image (including system extensions if configured)
+* `installer-<arch>[-secureboot].tar` (e.g. `installer-amd64.tar`) is a custom Talos Linux installer image for `metal` platform (including system extensions if configured)
path: README.md
user: login: smira
body: we should also update for the registry pulls around line 180
created_at: 2025-03-20T09:40:30Z
author_association: MEMBER
diff_hunk: @@ -35,6 +35,8 @@ func (f *Frontend) handleHealth(_ context.Context, _ http.ResponseWriter, _ *htt
 }
 
 type requestedImage struct {
+	imageName  string
path: internal/frontend/http/registry.go
user: login: smira
body: we seem to be filling this `imageName`, but later we don't use it in `Name()` function - so I'm a bit confused here - we either should use this or drop it?
created_at: 2025-03-20T09:42:48Z
author_association: MEMBER

PR comments:
user: login: frezbo
created_at: 2025-03-19T13:40:24Z
body: > we also need to update
> 
> https://github.com/siderolabs/image-factory/blob/e1d19dfbff074ccbec9751ed3c9a3eff8914bf47/internal/profile/profile.go#L433-L439
> 
> otherwise it should ignore extra kernel args for the installers with UKIs
> 
> and we need to add an integration test for it (generating installer image from a schematic with extra kernel args)

I didn't quite get this logic, seems we set extra kernel args for uki which seems correct :thinking: 
author_association: MEMBER
user: login: smira
created_at: 2025-03-19T13:56:22Z
body: > I didn't quite get this logic, seems we set extra kernel args for uki which seems correct 🤔

The problem is that it ignores ExtraKernelArgs for all installers except for SecureBoot ones, while it should actually use them for any installers >= 1.10 (in addition to SecureBoot).
author_association: MEMBER
user: login: frezbo
created_at: 2025-03-20T16:59:21Z
body: /m
author_association: MEMBER
user: login: frezbo
created_at: 2025-03-20T17:22:17Z
body: /m
author_association: MEMBER