feat(update): OCI registry distribution for update artifacts
Some checks failed
ARM64 Build / Build generic ARM64 disk image (push) Failing after 4s
CI / Go Tests (push) Successful in 1m28s
CI / Shellcheck (push) Successful in 45s
CI / Build Go Binaries (amd64, linux, linux-amd64) (push) Successful in 1m17s
CI / Build Go Binaries (arm64, linux, linux-arm64) (push) Successful in 1m13s
Some checks failed
ARM64 Build / Build generic ARM64 disk image (push) Failing after 4s
CI / Go Tests (push) Successful in 1m28s
CI / Shellcheck (push) Successful in 45s
CI / Build Go Binaries (amd64, linux, linux-amd64) (push) Successful in 1m17s
CI / Build Go Binaries (arm64, linux, linux-arm64) (push) Successful in 1m13s
Phase 7 of v0.3. The update agent can now pull update artifacts from any
OCI-compliant registry (ghcr.io, quay.io, harbor, zot, etc.) alongside the
existing HTTP latest.json protocol. Multi-arch artifacts are resolved
through manifest indexes so the same tag (e.g. "stable") yields the
right kernel + initramfs for runtime.GOARCH.
New package update/pkg/oci (~280 LOC, 9 tests):
- Client wraps oras-go/v2's remote.Repository. NewClient parses
host/path references; WithPlainHTTP toggle for httptest.
- FetchMetadata resolves a tag and returns image.UpdateMetadata from
manifest annotations (io.kubesolo.os.{version,channel,architecture,
min_compatible_version,release_notes,release_date}). No blobs fetched.
- Pull resolves the tag, walks index → arch-specific manifest, downloads
kernel + initramfs layers identified by their custom media types
(application/vnd.kubesolo.os.kernel.v1+octet-stream and
application/vnd.kubesolo.os.initramfs.v1+gzip), verifies their digests
against the manifest, returns the same image.StagedImage shape the
HTTP client produces.
- Cross-arch single-arch manifests are refused via the AnnotArch check
(defense in depth on top of the gates in cmd/apply.go).
- Tests use a hand-rolled httptest registry implementing /v2/probe,
manifest fetch by tag-or-digest, blob fetch by digest. Cover index
arch-selection, single-arch manifests, missing-arch error, tampered
blob rejection (digest mismatch), and reference parsing.
Dependencies added: oras.land/oras-go/v2 v2.6.0 plus its transitive
opencontainers/{go-digest,image-spec} and golang.org/x/sync. All small
and well-maintained; total binary size impact is negligible relative to
the existing 6.1 MB update agent.
cmd/apply.go:
- New --registry and --tag flags; mutually exclusive with --server.
- applyMetadataGates extracted as a helper, called from both transports
so channel/arch/min-version policy is enforced identically regardless
of how metadata was fetched.
- State transitions identical to the HTTP path: Checking → Downloading
→ Staged, with RecordError on any failure.
cmd/opts.go: --registry, --tag CLI flags. update.conf "server=" already
accepts either an HTTP URL or an OCI ref; the agent distinguishes by
which CLI/conf field carries the value.
build/scripts/push-oci-artifact.sh: new tool that publishes a single-arch
update artifact via the oras CLI with our custom media types and
annotations. After running for each arch, the operator composes the
multi-arch index with `oras manifest index create`. Documented inline.
build/Dockerfile.builder: installs oras 1.2.3 from upstream releases so
the Gitea Actions build container can run the new script.
Signature verification on the OCI path is intentionally deferred — the
artifact format is digest-verified end-to-end via oras-go, and Ed25519
signature consumption via OCI referrers is a follow-up. Plain HTTP
clients keep their existing signature path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,13 @@ RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
|
|||||||
| tar -C /usr/local -xzf -
|
| tar -C /usr/local -xzf -
|
||||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
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
|
WORKDIR /build
|
||||||
COPY . /build
|
COPY . /build
|
||||||
|
|
||||||
|
|||||||
150
build/scripts/push-oci-artifact.sh
Executable file
150
build/scripts/push-oci-artifact.sh
Executable file
@@ -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`:
|
||||||
|
#
|
||||||
|
# <registry>/<repo>:<version>-<arch> 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 <channel>-<arch> 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 ""
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -8,10 +9,42 @@ import (
|
|||||||
|
|
||||||
"github.com/portainer/kubesolo-os/update/pkg/config"
|
"github.com/portainer/kubesolo-os/update/pkg/config"
|
||||||
"github.com/portainer/kubesolo-os/update/pkg/image"
|
"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/partition"
|
||||||
"github.com/portainer/kubesolo-os/update/pkg/state"
|
"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.
|
// Apply downloads a new OS image and writes it to the passive partition.
|
||||||
// It does NOT activate the new partition — use 'activate' for that.
|
// It does NOT activate the new partition — use 'activate' for that.
|
||||||
//
|
//
|
||||||
@@ -20,8 +53,11 @@ import (
|
|||||||
func Apply(args []string) error {
|
func Apply(args []string) error {
|
||||||
opts := parseOpts(args)
|
opts := parseOpts(args)
|
||||||
|
|
||||||
if opts.ServerURL == "" {
|
if opts.ServerURL == "" && opts.Registry == "" {
|
||||||
return fmt.Errorf("--server is required (or set in /etc/kubesolo/update.conf)")
|
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.
|
// 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)
|
slog.Info("applying update", "target_slot", passiveSlot)
|
||||||
|
|
||||||
// Check for update
|
|
||||||
stageDir := "/tmp/kubesolo-update-stage"
|
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 {
|
if err := st.Transition(opts.StatePath, state.PhaseChecking, "", ""); err != nil {
|
||||||
slog.Warn("state transition failed", "phase", state.PhaseChecking, "error", err)
|
slog.Warn("state transition failed", "phase", state.PhaseChecking, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta, err := client.CheckForUpdate()
|
// Resolve metadata via the configured transport. OCI registry mode pulls
|
||||||
if err != nil {
|
// the manifest only; HTTP mode hits latest.json.
|
||||||
_ = st.RecordError(opts.StatePath, fmt.Errorf("checking for update: %w", err))
|
var (
|
||||||
return fmt.Errorf("checking for update: %w", err)
|
meta *image.UpdateMetadata
|
||||||
}
|
staged *image.StagedImage
|
||||||
|
)
|
||||||
slog.Info("update available", "version", meta.Version, "channel", meta.Channel, "arch", meta.Architecture)
|
if opts.Registry != "" {
|
||||||
|
ociClient, err := oci.NewClient(opts.Registry)
|
||||||
// Channel gate — refuse mismatched channels.
|
if err != nil {
|
||||||
if meta.Channel != "" && meta.Channel != opts.Channel {
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("oci client: %w", err))
|
||||||
err := fmt.Errorf("metadata channel %q does not match local channel %q",
|
return fmt.Errorf("oci client: %w", err)
|
||||||
meta.Channel, opts.Channel)
|
}
|
||||||
_ = st.RecordError(opts.StatePath, err)
|
tag := opts.Tag
|
||||||
return err
|
if tag == "" {
|
||||||
}
|
tag = opts.Channel
|
||||||
|
}
|
||||||
// Architecture gate — refuse cross-arch artifacts. runtime.GOARCH is our
|
if tag == "" {
|
||||||
// canonical identifier ("amd64", "arm64", "arm").
|
tag = "stable"
|
||||||
if meta.Architecture != "" && meta.Architecture != runtime.GOARCH {
|
}
|
||||||
err := fmt.Errorf("metadata architecture %q does not match runtime %q",
|
meta, err = ociClient.FetchMetadata(context.Background(), tag)
|
||||||
meta.Architecture, runtime.GOARCH)
|
if err != nil {
|
||||||
_ = st.RecordError(opts.StatePath, err)
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("oci fetch metadata: %w", err))
|
||||||
return err
|
return fmt.Errorf("oci fetch metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := applyMetadataGates(opts, st, meta); err != nil {
|
||||||
// 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)
|
|
||||||
return err
|
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
|
slog.Info("update available", "version", meta.Version, "channel", meta.Channel, "arch", meta.Architecture)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount passive partition
|
// Mount passive partition
|
||||||
partInfo, err := partition.GetSlotPartition(passiveSlot)
|
partInfo, err := partition.GetSlotPartition(passiveSlot)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
// opts holds shared command-line options for all subcommands.
|
// opts holds shared command-line options for all subcommands.
|
||||||
type opts struct {
|
type opts struct {
|
||||||
ServerURL string
|
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
|
GrubenvPath string
|
||||||
TimeoutSecs int
|
TimeoutSecs int
|
||||||
PubKeyPath string
|
PubKeyPath string
|
||||||
@@ -105,6 +107,16 @@ func parseOpts(args []string) opts {
|
|||||||
o.ServerURL = args[i+1]
|
o.ServerURL = args[i+1]
|
||||||
i++
|
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":
|
case "--grubenv":
|
||||||
if i+1 < len(args) {
|
if i+1 < len(args) {
|
||||||
o.GrubenvPath = args[i+1]
|
o.GrubenvPath = args[i+1]
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
module github.com/portainer/kubesolo-os/update
|
module github.com/portainer/kubesolo-os/update
|
||||||
|
|
||||||
go 1.25.5
|
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
|
||||||
|
)
|
||||||
|
|||||||
8
update/go.sum
Normal file
8
update/go.sum
Normal file
@@ -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=
|
||||||
@@ -78,18 +78,25 @@ Commands:
|
|||||||
metrics Start Prometheus-compatible metrics HTTP server
|
metrics Start Prometheus-compatible metrics HTTP server
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--server URL Update server URL (default: from /etc/kubesolo/update.conf)
|
--server URL HTTP update server (mutually exclusive with --registry)
|
||||||
--grubenv PATH Path to grubenv file (default: /boot/grub/grubenv)
|
--registry REPO OCI registry repository, e.g. ghcr.io/portainer/kubesolo-os
|
||||||
--state PATH Update state file (default: /var/lib/kubesolo/update/state.json)
|
(mutually exclusive with --server)
|
||||||
--timeout SECS Health check timeout in seconds (default: 120)
|
--tag TAG OCI tag to pull (default: channel name, then "stable")
|
||||||
--pubkey PATH Ed25519 public key for signature verification (optional)
|
--conf PATH update.conf path (default: /etc/kubesolo/update.conf)
|
||||||
--json For 'status': emit JSON instead of human-readable output
|
--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:
|
Examples:
|
||||||
kubesolo-update check --server https://updates.example.com
|
kubesolo-update apply --server https://updates.example.com
|
||||||
kubesolo-update apply --server https://updates.example.com --pubkey /etc/kubesolo/update-pubkey.hex
|
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 healthcheck
|
||||||
kubesolo-update status
|
|
||||||
kubesolo-update status --json
|
kubesolo-update status --json
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
281
update/pkg/oci/oci.go
Normal file
281
update/pkg/oci/oci.go
Normal file
@@ -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")
|
||||||
377
update/pkg/oci/oci_test.go
Normal file
377
update/pkg/oci/oci_test.go
Normal file
@@ -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/<tag-or-digest> -> manifest
|
||||||
|
// HEAD same -> same headers, no body
|
||||||
|
// GET /v2/test/kubesolo-os/blobs/<digest> -> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user