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>
158 lines
3.8 KiB
Go
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
|
|
}
|