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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user