Files
kubesolo-os/update/cmd/opts.go
Adolfo Delorenzo 28de656b97
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
feat(update): OCI registry distribution for update artifacts
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>
2026-05-14 18:58:38 -06:00

158 lines
3.8 KiB
Go

package cmd
import (
"log/slog"
"github.com/portainer/kubesolo-os/update/pkg/bootenv"
"github.com/portainer/kubesolo-os/update/pkg/config"
"github.com/portainer/kubesolo-os/update/pkg/state"
)
// 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
BootEnvType string // "grub" or "rpi"
BootEnvPath string // path for RPi boot control dir
StatePath string // location of state.json (default: state.DefaultPath)
ConfPath string // location of update.conf (default: config.DefaultPath)
Channel string // update channel ("stable" by default)
MaintenanceWindow string // "HH:MM-HH:MM" or empty for always-allow
Force bool // bypass maintenance window
JSON bool // status: emit JSON instead of human-readable
}
// NewBootEnv creates a BootEnv from the parsed options.
func (o opts) NewBootEnv() bootenv.BootEnv {
switch o.BootEnvType {
case "rpi":
return bootenv.NewRPi(o.BootEnvPath)
default:
return bootenv.NewGRUB(o.GrubenvPath)
}
}
// parseOpts extracts command-line flags from args.
//
// Precedence: explicit CLI flags > /etc/kubesolo/update.conf > package
// defaults. The config file is loaded first so any CLI flag overrides it.
//
// Unknown flags are ignored (forward-compat).
func parseOpts(args []string) opts {
o := opts{
GrubenvPath: "/boot/grub/grubenv",
TimeoutSecs: 120,
BootEnvType: "grub",
StatePath: state.DefaultPath,
ConfPath: config.DefaultPath,
Channel: "stable",
}
// First pass: pick up --conf so it can point at a different file before
// we load. (Tests pass --conf <tempdir>/update.conf.)
for i := 0; i < len(args); i++ {
if args[i] == "--conf" && i+1 < len(args) {
o.ConfPath = args[i+1]
}
}
// Load config file. Missing file is fine (fresh system, no cloud-init yet).
if cfg, err := config.Load(o.ConfPath); err == nil && cfg != nil {
if cfg.Server != "" {
o.ServerURL = cfg.Server
}
if cfg.Channel != "" {
o.Channel = cfg.Channel
}
if cfg.MaintenanceWindow != "" {
o.MaintenanceWindow = cfg.MaintenanceWindow
}
if cfg.PubKey != "" {
o.PubKeyPath = cfg.PubKey
}
} else if err != nil {
slog.Warn("could not load update.conf", "path", o.ConfPath, "error", err)
}
// Second pass: CLI overrides config file values.
for i := 0; i < len(args); i++ {
switch args[i] {
case "--conf":
i++ // already handled above
case "--state":
if i+1 < len(args) {
o.StatePath = args[i+1]
i++
}
case "--channel":
if i+1 < len(args) {
o.Channel = args[i+1]
i++
}
case "--maintenance-window":
if i+1 < len(args) {
o.MaintenanceWindow = args[i+1]
i++
}
case "--force":
o.Force = true
case "--json":
o.JSON = true
case "--server":
if i+1 < len(args) {
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]
i++
}
case "--timeout":
if i+1 < len(args) {
val := 0
for _, c := range args[i+1] {
if c >= '0' && c <= '9' {
val = val*10 + int(c-'0')
}
}
if val > 0 {
o.TimeoutSecs = val
}
i++
}
case "--pubkey":
if i+1 < len(args) {
o.PubKeyPath = args[i+1]
i++
}
case "--bootenv":
if i+1 < len(args) {
o.BootEnvType = args[i+1]
i++
}
case "--bootenv-path":
if i+1 < len(args) {
o.BootEnvPath = args[i+1]
i++
}
}
}
return o
}