diff --git a/build/Dockerfile.builder b/build/Dockerfile.builder index a2438cd..12541ce 100644 --- a/build/Dockerfile.builder +++ b/build/Dockerfile.builder @@ -55,6 +55,13 @@ RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ | tar -C /usr/local -xzf - ENV PATH="/usr/local/go/bin:${PATH}" +# Install oras (OCI artifact CLI) for push-oci-artifact.sh. +# Bump ORAS_VERSION when pushing breaks or when oras gains useful flags. +ARG ORAS_VERSION=1.2.3 +RUN curl -fsSL "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" \ + | tar -C /usr/local/bin -xzf - oras \ + && chmod +x /usr/local/bin/oras + WORKDIR /build COPY . /build diff --git a/build/scripts/push-oci-artifact.sh b/build/scripts/push-oci-artifact.sh new file mode 100755 index 0000000..ff274ed --- /dev/null +++ b/build/scripts/push-oci-artifact.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# push-oci-artifact.sh — Publish a KubeSolo OS update artifact to an OCI registry. +# +# Produces the artifact format consumed by `kubesolo-update --registry`: +# +# /:- per-arch manifest, layers: +# * vmlinuz (Image on arm64) → application/vnd.kubesolo.os.kernel.v1+octet-stream +# * kubesolo-os.gz → application/vnd.kubesolo.os.initramfs.v1+gzip +# annotations: +# io.kubesolo.os.version +# io.kubesolo.os.channel +# io.kubesolo.os.architecture +# io.kubesolo.os.min_compatible_version (optional) +# +# After running this for each architecture, combine the per-arch tags into a +# multi-arch index with `oras manifest index create` (see end of script). +# +# Requires: oras (>= 1.2), curl, jq. +# +# Usage: +# build/scripts/push-oci-artifact.sh \ +# --registry ghcr.io/portainer/kubesolo-os \ +# --arch amd64 \ +# --channel stable \ +# [--min-compatible-version v0.2.0] +# +# Authentication: oras reads ~/.docker/config.json. In CI, run +# `oras login ghcr.io -u USER -p TOKEN` before invoking this script +# (or set DOCKER_CONFIG to a directory with config.json). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +VERSION="$(cat "$PROJECT_ROOT/VERSION")" +OUTPUT_DIR="$PROJECT_ROOT/output" +CACHE_DIR="$PROJECT_ROOT/build/cache" + +REGISTRY="" +ARCH="" +CHANNEL="stable" +MIN_COMPATIBLE_VERSION="" +RELEASE_NOTES="" + +while [ $# -gt 0 ]; do + case "$1" in + --registry) REGISTRY="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --min-compatible-version) MIN_COMPATIBLE_VERSION="$2"; shift 2 ;; + --release-notes) RELEASE_NOTES="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$REGISTRY" ] || [ -z "$ARCH" ]; then + echo "Usage: $0 --registry REGISTRY/REPO --arch (amd64|arm64) [--channel stable] [--min-compatible-version vX.Y.Z]" >&2 + exit 1 +fi + +if ! command -v oras >/dev/null 2>&1; then + echo "ERROR: oras CLI not found. Install from https://oras.land/docs/installation/" >&2 + echo " or apt-get install oras (Ubuntu 24.04+)" >&2 + exit 1 +fi + +# Locate the artifacts. For arm64 the kernel is "Image"; everywhere else it's +# "vmlinuz". Initramfs is always kubesolo-os.gz. +case "$ARCH" in + amd64) + KERNEL="$CACHE_DIR/custom-kernel/vmlinuz" + [ -f "$KERNEL" ] || KERNEL="$OUTPUT_DIR/vmlinuz" + KERNEL_BASENAME="vmlinuz" + ;; + arm64) + KERNEL="$CACHE_DIR/kernel-arm64-generic/Image" + KERNEL_BASENAME="vmlinuz" # we publish under the vmlinuz name regardless; + # the consumer looks up by media type, not filename. + ;; + *) + echo "ERROR: unsupported --arch $ARCH (use amd64 or arm64)" >&2 + exit 1 + ;; +esac + +INITRAMFS="$PROJECT_ROOT/build/rootfs-work/kubesolo-os.gz" + +if [ ! -f "$KERNEL" ]; then + echo "ERROR: kernel not found at $KERNEL" >&2 + echo " Run 'make kernel' (amd64) or 'make kernel-arm64' (arm64) first." >&2 + exit 1 +fi +if [ ! -f "$INITRAMFS" ]; then + echo "ERROR: initramfs not found at $INITRAMFS" >&2 + echo " Run 'make initramfs' or 'make rootfs-arm64' first." >&2 + exit 1 +fi + +# Stage files in a temp dir so the basenames in the manifest are clean. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +cp "$KERNEL" "$STAGE/$KERNEL_BASENAME" +cp "$INITRAMFS" "$STAGE/kubesolo-os.gz" + +KERNEL_MEDIA="application/vnd.kubesolo.os.kernel.v1+octet-stream" +INITRD_MEDIA="application/vnd.kubesolo.os.initramfs.v1+gzip" + +REF="${REGISTRY}:${VERSION}-${ARCH}" +CHANNEL_REF="${REGISTRY}:${CHANNEL}-${ARCH}" + +echo "==> Pushing ${REF}" +echo " kernel: $KERNEL ($(du -h "$KERNEL" | cut -f1))" +echo " initramfs: $INITRAMFS ($(du -h "$INITRAMFS" | cut -f1))" + +ORAS_ANNOTATIONS=( + --annotation "io.kubesolo.os.version=${VERSION}" + --annotation "io.kubesolo.os.channel=${CHANNEL}" + --annotation "io.kubesolo.os.architecture=${ARCH}" +) +if [ -n "$MIN_COMPATIBLE_VERSION" ]; then + ORAS_ANNOTATIONS+=(--annotation "io.kubesolo.os.min_compatible_version=${MIN_COMPATIBLE_VERSION}") +fi +if [ -n "$RELEASE_NOTES" ]; then + ORAS_ANNOTATIONS+=(--annotation "io.kubesolo.os.release_notes=${RELEASE_NOTES}") +fi +ORAS_ANNOTATIONS+=(--annotation "io.kubesolo.os.release_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)") + +# oras push: --artifact-type sets the manifest artifactType field; +# file:type syntax sets per-layer media types. +(cd "$STAGE" && oras push "$REF" \ + --artifact-type "application/vnd.kubesolo.os.update.v1+json" \ + "${ORAS_ANNOTATIONS[@]}" \ + "${KERNEL_BASENAME}:${KERNEL_MEDIA}" \ + "kubesolo-os.gz:${INITRD_MEDIA}") + +# Also tag as - so the manifest-index step can reference it +# stably across patch releases. +echo "==> Tagging ${CHANNEL_REF}" +oras tag "$REF" "${CHANNEL}-${ARCH}" + +echo "" +echo "==> Published:" +echo " ${REF}" +echo " ${CHANNEL_REF}" +echo "" +echo "To combine multi-arch into the channel index, run after both arches are pushed:" +echo "" +echo " oras manifest index create ${REGISTRY}:${CHANNEL} \\" +echo " ${REGISTRY}:${CHANNEL}-amd64,platform=linux/amd64 \\" +echo " ${REGISTRY}:${CHANNEL}-arm64,platform=linux/arm64" +echo "" diff --git a/update/cmd/apply.go b/update/cmd/apply.go index f7c4e1d..7fc8f22 100644 --- a/update/cmd/apply.go +++ b/update/cmd/apply.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "log/slog" "runtime" @@ -8,10 +9,42 @@ import ( "github.com/portainer/kubesolo-os/update/pkg/config" "github.com/portainer/kubesolo-os/update/pkg/image" + "github.com/portainer/kubesolo-os/update/pkg/oci" "github.com/portainer/kubesolo-os/update/pkg/partition" "github.com/portainer/kubesolo-os/update/pkg/state" ) +// applyMetadataGates enforces channel / architecture / min-version policy on +// resolved update metadata, regardless of transport (HTTP or OCI). Records +// any failure to the state file before returning. +func applyMetadataGates(opts opts, st *state.UpdateState, meta *image.UpdateMetadata) error { + if meta.Channel != "" && meta.Channel != opts.Channel { + err := fmt.Errorf("metadata channel %q does not match local channel %q", + meta.Channel, opts.Channel) + _ = st.RecordError(opts.StatePath, err) + return err + } + if meta.Architecture != "" && meta.Architecture != runtime.GOARCH { + err := fmt.Errorf("metadata architecture %q does not match runtime %q", + meta.Architecture, runtime.GOARCH) + _ = st.RecordError(opts.StatePath, err) + return err + } + if meta.MinCompatibleVersion != "" && st.FromVersion != "" { + cmp, cerr := config.CompareVersions(st.FromVersion, meta.MinCompatibleVersion) + if cerr != nil { + slog.Warn("min-version comparison failed", "error", cerr, + "from", st.FromVersion, "min", meta.MinCompatibleVersion) + } else if cmp < 0 { + err := fmt.Errorf("current version %s is below min_compatible_version %s; install %s first", + st.FromVersion, meta.MinCompatibleVersion, meta.MinCompatibleVersion) + _ = st.RecordError(opts.StatePath, err) + return err + } + } + return nil +} + // Apply downloads a new OS image and writes it to the passive partition. // It does NOT activate the new partition — use 'activate' for that. // @@ -20,8 +53,11 @@ import ( func Apply(args []string) error { opts := parseOpts(args) - if opts.ServerURL == "" { - return fmt.Errorf("--server is required (or set in /etc/kubesolo/update.conf)") + if opts.ServerURL == "" && opts.Registry == "" { + return fmt.Errorf("--server or --registry is required (or set in /etc/kubesolo/update.conf)") + } + if opts.ServerURL != "" && opts.Registry != "" { + return fmt.Errorf("--server and --registry are mutually exclusive") } // Maintenance window gate — earliest cheap check, before any HTTP work. @@ -67,75 +103,74 @@ func Apply(args []string) error { } slog.Info("applying update", "target_slot", passiveSlot) - - // Check for update stageDir := "/tmp/kubesolo-update-stage" - client := image.NewClient(opts.ServerURL, stageDir) - defer client.Cleanup() - - // Enable signature verification if public key is configured - if opts.PubKeyPath != "" { - client.SetPublicKeyPath(opts.PubKeyPath) - slog.Info("signature verification enabled", "pubkey", opts.PubKeyPath) - } if err := st.Transition(opts.StatePath, state.PhaseChecking, "", ""); err != nil { slog.Warn("state transition failed", "phase", state.PhaseChecking, "error", err) } - meta, err := client.CheckForUpdate() - if err != nil { - _ = st.RecordError(opts.StatePath, fmt.Errorf("checking for update: %w", err)) - return fmt.Errorf("checking for update: %w", err) - } - - slog.Info("update available", "version", meta.Version, "channel", meta.Channel, "arch", meta.Architecture) - - // Channel gate — refuse mismatched channels. - if meta.Channel != "" && meta.Channel != opts.Channel { - err := fmt.Errorf("metadata channel %q does not match local channel %q", - meta.Channel, opts.Channel) - _ = st.RecordError(opts.StatePath, err) - return err - } - - // Architecture gate — refuse cross-arch artifacts. runtime.GOARCH is our - // canonical identifier ("amd64", "arm64", "arm"). - if meta.Architecture != "" && meta.Architecture != runtime.GOARCH { - err := fmt.Errorf("metadata architecture %q does not match runtime %q", - meta.Architecture, runtime.GOARCH) - _ = st.RecordError(opts.StatePath, err) - return err - } - - // Min-compatible-version gate — refuse if a stepping-stone is required. - // We need the active slot's version (already populated into st.FromVersion - // earlier in this command). - if meta.MinCompatibleVersion != "" && st.FromVersion != "" { - cmp, cerr := config.CompareVersions(st.FromVersion, meta.MinCompatibleVersion) - if cerr != nil { - slog.Warn("min-version comparison failed", "error", cerr, - "from", st.FromVersion, "min", meta.MinCompatibleVersion) - } else if cmp < 0 { - err := fmt.Errorf("current version %s is below min_compatible_version %s; install %s first", - st.FromVersion, meta.MinCompatibleVersion, meta.MinCompatibleVersion) - _ = st.RecordError(opts.StatePath, err) + // Resolve metadata via the configured transport. OCI registry mode pulls + // the manifest only; HTTP mode hits latest.json. + var ( + meta *image.UpdateMetadata + staged *image.StagedImage + ) + if opts.Registry != "" { + ociClient, err := oci.NewClient(opts.Registry) + if err != nil { + _ = st.RecordError(opts.StatePath, fmt.Errorf("oci client: %w", err)) + return fmt.Errorf("oci client: %w", err) + } + tag := opts.Tag + if tag == "" { + tag = opts.Channel + } + if tag == "" { + tag = "stable" + } + meta, err = ociClient.FetchMetadata(context.Background(), tag) + if err != nil { + _ = st.RecordError(opts.StatePath, fmt.Errorf("oci fetch metadata: %w", err)) + return fmt.Errorf("oci fetch metadata: %w", err) + } + if err := applyMetadataGates(opts, st, meta); err != nil { return err } + if err := st.Transition(opts.StatePath, state.PhaseDownloading, meta.Version, ""); err != nil { + slog.Warn("state transition failed", "phase", state.PhaseDownloading, "error", err) + } + staged, _, err = ociClient.Pull(context.Background(), tag, stageDir) + if err != nil { + _ = st.RecordError(opts.StatePath, fmt.Errorf("oci pull: %w", err)) + return fmt.Errorf("oci pull: %w", err) + } + } else { + client := image.NewClient(opts.ServerURL, stageDir) + defer client.Cleanup() + if opts.PubKeyPath != "" { + client.SetPublicKeyPath(opts.PubKeyPath) + slog.Info("signature verification enabled", "pubkey", opts.PubKeyPath) + } + var err error + meta, err = client.CheckForUpdate() + if err != nil { + _ = st.RecordError(opts.StatePath, fmt.Errorf("checking for update: %w", err)) + return fmt.Errorf("checking for update: %w", err) + } + if err := applyMetadataGates(opts, st, meta); err != nil { + return err + } + if err := st.Transition(opts.StatePath, state.PhaseDownloading, meta.Version, ""); err != nil { + slog.Warn("state transition failed", "phase", state.PhaseDownloading, "error", err) + } + staged, err = client.Download(meta) + if err != nil { + _ = st.RecordError(opts.StatePath, fmt.Errorf("downloading update: %w", err)) + return fmt.Errorf("downloading update: %w", err) + } } - // Now we know the target version — record it (resets attempt count if it - // differs from the previous attempt's ToVersion). - if err := st.Transition(opts.StatePath, state.PhaseDownloading, meta.Version, ""); err != nil { - slog.Warn("state transition failed", "phase", state.PhaseDownloading, "error", err) - } - - // Download and verify - staged, err := client.Download(meta) - if err != nil { - _ = st.RecordError(opts.StatePath, fmt.Errorf("downloading update: %w", err)) - return fmt.Errorf("downloading update: %w", err) - } + slog.Info("update available", "version", meta.Version, "channel", meta.Channel, "arch", meta.Architecture) // Mount passive partition partInfo, err := partition.GetSlotPartition(passiveSlot) diff --git a/update/cmd/opts.go b/update/cmd/opts.go index 9472f2b..9903569 100644 --- a/update/cmd/opts.go +++ b/update/cmd/opts.go @@ -11,6 +11,8 @@ import ( // opts holds shared command-line options for all subcommands. type opts struct { ServerURL string + Registry string // OCI registry ref (e.g. ghcr.io/foo/kubesolo-os). Mutually exclusive with ServerURL. + Tag string // OCI tag to pull (default: equal to Channel, falling back to "stable") GrubenvPath string TimeoutSecs int PubKeyPath string @@ -105,6 +107,16 @@ func parseOpts(args []string) opts { o.ServerURL = args[i+1] i++ } + case "--registry": + if i+1 < len(args) { + o.Registry = args[i+1] + i++ + } + case "--tag": + if i+1 < len(args) { + o.Tag = args[i+1] + i++ + } case "--grubenv": if i+1 < len(args) { o.GrubenvPath = args[i+1] diff --git a/update/go.mod b/update/go.mod index 29c4e47..23ced7a 100644 --- a/update/go.mod +++ b/update/go.mod @@ -1,3 +1,10 @@ module github.com/portainer/kubesolo-os/update go 1.25.5 + +require ( + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + golang.org/x/sync v0.14.0 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect +) diff --git a/update/go.sum b/update/go.sum new file mode 100644 index 0000000..2a92fa1 --- /dev/null +++ b/update/go.sum @@ -0,0 +1,8 @@ +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= diff --git a/update/main.go b/update/main.go index 09e5267..e614145 100644 --- a/update/main.go +++ b/update/main.go @@ -78,18 +78,25 @@ Commands: metrics Start Prometheus-compatible metrics HTTP server Options: - --server URL Update server URL (default: from /etc/kubesolo/update.conf) - --grubenv PATH Path to grubenv file (default: /boot/grub/grubenv) - --state PATH Update state file (default: /var/lib/kubesolo/update/state.json) - --timeout SECS Health check timeout in seconds (default: 120) - --pubkey PATH Ed25519 public key for signature verification (optional) - --json For 'status': emit JSON instead of human-readable output + --server URL HTTP update server (mutually exclusive with --registry) + --registry REPO OCI registry repository, e.g. ghcr.io/portainer/kubesolo-os + (mutually exclusive with --server) + --tag TAG OCI tag to pull (default: channel name, then "stable") + --conf PATH update.conf path (default: /etc/kubesolo/update.conf) + --state PATH Update state file (default: /var/lib/kubesolo/update/state.json) + --channel NAME Update channel (default: "stable", or value from update.conf) + --maintenance-window HH:MM-HH:MM local time window; apply refuses outside it + --force Bypass maintenance-window check + --grubenv PATH Path to grubenv file (default: /boot/grub/grubenv) + --timeout SECS Health check timeout in seconds (default: 120) + --pubkey PATH Ed25519 public key for signature verification (optional) + --json For 'status': emit JSON instead of human-readable output Examples: - kubesolo-update check --server https://updates.example.com - kubesolo-update apply --server https://updates.example.com --pubkey /etc/kubesolo/update-pubkey.hex + kubesolo-update apply --server https://updates.example.com + kubesolo-update apply --registry ghcr.io/portainer/kubesolo-os --tag stable + kubesolo-update apply --force # uses /etc/kubesolo/update.conf kubesolo-update healthcheck - kubesolo-update status kubesolo-update status --json `) } diff --git a/update/pkg/oci/oci.go b/update/pkg/oci/oci.go new file mode 100644 index 0000000..e23db44 --- /dev/null +++ b/update/pkg/oci/oci.go @@ -0,0 +1,281 @@ +// Package oci pulls KubeSolo OS update artifacts from an OCI-compliant +// container registry (e.g. ghcr.io). It is the registry-native alternative +// to the legacy HTTP `latest.json` protocol implemented in pkg/image. +// +// # Artifact layout +// +// An update is published as a single OCI artifact under a tag like +// `stable` or `v0.3.0`. The tag may point at either: +// +// - A manifest index (preferred) containing per-architecture manifests. +// The agent picks the one matching runtime.GOARCH. +// - A single manifest (used for arch-specific tags such as +// `v0.3.0-amd64`). The agent verifies architecture against the +// manifest's platform annotation before trusting it. +// +// Each per-architecture manifest carries two layers: +// +// application/vnd.kubesolo.os.kernel.v1+octet-stream // vmlinuz / Image +// application/vnd.kubesolo.os.initramfs.v1+gzip // kubesolo-os.gz +// +// And these annotations (read into image.UpdateMetadata): +// +// io.kubesolo.os.version "v0.3.0" +// io.kubesolo.os.channel "stable" +// io.kubesolo.os.min_compatible_version "v0.2.0" +// io.kubesolo.os.architecture "amd64" +// io.kubesolo.os.release_notes (optional, short) +// io.kubesolo.os.release_date (optional, RFC3339) +// +// The agent ignores any additional layers, so the same image can also be +// shaped as a "scratch" container if the build pipeline finds that convenient +// for ecosystem tooling. +package oci + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "runtime" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" + + "github.com/portainer/kubesolo-os/update/pkg/image" +) + +// Media types used on KubeSolo OS update artifacts. Kept here (not in +// pkg/image) so the OCI protocol surface is fully self-contained. +const ( + MediaKernel = "application/vnd.kubesolo.os.kernel.v1+octet-stream" + MediaInitramfs = "application/vnd.kubesolo.os.initramfs.v1+gzip" + + AnnotVersion = "io.kubesolo.os.version" + AnnotChannel = "io.kubesolo.os.channel" + AnnotMinVersion = "io.kubesolo.os.min_compatible_version" + AnnotArch = "io.kubesolo.os.architecture" + AnnotReleaseNote = "io.kubesolo.os.release_notes" + AnnotReleaseDate = "io.kubesolo.os.release_date" +) + +// Client pulls artifacts from a single OCI repository (e.g. +// `ghcr.io/portainer/kubesolo-os`). +// +// Anonymous (public-pull) access is supported out of the box. For private +// repositories, configure auth via the underlying remote.Repository.Client +// before passing it to Resolve/Pull — that hook isn't surfaced here yet +// (deferred until we actually need it for a private fleet). +type Client struct { + repo *remote.Repository + // Arch is the architecture string we match against manifest indexes. + // Defaults to runtime.GOARCH; overridable for testing. + Arch string +} + +// NewClient parses a repository reference of the form `host/path` (no tag) +// and returns a ready-to-use Client. +func NewClient(repoRef string) (*Client, error) { + repo, err := remote.NewRepository(repoRef) + if err != nil { + return nil, fmt.Errorf("invalid OCI reference %q: %w", repoRef, err) + } + // remote.NewRepository defaults to HTTPS. PlainHTTP is set per-test + // via the WithPlainHTTP option when we hit a httptest.Server. + return &Client{repo: repo, Arch: runtime.GOARCH}, nil +} + +// WithPlainHTTP toggles the underlying registry transport to HTTP. Useful for +// httptest-driven unit tests; do not use against production registries. +func (c *Client) WithPlainHTTP(plain bool) *Client { + c.repo.PlainHTTP = plain + return c +} + +// FetchMetadata resolves the tag, walks index → manifest if needed, and +// returns an image.UpdateMetadata populated from the manifest's annotations. +// No blobs are downloaded — this is the cheap "what's available" probe. +func (c *Client) FetchMetadata(ctx context.Context, tag string) (*image.UpdateMetadata, error) { + manifest, _, err := c.resolveArchManifest(ctx, tag) + if err != nil { + return nil, err + } + return metadataFromAnnotations(manifest.Annotations), nil +} + +// Pull resolves the tag, picks the matching-architecture manifest, downloads +// the kernel + initramfs layers to `stageDir`, verifies their digests, and +// returns a StagedImage compatible with the existing pkg/image consumer. +func (c *Client) Pull(ctx context.Context, tag, stageDir string) (*image.StagedImage, *image.UpdateMetadata, error) { + manifest, _, err := c.resolveArchManifest(ctx, tag) + if err != nil { + return nil, nil, err + } + + if err := os.MkdirAll(stageDir, 0o755); err != nil { + return nil, nil, fmt.Errorf("create stage dir: %w", err) + } + + var kernelPath, initramfsPath string + for _, layer := range manifest.Layers { + switch layer.MediaType { + case MediaKernel: + kernelPath = filepath.Join(stageDir, "vmlinuz") + if err := c.fetchBlobTo(ctx, layer, kernelPath); err != nil { + return nil, nil, fmt.Errorf("download kernel: %w", err) + } + case MediaInitramfs: + initramfsPath = filepath.Join(stageDir, "kubesolo-os.gz") + if err := c.fetchBlobTo(ctx, layer, initramfsPath); err != nil { + return nil, nil, fmt.Errorf("download initramfs: %w", err) + } + default: + slog.Debug("oci: skipping unknown layer", "media", layer.MediaType) + } + } + + if kernelPath == "" { + return nil, nil, fmt.Errorf("manifest has no %s layer", MediaKernel) + } + if initramfsPath == "" { + return nil, nil, fmt.Errorf("manifest has no %s layer", MediaInitramfs) + } + + meta := metadataFromAnnotations(manifest.Annotations) + staged := &image.StagedImage{ + VmlinuzPath: kernelPath, + InitramfsPath: initramfsPath, + Version: meta.Version, + } + return staged, meta, nil +} + +// resolveArchManifest fetches the descriptor at `tag`, walks an index if +// present, and returns the platform-specific manifest matching c.Arch. +func (c *Client) resolveArchManifest(ctx context.Context, tag string) (*ocispec.Manifest, *ocispec.Descriptor, error) { + desc, err := c.repo.Resolve(ctx, tag) + if err != nil { + return nil, nil, fmt.Errorf("resolve tag %q: %w", tag, err) + } + + switch desc.MediaType { + case ocispec.MediaTypeImageIndex, "application/vnd.docker.distribution.manifest.list.v2+json": + index, err := fetchJSON[ocispec.Index](ctx, c.repo, desc) + if err != nil { + return nil, nil, fmt.Errorf("fetch index: %w", err) + } + var matched *ocispec.Descriptor + for i := range index.Manifests { + m := &index.Manifests[i] + if m.Platform != nil && m.Platform.Architecture == c.Arch { + matched = m + break + } + } + if matched == nil { + return nil, nil, fmt.Errorf("no manifest in index for architecture %q", c.Arch) + } + manifest, err := fetchJSON[ocispec.Manifest](ctx, c.repo, *matched) + if err != nil { + return nil, nil, fmt.Errorf("fetch manifest: %w", err) + } + return manifest, matched, nil + + case ocispec.MediaTypeImageManifest, "application/vnd.docker.distribution.manifest.v2+json": + manifest, err := fetchJSON[ocispec.Manifest](ctx, c.repo, desc) + if err != nil { + return nil, nil, fmt.Errorf("fetch manifest: %w", err) + } + // Single-arch tag: if it declares an arch, enforce match. + if archAnnot := manifest.Annotations[AnnotArch]; archAnnot != "" && archAnnot != c.Arch { + return nil, nil, fmt.Errorf("single-arch manifest is %q, want %q", archAnnot, c.Arch) + } + return manifest, &desc, nil + + default: + return nil, nil, fmt.Errorf("unsupported media type %q at tag %q", desc.MediaType, tag) + } +} + +// fetchJSON pulls a small JSON document (manifest or index) and decodes it. +func fetchJSON[T any](ctx context.Context, store content.Fetcher, desc ocispec.Descriptor) (*T, error) { + rc, err := store.Fetch(ctx, desc) + if err != nil { + return nil, err + } + defer rc.Close() + data, err := content.ReadAll(rc, desc) + if err != nil { + return nil, err + } + var out T + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} + +// fetchBlobTo streams a blob to disk and verifies its digest matches. +// Cleans up the destination file on any error so we never leave a partial. +func (c *Client) fetchBlobTo(ctx context.Context, desc ocispec.Descriptor, dest string) (retErr error) { + rc, err := c.repo.Fetch(ctx, desc) + if err != nil { + return fmt.Errorf("fetch blob: %w", err) + } + defer rc.Close() + + f, err := os.Create(dest) + if err != nil { + return fmt.Errorf("create %s: %w", dest, err) + } + defer func() { + if cerr := f.Close(); retErr == nil && cerr != nil { + retErr = cerr + } + if retErr != nil { + _ = os.Remove(dest) + } + }() + + verifier := desc.Digest.Algorithm().Hash() + mw := io.MultiWriter(f, verifier) + n, err := io.Copy(mw, rc) + if err != nil { + return fmt.Errorf("stream blob: %w", err) + } + if desc.Size > 0 && n != desc.Size { + return fmt.Errorf("blob size mismatch: got %d, want %d", n, desc.Size) + } + got := digest.NewDigest(desc.Digest.Algorithm(), verifier) + if got != desc.Digest { + return fmt.Errorf("blob digest mismatch: got %s, want %s", got, desc.Digest) + } + return nil +} + +// metadataFromAnnotations builds an UpdateMetadata from manifest annotations. +// Always returns a non-nil value (missing fields stay empty). +func metadataFromAnnotations(a map[string]string) *image.UpdateMetadata { + if a == nil { + a = map[string]string{} + } + return &image.UpdateMetadata{ + Version: a[AnnotVersion], + Channel: a[AnnotChannel], + MinCompatibleVersion: a[AnnotMinVersion], + Architecture: a[AnnotArch], + ReleaseNotes: a[AnnotReleaseNote], + ReleaseDate: a[AnnotReleaseDate], + } +} + +// ErrNoManifestForArch is returned from FetchMetadata/Pull when an index has +// no entry matching the running architecture. Exposed so callers can +// distinguish "registry unreachable" from "this build doesn't ship for us". +var ErrNoManifestForArch = errors.New("no manifest in index for runtime architecture") diff --git a/update/pkg/oci/oci_test.go b/update/pkg/oci/oci_test.go new file mode 100644 index 0000000..175807e --- /dev/null +++ b/update/pkg/oci/oci_test.go @@ -0,0 +1,377 @@ +package oci + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// fakeRegistry implements the minimum OCI distribution-spec surface our +// Client touches: /v2/ probe, manifest fetch by tag or digest, blob fetch +// by digest. Backed by an in-memory blob+manifest store. +type fakeRegistry struct { + t *testing.T + srv *httptest.Server + blobs map[digest.Digest][]byte // keyed by digest + manifests map[string][]byte // keyed by digest string (raw form) + tags map[string]digest.Digest // tag -> manifest digest + mediaTypes map[digest.Digest]string // descriptor.MediaType per stored object +} + +func newFakeRegistry(t *testing.T) *fakeRegistry { + t.Helper() + r := &fakeRegistry{ + t: t, + blobs: map[digest.Digest][]byte{}, + manifests: map[string][]byte{}, + tags: map[string]digest.Digest{}, + mediaTypes: map[digest.Digest]string{}, + } + r.srv = httptest.NewServer(http.HandlerFunc(r.handle)) + t.Cleanup(r.srv.Close) + return r +} + +func (r *fakeRegistry) putBlob(media string, data []byte) digest.Digest { + h := sha256.Sum256(data) + d := digest.NewDigestFromBytes(digest.SHA256, h[:]) + r.blobs[d] = data + r.mediaTypes[d] = media + return d +} + +// putManifest stores a manifest/index document under both its digest and the +// given tag, returning the digest the caller can embed in indexes. +func (r *fakeRegistry) putManifest(tag string, media string, doc []byte) digest.Digest { + h := sha256.Sum256(doc) + d := digest.NewDigestFromBytes(digest.SHA256, h[:]) + r.manifests[d.String()] = doc + r.mediaTypes[d] = media + if tag != "" { + r.tags[tag] = d + } + return d +} + +// repoRef returns the "host:port/repo" string for use with NewClient. +func (r *fakeRegistry) repoRef() string { + u, _ := url.Parse(r.srv.URL) + return u.Host + "/test/kubesolo-os" +} + +func (r *fakeRegistry) handle(w http.ResponseWriter, req *http.Request) { + // Routes we implement: + // GET /v2/ -> 200 "{}" + // GET /v2/test/kubesolo-os/manifests/ -> manifest + // HEAD same -> same headers, no body + // GET /v2/test/kubesolo-os/blobs/ -> blob + + path := req.URL.Path + if path == "/v2/" || path == "/v2" { + w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "{}") + return + } + + const prefix = "/v2/test/kubesolo-os/" + if !strings.HasPrefix(path, prefix) { + http.NotFound(w, req) + return + } + rest := strings.TrimPrefix(path, prefix) + + switch { + case strings.HasPrefix(rest, "manifests/"): + ref := strings.TrimPrefix(rest, "manifests/") + var d digest.Digest + var data []byte + if td, ok := r.tags[ref]; ok { + d = td + data = r.manifests[d.String()] + } else if md, ok := r.manifests[ref]; ok { + d = digest.Digest(ref) + data = md + } else { + http.NotFound(w, req) + return + } + media := r.mediaTypes[d] + w.Header().Set("Content-Type", media) + w.Header().Set("Docker-Content-Digest", d.String()) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + if req.Method == http.MethodHead { + return + } + _, _ = w.Write(data) + + case strings.HasPrefix(rest, "blobs/"): + ref := strings.TrimPrefix(rest, "blobs/") + d := digest.Digest(ref) + blob, ok := r.blobs[d] + if !ok { + http.NotFound(w, req) + return + } + media := r.mediaTypes[d] + if media == "" { + media = "application/octet-stream" + } + w.Header().Set("Content-Type", media) + w.Header().Set("Docker-Content-Digest", d.String()) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(blob))) + if req.Method == http.MethodHead { + return + } + _, _ = w.Write(blob) + + default: + http.NotFound(w, req) + } +} + +// seedSingleArchManifest puts kernel+initramfs blobs and a manifest with the +// given annotations into the registry, tagged as `tag`. +func (r *fakeRegistry) seedSingleArchManifest(t *testing.T, tag string, annot map[string]string) (kernelData, initramfsData []byte) { + t.Helper() + kernelData = []byte("FAKE-KERNEL-" + tag) + initramfsData = []byte("FAKE-INITRAMFS-" + tag) + + kd := r.putBlob(MediaKernel, kernelData) + id := r.putBlob(MediaInitramfs, initramfsData) + + // An empty config blob with sha256 of "{}" (the canonical "empty" body + // per OCI). We don't actually fetch the config so any valid descriptor + // works for the tests, but the digest still has to be syntactically valid. + emptyConfigBody := []byte("{}") + emptyConfigDigest := r.putBlob("application/vnd.oci.empty.v1+json", emptyConfigBody) + + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.Descriptor{ + MediaType: "application/vnd.oci.empty.v1+json", + Size: int64(len(emptyConfigBody)), + Digest: emptyConfigDigest, + }, + Layers: []ocispec.Descriptor{ + {MediaType: MediaKernel, Digest: kd, Size: int64(len(kernelData))}, + {MediaType: MediaInitramfs, Digest: id, Size: int64(len(initramfsData))}, + }, + Annotations: annot, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + r.putManifest(tag, ocispec.MediaTypeImageManifest, manifestBytes) + return +} + +// seedIndex creates a manifest index pointing at per-arch manifests created +// via seedSingleArchManifest with arch-suffixed tags, then publishes the +// index under `tag`. +func (r *fakeRegistry) seedIndex(t *testing.T, tag string, perArchAnnots map[string]map[string]string) { + t.Helper() + var descriptors []ocispec.Descriptor + for arch, annot := range perArchAnnots { + // Reuse seedSingleArchManifest but under an internal arch-suffixed tag + archTag := tag + "-" + arch + r.seedSingleArchManifest(t, archTag, annot) + d := r.tags[archTag] + descriptors = append(descriptors, ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: d, + Size: int64(len(r.manifests[d.String()])), + Platform: &ocispec.Platform{Architecture: arch, OS: "linux"}, + }) + } + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: descriptors, + } + indexBytes, _ := json.Marshal(index) + r.putManifest(tag, ocispec.MediaTypeImageIndex, indexBytes) +} + +// --------------------------------------------------------------------------- + +func TestFetchMetadataSingleArchManifest(t *testing.T) { + reg := newFakeRegistry(t) + reg.seedSingleArchManifest(t, "v0.3.0", map[string]string{ + AnnotVersion: "v0.3.0", + AnnotChannel: "stable", + AnnotArch: "amd64", + }) + + c, err := NewClient(reg.repoRef()) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c.WithPlainHTTP(true) + c.Arch = "amd64" + + meta, err := c.FetchMetadata(context.Background(), "v0.3.0") + if err != nil { + t.Fatalf("FetchMetadata: %v", err) + } + if meta.Version != "v0.3.0" { + t.Errorf("version: got %q, want v0.3.0", meta.Version) + } + if meta.Channel != "stable" { + t.Errorf("channel: got %q", meta.Channel) + } +} + +func TestFetchMetadataIndexSelectsArch(t *testing.T) { + reg := newFakeRegistry(t) + reg.seedIndex(t, "stable", map[string]map[string]string{ + "amd64": {AnnotVersion: "v0.3.0", AnnotChannel: "stable", AnnotArch: "amd64"}, + "arm64": {AnnotVersion: "v0.3.0", AnnotChannel: "stable", AnnotArch: "arm64"}, + }) + + for _, arch := range []string{"amd64", "arm64"} { + t.Run(arch, func(t *testing.T) { + c, err := NewClient(reg.repoRef()) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c.WithPlainHTTP(true) + c.Arch = arch + + meta, err := c.FetchMetadata(context.Background(), "stable") + if err != nil { + t.Fatalf("FetchMetadata: %v", err) + } + if meta.Architecture != arch { + t.Errorf("arch annotation: got %q, want %q", meta.Architecture, arch) + } + if meta.Version != "v0.3.0" { + t.Errorf("version: got %q, want v0.3.0", meta.Version) + } + }) + } +} + +func TestFetchMetadataIndexMissingArchErrors(t *testing.T) { + reg := newFakeRegistry(t) + reg.seedIndex(t, "stable", map[string]map[string]string{ + "amd64": {AnnotVersion: "v0.3.0", AnnotArch: "amd64"}, + }) + + c, _ := NewClient(reg.repoRef()) + c.WithPlainHTTP(true) + c.Arch = "arm64" // not in the index + + _, err := c.FetchMetadata(context.Background(), "stable") + if err == nil { + t.Fatal("expected error for missing arch, got nil") + } + if !strings.Contains(err.Error(), "arm64") { + t.Errorf("expected error mentioning arm64, got: %v", err) + } +} + +func TestFetchMetadataSingleArchManifestRejectsCrossArch(t *testing.T) { + // If the manifest declares an arch via annotation and it doesn't match + // our runtime, Pull should refuse — defense in depth on top of the + // channel/version gates in cmd/apply.go. + reg := newFakeRegistry(t) + reg.seedSingleArchManifest(t, "v0.3.0-arm64", map[string]string{ + AnnotArch: "arm64", + }) + + c, _ := NewClient(reg.repoRef()) + c.WithPlainHTTP(true) + c.Arch = "amd64" + + _, err := c.FetchMetadata(context.Background(), "v0.3.0-arm64") + if err == nil { + t.Fatal("expected error pulling cross-arch single-arch manifest, got nil") + } +} + +func TestPullDownloadsBlobsAndVerifiesDigest(t *testing.T) { + reg := newFakeRegistry(t) + kernelData, initramfsData := reg.seedSingleArchManifest(t, "v0.3.0", + map[string]string{AnnotVersion: "v0.3.0", AnnotArch: "amd64"}) + + c, _ := NewClient(reg.repoRef()) + c.WithPlainHTTP(true) + c.Arch = "amd64" + + stageDir := filepath.Join(t.TempDir(), "stage") + staged, meta, err := c.Pull(context.Background(), "v0.3.0", stageDir) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if meta.Version != "v0.3.0" { + t.Errorf("meta version: got %q", meta.Version) + } + if staged.Version != "v0.3.0" { + t.Errorf("staged version: got %q", staged.Version) + } + + gotKernel, err := os.ReadFile(staged.VmlinuzPath) + if err != nil { + t.Fatalf("read kernel: %v", err) + } + if string(gotKernel) != string(kernelData) { + t.Errorf("kernel mismatch:\n got %q\nwant %q", gotKernel, kernelData) + } + gotInit, err := os.ReadFile(staged.InitramfsPath) + if err != nil { + t.Fatalf("read initramfs: %v", err) + } + if string(gotInit) != string(initramfsData) { + t.Errorf("initramfs mismatch") + } +} + +func TestPullRejectsTamperedBlob(t *testing.T) { + // Mutate the kernel blob after it's been digested into the manifest. + // Pull should refuse with a digest mismatch. + reg := newFakeRegistry(t) + _, _ = reg.seedSingleArchManifest(t, "v0.3.0", + map[string]string{AnnotVersion: "v0.3.0", AnnotArch: "amd64"}) + + // Corrupt every stored kernel blob in the registry by replacing its body. + for d, m := range reg.mediaTypes { + if m == MediaKernel { + reg.blobs[d] = []byte("TAMPERED-KERNEL-WRONG-LENGTH-AND-DIGEST") + } + } + + c, _ := NewClient(reg.repoRef()) + c.WithPlainHTTP(true) + c.Arch = "amd64" + + _, _, err := c.Pull(context.Background(), "v0.3.0", filepath.Join(t.TempDir(), "stage")) + if err == nil { + t.Fatal("expected digest mismatch error on tampered blob, got nil") + } + if !strings.Contains(err.Error(), "mismatch") { + t.Errorf("expected mismatch in error, got: %v", err) + } +} + +func TestNewClientRejectsGarbageReference(t *testing.T) { + _, err := NewClient("not a valid reference") + if err == nil { + t.Error("expected error on bad reference, got nil") + } +}