diff --git a/evetest/Dockerfile.broker b/evetest/Dockerfile.broker index 6f9567c7af9..bc2e7a5b5a2 100644 --- a/evetest/Dockerfile.broker +++ b/evetest/Dockerfile.broker @@ -72,9 +72,13 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then export CGO_ENABLED=1; else export ########################### FROM alpine:${ALPINE_VERSION} -# Install runtime dependencies +# Install runtime dependencies. +# qemu-img provides qemu-img/qemu-io/qemu-nbd; mtools provides mcopy, used to +# overlay a device's config files onto the EVE config partition image exactly +# as pkg/eve/runme.sh does. bash is not used by the broker itself -- it is for +# interactive debugging via `docker exec`, where BusyBox ash is painful. # hadolint ignore=DL3018 -RUN apk add --no-cache libvirt iptables ip6tables qemu-img +RUN apk add --no-cache libvirt iptables ip6tables qemu-img mtools bash # Copy the built broker binary from builder COPY --from=builder /go/bin/evetest-broker /usr/local/bin/evetest-broker diff --git a/evetest/Dockerfile.evetest b/evetest/Dockerfile.evetest index c45ab2276d1..1984b9f5304 100644 --- a/evetest/Dockerfile.evetest +++ b/evetest/Dockerfile.evetest @@ -66,11 +66,14 @@ ENV GOFLAGS="-mod=readonly" # hadolint ignore=DL3018 RUN apk add --no-cache iptables dnsmasq radvd swtpm -# Install QEMU +# Install QEMU. qemu-img also provides qemu-io/qemu-nbd; mtools provides mcopy, +# used together to assemble a device's config partition and write it into a +# template-backed disk image (see Dockerfile.broker). # hadolint ignore=DL3018 RUN apk add --no-cache \ qemu \ qemu-img \ + mtools \ qemu-system-x86_64 \ qemu-system-aarch64 diff --git a/evetest/Makefile b/evetest/Makefile index d4dbccf4b80..4f7504f3691 100644 --- a/evetest/Makefile +++ b/evetest/Makefile @@ -46,14 +46,24 @@ ifndef NAME endif $(eval EVETEST_NAME := $(NAME)) endif + @# Default the EVE version to this checkout's, which is what the container + @# transport needs to name an image. Deliberately NOT defaulted under + @# EVETEST_EVE_LIVE_IMAGE: the live transport looks the version up among the + @# local builds under dist/, and `make version` reports the working tree as it + @# is *now* -- including a "-dirty-" suffix -- which is a directory + @# that generally does not exist. Left empty, the newest build (dist// + @# current) is used and reports its own version. An EVETEST_EVE_VERSION the + @# operator sets explicitly is always honoured, by both transports. $(eval EVE_VERSION_ENV :=) ifndef EVETEST_EVE_VERSION +ifeq ($(strip $(filter-out false False FALSE 0 f F,$(EVETEST_EVE_LIVE_IMAGE))),) $(eval EVETEST_EVE_VERSION := $(strip $(shell \ $(MAKE) -s -C $(REPO_ROOT) version 2>/dev/null \ ))) $(eval EVE_VERSION_ENV := -e EVETEST_EVE_VERSION=$(EVETEST_EVE_VERSION)) endif - @echo "Running evetest $(EVETEST_NAME) for EVE $(EVETEST_EVE_VERSION) with evetest image version $(EVETEST_VERSION) (adam $(EVETEST_ADAM_VERSION))..." +endif + @echo "Running evetest $(EVETEST_NAME) for EVE $(or $(EVETEST_EVE_VERSION),the newest local build) with evetest image version $(EVETEST_VERSION) (adam $(EVETEST_ADAM_VERSION))..." $(eval ENV_VARS := $(shell env | grep '^EVETEST_' | grep -v '^EVETEST_NAME=' | sed 's/^/-e /')) $(eval ARTIFACTS_MOUNT :=) ifneq ($(EVETEST_COLLECT_ARTIFACTS),) @@ -105,6 +115,19 @@ endif @# never available; omit the flag so the container starts and QEMU falls back @# to TCG software emulation. $(eval KVM_DEVICE := $(shell [ -e /dev/kvm ] && echo '--device=/dev/kvm')) + @# Mount the EVE build output directory so a locally built live.qcow2 is + @# visible inside the container. Same absolute path on both sides so that + @# EVETEST_EVE_DIST_DIR is valid both inside the container and in any error + @# message a developer sees on the host. Read-write: the harness caches the + @# image's sha256 in a sidecar file next to it. Only added when a local + @# build exists, so nothing breaks without one. + $(eval EVETEST_DIST_DIR := $(or $(EVETEST_EVE_DIST_DIR),$(REPO_ROOT)/dist)) + $(eval DIST_DIR_MOUNT :=) + $(eval DIST_DIR_ENV :=) + $(if $(wildcard $(EVETEST_DIST_DIR)), \ + $(eval DIST_DIR_MOUNT := -v $(EVETEST_DIST_DIR):$(EVETEST_DIST_DIR)) \ + $(eval DIST_DIR_ENV := -e EVETEST_EVE_DIST_DIR=$(EVETEST_DIST_DIR)) \ + ) @if docker inspect --type container evetest-$(EVETEST_API_PORT) >/dev/null 2>&1; then \ echo ""; \ echo "Error: evetest instance 'evetest-$(EVETEST_API_PORT)' is already running."; \ @@ -138,11 +161,13 @@ endif $(EVETEST_HOME_MOUNT) \ $(BROKER_IMAGE_MOUNT) \ $(BROKER_PROXY_CA_MOUNT) \ + $(DIST_DIR_MOUNT) \ $(GO_CACHE_MOUNT) \ $(DOCKER_CONFIG_MOUNT) \ $(ENV_VARS) \ $(EVE_VERSION_ENV) \ $(BROKER_IMAGE_ENV) \ + $(DIST_DIR_ENV) \ $(GO_CACHE_ENV) \ -e EVETEST_NAME=$(EVETEST_NAME) \ -e EVETEST_HOME=$(EVETEST_HOME_DIR) \ @@ -150,6 +175,10 @@ endif -e EVETEST_HOST_GID=$(EVETEST_HOST_GID) \ $(EVETEST_IMAGE) +# Only builds when the image is missing -- a locally built image is never +# rebuilt just because harness source changed, so after editing evetest code +# run `make build-container` explicitly before `make evetest`, or the run +# silently uses the stale, previously-built harness. ensure-evetest-image: @if ! docker image inspect $(EVETEST_IMAGE) >/dev/null 2>&1; then \ echo "Docker image $(EVETEST_IMAGE) not found locally, trying to pull..."; \ diff --git a/evetest/README.md b/evetest/README.md index 531e08c2362..5f731f38a38 100644 --- a/evetest/README.md +++ b/evetest/README.md @@ -487,6 +487,110 @@ EVETEST_COLLECT_ARTIFACTS=/tmp/evetest-artifacts \ make evetest NAME=TestDHCPIPv4Only ``` +### Testing a Local EVE Build + +A developer iterating on EVE can point evetest at their own `make live` output instead +of pulling or building an EVE container image: + +```bash +make live # build EVE locally +EVETEST_EVE_LIVE_IMAGE=true make evetest NAME= +``` + +**Two independent settings.** Which EVE build runs and how its bits get delivered are +separate questions: + +| | setting | values | +|---|---|---| +| **which build** | `EVETEST_EVE_VERSION` | a version (`16.0.0-lts`), or unset | +| **how it is delivered** | `EVETEST_EVE_LIVE_IMAGE` | `true` = the artifacts `make live` wrote; unset/`false` = an EVE container image | + +A local build is not a transport: `make eve` produces a local *container* image, and the +harness pushes it to the broker when the broker does not have it. So +`EVETEST_EVE_LIVE_IMAGE` says only *how*, never *which* — it is a plain boolean and takes +no path. + +With the live transport on, the version selects the build directory under +`EVETEST_EVE_DIST_DIR` (set automatically by `make evetest`): + +- **`EVETEST_EVE_VERSION` unset** — the newest build, via `dist//current`; the run + reports whatever version that turns out to be. Note the symlink is **arch-scoped** + (`dist/amd64/current`, not `dist/current`). +- **`EVETEST_EVE_VERSION=`** — `dist///`. If that version is not built here, + the run **fails** rather than quietly fetching it from a registry: you asked for a + specific build *and* for the live transport, and silently delivering different bits is + the kind of thing that costs an afternoon to notice. +- A version pinned by the test itself (`RequireEdgeDevice.WithEVEVersion`, e.g. + `TestEVEUpgrade`'s pre-upgrade version) names a *released* build and always comes from + a container image, whatever the transport setting. + +The harness content-hashes the image, then tells the broker both the hash and where the +files are (`live.qcow2`, `installer/config.img`, `installer/firmware/*`), and the broker +picks how to get them: + +- **It can read those paths itself** -- all-in-one mode, or a broker you started by hand + on your own machine. It installs the template straight from the dist directory, and + nothing is uploaded at all. Whether the paths are readable is decided by the broker, + by looking; the harness does not guess from its deployment mode. +- **It cannot** (the usual distributed setup, where the broker is a different machine). + It reports the image as missing and the harness uploads it as a tar, exactly as + before. + +Either way the upload/read happens only when the broker does not already hold that hash, +and the template is then installed through the machinery described in +[The EVE Image Template Cache](#the-eve-image-template-cache). The version reported for the +run is the resolved build directory's name (e.g. `0.0.0-my-branch-abc123`), which is the +authority on what was actually delivered. + +A local read is only ever used when the file's size **and** its sha256 match what the +harness declared, so a broker can never install different content under a hash another +run will later ask for -- the same check the upload path applies to received bytes. + +This is dramatically faster than the container path: there is no container build and no +multi-gigabyte pull. Rebuilding with `make live` produces a qcow2 with different content +and therefore a new hash, so the next run performs exactly one new upload -- nothing is +re-uploaded until the image actually changes. + +#### Upgrading *to* a local build + +`TestEVEUpgrade` boots `INITIAL_EVE_VERSION` and upgrades to `EVE_VERSION`, and the two +axes make both useful shapes fall out without any special-casing: + +```bash +# A released version upgraded to another released version +EVETEST_INITIAL_EVE_VERSION=16.0.0-lts EVETEST_EVE_VERSION=16.1.0-lts \ + make evetest NAME=TestEVEUpgrade + +# A released version upgraded to your working tree +make live +EVETEST_INITIAL_EVE_VERSION=16.0.0-lts EVETEST_EVE_LIVE_IMAGE=true \ + make evetest NAME=TestEVEUpgrade +``` + +The pre-upgrade device pins `INITIAL_EVE_VERSION`, so it always comes from a container +image — a released build is the point of that field. Only the *target* uses the live +transport, and it needs a different artifact than a fresh device does: an upgrade installs +a base OS image, so the harness serves the build's own `installer/rootfs.img` from its HTTP +image server and takes the version EVE will report from `installer/eve_version`. No +container is pulled and the broker is not involved at all, since the rootfs goes straight +from the harness to the device. + +Two constraints to be aware of: + +- **Installer-based tests cannot use this path.** A live qcow2 cannot produce an + installer flow, so a test that also requests an installer + (`CreateFromScratchWithInstaller`) fails immediately with a clear error instead of + silently falling back to the container path. +- **The broker must advertise `CAPABILITY_LOCAL_LIVE_IMAGE`.** All three providers do + today; a broker too old to support the feature, or a future provider that still builds + images per device (see + [The EVE Image Template Cache](#the-eve-image-template-cache)), fails the test with a + clear error rather than quietly falling back and testing a different EVE build than + the one requested. + +See [Essential Variables](#essential-variables) for the full reference on +`EVETEST_EVE_LIVE_IMAGE`, `EVETEST_EVE_DIST_DIR`, and `EVETEST_EVE_FIRMWARE_DIR`. + ### Code Coverage When EVE is built with `COVER=y`, the `zedbox` binary is instrumented for @@ -709,6 +813,9 @@ non-default behavior. | `EVETEST_NAME` | Test or suite name to run (**required**) | -- | | `EVETEST_OUTPUT_FORMAT` | `go test` output format: `json` (machine-readable, for `gotestfmt`) or `quiet` (compact, no `-v`); default is verbose (`-v`). **Do not combine `quiet` with `EVETEST_PAUSE_ON_FAILURE` or `EVETEST_PAUSE_ON_CHECKPOINT`** — without `-v`, `go test` buffers all output until the test completes, so a pause appears frozen with no visible output. | -- | | `EVETEST_EVE_VERSION` | EVE version to test | current repo HEAD | +| `EVETEST_EVE_LIVE_IMAGE` | **How** EVE's bits reach a device: `true` delivers the artifacts `make live` wrote under `EVETEST_EVE_DIST_DIR`, unset/`false` uses an EVE container image. A boolean only -- **which** build to run is `EVETEST_EVE_VERSION`'s business, so this takes no path, and a non-boolean value is an error. See [Testing a Local EVE Build](#testing-a-local-eve-build) | `false` | +| `EVETEST_EVE_DIST_DIR` | EVE build output directory whose `//` subdirectories (and the `/current` symlink) hold the local builds. Must be an absolute path (the harness runs inside a container). Set automatically by `make evetest` when a local `dist/` directory exists | -- | +| `EVETEST_EVE_FIRMWARE_DIR` | Overrides firmware discovery for a local live image, which otherwise looks for `OVMF*.fd` in `installer/firmware` next to the resolved qcow2 | -- | | `EVETEST_PREFERRED_ARCH` | Preferred CPU architecture (`amd64`, `arm64`) | `amd64` | | `EVETEST_LOG_LEVEL` | Framework log level (`debug`, `info`, `warn`) | `info` | | `EVETEST_COLLECT_ARTIFACTS` | Host path for artifacts (logs, collect-info) | -- | @@ -782,6 +889,8 @@ Common to every provider: | `EVETEST_BROKER_MAX_CLIENTS` | Max concurrent evetest clients the broker will accept; new connections are rejected with an error once this many are already connected (reconnects of existing clients are never blocked) | `-1` (unlimited) | | `EVETEST_BROKER_DOCKER_IMAGE_RETENTION` | How long, in minutes, an unused, evetest-managed Docker image (one the broker itself pulled or built) is kept before the broker's periodic cleanup removes it | `10080` (7 days) | | `EVETEST_BROKER_DOCKER_DISK_USAGE_THRESHOLD` | Disk usage percent (on the filesystem backing Docker's storage) at or above which the broker aggressively evicts the oldest unused, evetest-managed Docker images, regardless of the retention setting above | `80` | +| `EVETEST_BROKER_TEMPLATE_RETENTION` | How long, in minutes, an unused EVE disk-image template (see [The EVE Image Template Cache](#the-eve-image-template-cache)) is kept before the broker's periodic cleanup removes it. Deliberately generous, since templates let consecutive runs against the same EVE version skip the image build entirely; zero or negative disables age-based eviction, but disk-usage-based eviction still applies regardless. A template still backing a live VM is never removed regardless of this value | `10080` (7 days) | +| `EVETEST_BROKER_TEMPLATE_DISK_USAGE_THRESHOLD` | Disk usage percent (on the filesystem backing the broker's image directory) at or above which the broker evicts the oldest unreferenced EVE image templates, regardless of the retention setting above. Deliberately higher than `EVETEST_BROKER_DOCKER_DISK_USAGE_THRESHOLD`: broker hosts routinely idle above 80%, so an 80% threshold would evict every unreferenced template on every pass and the cache would never stay warm; templates are also the wrong thing to give up first -- one is 1-2 GB, where the Docker image store is tens of GB | `90` | | `EVETEST_BROKER_PPROF_PORT` | Port for the broker's `net/http/pprof` debug endpoint (listens on all interfaces); `0` disables it | `0` (disabled) | **`libvirt` provider only:** @@ -936,6 +1045,44 @@ for reuse across tests, and acts as a tunnel proxy forwarding IP packets between evetest container and the SDN VM. This tunneling allows the evetest container to operate without direct network connectivity to the VMs -- it only needs access to the broker. +#### The EVE Image Template Cache + +Building an EVE image used to mean one full container build per device -- roughly 4 +minutes and 2 GB of I/O -- run serially, even though most of that work (unpacking the +container, laying out the disk) does not depend on anything device-specific like the +onboarding certificate. The broker now builds a **configuration-independent template** +once per distinct (docker image content ID, disk size, installer flag, arch) and reuses +it across every device and every test run that matches: + +- Templates are cached under `$EVETEST_BROKER_IMAGE_DIR/templates//`, keyed by + content rather than by EVE version string, so identical image content built under a + different tag still hits the cache. +- Each device gets its own qcow2 copy-on-write overlay backed by the template's disk, + with that device's own 5 MiB FAT config partition written into the overlay's CONFIG + partition -- the template itself is never modified. +- Templates are reference-counted: one currently backing a live VM is never deleted, + regardless of age or disk pressure. +- An image-directory-wide `flock` guards template creation and eviction, so two brokers + sharing the same `EVETEST_BROKER_IMAGE_DIR` cannot destroy each other's state. +- Unreferenced templates are evicted by age (`EVETEST_BROKER_TEMPLATE_RETENTION`) and by + disk pressure (`EVETEST_BROKER_TEMPLATE_DISK_USAGE_THRESHOLD`), mirroring the existing + Docker image cleanup. + +Every provider uses the cache; they differ only in how a device's disk is derived from a +template. `libvirt` and `qemu` use **overlays**, since both attach local image files +directly. `proxmox` uses a **standalone copy**: it uploads each device's disk to the PVE +node, where a backing file would not exist. A live-image template is deliberately keyed +without the disk size, so the per-device disk -- overlay or copy -- is grown to the +requested size instead. + +With the `proxmox` provider that copy is what the broker uploads, so the per-device cost +there is one full-size copy plus the upload to the node, against a ~4-minute container +build per device before. + +With the `qemu` provider the broker lives inside the short-lived evetest container, so +its 30-minute cleanup loop would rarely tick before the container exits. There the sweep +runs once at broker startup instead -- same age and disk-pressure rules, same variables. + ### SDN (Software Defined Network) The SDN is a lightweight LinuxKit-based VM that models physical network infrastructure diff --git a/evetest/broker/broker.go b/evetest/broker/broker.go index 4c69d736217..1cd5efd80f8 100644 --- a/evetest/broker/broker.go +++ b/evetest/broker/broker.go @@ -65,6 +65,11 @@ type broker struct { diskThresholdPct int imageUsage *imageUsageTracker + // Periodic EVE image template cleanup + templates *templateCache + tmplRetention time.Duration + tmplDiskThresholdPct int + // mutex protects only broker-global state: sessions, imageUploads // and usedSDNUplinkMACs. It is always held briefly. Per-session state // (session.eveDevices, session.sdnDevice) is protected by that session's @@ -125,11 +130,33 @@ type device struct { // live QCOW2; for installer devices this is the blank target QCOW2 that the // installer writes EVE into. Also set for the SDN device. disks []provider.DiskImage + + // templateKey identifies the cached EVE image template backing this + // device's disk, if any. Empty for legacy-build devices and for the SDN + // device. Teardown releases the template reference recorded under this key. + templateKey string +} + +// brokerCapabilities composes the full capability set advertised to clients: +// the provider's own capabilities plus CAPABILITY_LOCAL_LIVE_IMAGE whenever +// diskStrategy is not DiskImageLegacyBuild. A provider never builds or +// receives an EVE image, so whether an uploaded live image can be consumed is +// the broker's determination; it depends on the provider only through its +// disk image strategy, which is why that decision lives here rather than in +// provider.Capabilities(). +func brokerCapabilities( + providerCaps []api.Capability, diskStrategy provider.DiskImageStrategy) []api.Capability { + caps := append([]api.Capability{}, providerCaps...) + if diskStrategy != provider.DiskImageLegacyBuild { + caps = append(caps, api.Capability_CAPABILITY_LOCAL_LIVE_IMAGE) + } + return caps } func newBroker(log *logrus.Logger, provider provider.DeviceProvider, providerName, imageDir string, sdnGrpcPort uint16, maxClients int, - imgRetention time.Duration, diskThresholdPct int) (*broker, error) { + imgRetention time.Duration, diskThresholdPct int, + tmplRetention time.Duration, tmplDiskThresholdPct int) (*broker, error) { supportedArchs, err := provider.GetSupportedDeviceArchs() if err != nil { return nil, fmt.Errorf("cannot retrieve supported device architectures: %w", err) @@ -155,28 +182,55 @@ func newBroker(log *logrus.Logger, provider provider.DeviceProvider, len(proxyCACerts), proxyChainPath) } b := &broker{ - globalLog: log, - provider: provider, - providerName: providerName, - imageDir: imageDir, - sdnGrpcPort: sdnGrpcPort, - supportedArchs: supportedArchs, - capabilities: provider.Capabilities(), - proxyCACerts: proxyCACerts, - registryMirrors: constants.LoadRegistryMirrors(), - maxClients: maxClients, - imgRetention: imgRetention, - diskThresholdPct: diskThresholdPct, - imageUsage: newImageUsageTracker(imageDir), - sessions: make(map[string]*session), - imageUploads: make(map[string]chan struct{}), - usedSDNUplinkMACs: make(map[string]struct{}), + globalLog: log, + provider: provider, + providerName: providerName, + imageDir: imageDir, + sdnGrpcPort: sdnGrpcPort, + supportedArchs: supportedArchs, + capabilities: brokerCapabilities(provider.Capabilities(), provider.DiskImageStrategy()), + proxyCACerts: proxyCACerts, + registryMirrors: constants.LoadRegistryMirrors(), + maxClients: maxClients, + imgRetention: imgRetention, + diskThresholdPct: diskThresholdPct, + imageUsage: newImageUsageTracker(imageDir), + templates: newTemplateCache(imageDir, log), + tmplRetention: tmplRetention, + tmplDiskThresholdPct: tmplDiskThresholdPct, + sessions: make(map[string]*session), + imageUploads: make(map[string]chan struct{}), + usedSDNUplinkMACs: make(map[string]struct{}), + } + // Claim the image directory before the cleanup goroutine starts: it reads + // b.templates.owner, so the lock must be taken first. A second broker + // sharing the directory runs in non-owner mode and never deletes anything. + if err := b.templates.tryLock(); err != nil { + return nil, err + } + // Client sessions do not survive a broker restart, so every reference on + // disk is stale and any in-progress build was abandoned. Both are no-ops + // unless this broker owns the directory: a non-owner must never delete + // another broker's in-progress build or its live VMs' refs. + if err := b.templates.removeStaleTmpDirs(); err != nil { + log.Warnf("Failed to sweep stale template builds: %v", err) + } + if err := b.templates.clearAllRefs(); err != nil { + log.Warnf("Failed to clear stale template references: %v", err) + } + if err := b.templates.removeStaleLiveUploads(); err != nil { + log.Warnf("Failed to sweep stale live image uploads: %v", err) } // The qemu provider runs the broker embedded inside the short-lived evetest // container itself (all-in-one mode), so it exits when the test ends -- there's - // no accumulated state worth periodically cleaning up, and no long-lived Docker - // storage to protect. - if providerName != "qemu" { + // no long-lived Docker storage to protect, and a 30-minute periodic loop would + // usually never tick. Templates do outlive it, though: the image directory is a + // host mount shared by every run. Sweep them once here instead, right after + // clearAllRefs, where nothing is referenced yet and the whole cache is visible + // to the age and disk-pressure passes. + if providerName == "qemu" { + b.cleanupTemplates(context.Background()) + } else { go b.runImageCleanupLoop() } return b, nil @@ -499,7 +553,9 @@ func (b *broker) BuildImage( return nil, err } - // Resolve Docker image name from ImageRef + // Resolve Docker image name from ImageRef. Still resolved on the live path + // too: the harness always sends an ImageRef for reporting, and this is a + // pure string computation, no I/O. dockerImageName, err := utils.EVEDockerImageName(req.Image) if err != nil { err = fmt.Errorf("invalid image reference: %w", err) @@ -507,35 +563,117 @@ func (b *broker) BuildImage( return nil, err } - // Check if the Docker image exists locally - haveImage, err := utils.HaveDockerImage(ctx, log, dockerImageName) - if err != nil { - err = fmt.Errorf("failed to check for image %q presence: %w", - dockerImageName, err) - log.Error(err) - return nil, err - } - if !haveImage { - log.Infof("Docker image %q not found locally, trying to pull...", dockerImageName) - err = utils.PullDockerImage(ctx, log, dockerImageName) + // A local live image may exist with no EVE container image on this broker + // at all, so its preconditions and miss check must be resolved before any + // docker image I/O is attempted: a HaveDockerImage/PullDockerImage below + // would otherwise waste (or fail) a multi-GB pull that the live path + // never needed. + // liveSource is set when this broker can read the client's live image files + // itself, which makes the upload pointless. + var liveSource *api.LocalLiveImageSource + if live := req.GetLiveImage(); live != nil { + if req.MakeInstaller { + err := fmt.Errorf( + "device %q requests an installer image, which a local live image "+ + "cannot provide; unset EVETEST_EVE_LIVE_IMAGE to use the container path", + req.DeviceName) + log.Error(err) + return nil, err + } + if b.provider.DiskImageStrategy() == provider.DiskImageLegacyBuild { + err := fmt.Errorf( + "device %q requests a local live image, but the %T provider still uses "+ + "the per-device container build path and cannot consume one; unset "+ + "EVETEST_EVE_LIVE_IMAGE to build from the EVE container image", + req.DeviceName, b.provider) + log.Error(err) + return nil, err + } + if err := validLiveImageSHA256(live.GetSha256()); err != nil { + log.Error(err) + return nil, err + } + // Checked before the staged upload and the template cache, and kept even + // on a cache hit: a template evicted between this check and the install + // then still has a source to be rebuilt from, and asking for an upload + // this broker does not need is never right. + if localLiveSourceUsable(log, req.GetLiveImageSource()) { + liveSource = req.GetLiveImageSource() + } else { + tarPath := liveUploadPath(b.imageDir, live.GetSha256()) + if _, statErr := os.Stat(tarPath); statErr != nil { + if !b.templates.hasTemplate(templateKeyParams{ + LiveImageSHA256: live.GetSha256(), + Arch: imageArch, + }) { + return &api.BuildImageResponse{MissingEveLiveImage: true}, nil + } + } + } + } else { + // Check if the Docker image exists locally + haveImage, err := utils.HaveDockerImage(ctx, log, dockerImageName) if err != nil { - log.Warnf("Failed to pull Docker image %q: %v", dockerImageName, err) - return &api.BuildImageResponse{MissingEveContainerImage: true}, nil + err = fmt.Errorf("failed to check for image %q presence: %w", + dockerImageName, err) + log.Error(err) + return nil, err + } + if !haveImage { + log.Infof("Docker image %q not found locally, trying to pull...", dockerImageName) + err = utils.PullDockerImage(ctx, log, dockerImageName) + if err != nil { + log.Warnf("Failed to pull Docker image %q: %v", dockerImageName, err) + return &api.BuildImageResponse{MissingEveContainerImage: true}, nil + } } + b.imageUsage.touch(dockerImageName) } - b.imageUsage.touch(dockerImageName) - // Build QCOW2 or RAW image providerDevName := fmt.Sprintf("eve-%s-%s", clientSession.clientID, req.DeviceName) imageDirPath := filepath.Join(b.imageDir, providerDevName) - eveImage, err := buildEVEImage(ctx, log, buildEVEImageParams{ - imageDirPath: imageDirPath, - dockerImageName: dockerImageName, - config: req.Config, - proxyCACerts: b.proxyCACerts, - diskSize: req.DiskBytes, - installer: req.MakeInstaller, - }) + softSerial := resolveSoftSerial(req.GetConfig().GetSoftSerial()) + + var eveImage buildEVEImageResult + var templateKey string + if b.provider.DiskImageStrategy() == provider.DiskImageLegacyBuild { + eveImage, err = buildEVEImage(ctx, log, buildEVEImageParams{ + imageDirPath: imageDirPath, + dockerImageName: dockerImageName, + config: req.Config, + proxyCACerts: b.proxyCACerts, + softSerial: softSerial, + diskSize: req.DiskBytes, + installer: req.MakeInstaller, + }) + } else { + // No EVE container image need exist on this broker at all on the live + // path, so its content ID is never inspected. The cache key comes from + // liveImageSHA256 via liveTemplateKeyParams instead; dockerImageID stays + // empty and unused. + var dockerImageID string + if req.GetLiveImage() == nil { + dockerImageID, err = utils.DockerImageID(ctx, dockerImageName) + } + if err == nil { + eveImage, templateKey, err = makeDeviceImage(ctx, log, b.templates, + providerDevName, makeDeviceImageParams{ + imageDirPath: imageDirPath, + dockerImageName: dockerImageName, + dockerImageID: dockerImageID, + arch: imageArch, + config: req.Config, + proxyCACerts: b.proxyCACerts, + softSerial: softSerial, + diskSize: req.DiskBytes, + installer: req.MakeInstaller, + overlay: b.provider.DiskImageStrategy() == provider.DiskImageOverlay, + liveImageSHA256: req.GetLiveImage().GetSha256(), + liveTarPath: liveUploadPath(b.imageDir, req.GetLiveImage().GetSha256()), + liveSource: liveSource, + }) + } + } if err != nil { err = fmt.Errorf("failed to build EVE image for device %q: %v", req.DeviceName, err) @@ -566,6 +704,7 @@ func (b *broker) BuildImage( providerDevName: providerDevName, installerImage: eveImage.installerImage, disks: eveImage.disks, + templateKey: templateKey, created: false, // device is created by SetupDevices } clientSession.eveDevices[req.DeviceName] = eveDevice @@ -725,6 +864,139 @@ func (b *broker) PushEVEContainerImage( }) } +// PushEVELiveImage receives a locally built EVE live image, uploaded as a tar +// stream (disk.qcow2, config.img, firmware/*), and stages it at +// liveUploadPath for BuildImage's retry to install via unpackLiveTemplate. +// +// Modelled on PushEVEContainerImage: the first message carries metadata, the +// rest are raw tar bytes. The stream is written to a ".part" file and renamed +// into place only once it completes without error, so a client disconnect or +// broker crash mid-upload never leaves a tar that looks complete -- the +// partial is removed on every error return instead. +func (b *broker) PushEVELiveImage( + stream grpc.ClientStreamingServer[api.PushLiveImageChunk, api.PushLiveImageResponse]) error { + // Receive first message (metadata) + firstChunk, err := stream.Recv() + if err != nil { + err = fmt.Errorf("failed to receive live image metadata: %v", err) + b.globalLog.Error(err) + return err + } + + req := firstChunk.GetRequest() + if req == nil { + err = fmt.Errorf("first message must contain live image metadata") + b.globalLog.Error(err) + return err + } + + // Lookup client session + b.mutex.Lock() + clientSession, exists := b.sessions[req.ClientId] + b.mutex.Unlock() + if !exists { + err = clientNotFoundErr(req.ClientId) + b.globalLog.Error(err) + return err + } + log := clientSession.log + + sha := req.GetLiveImage().GetSha256() + if err = validLiveImageSHA256(sha); err != nil { + log.Error(err) + return err + } + tarPath := liveUploadPath(b.imageDir, sha) + + // The staged tar is keyed only on content hash, so this check is enough on + // its own to skip a redundant 2.1 GB upload -- no need to also drain the + // stream first. SendAndClose without draining is safe, as in + // PushEVEContainerImage: the client handles the early close by breaking out + // of its send loop and calling CloseAndRecv. + if _, statErr := os.Stat(tarPath); statErr == nil { + log.Infof("Live image %q is already staged, skipping upload", sha) + return stream.SendAndClose(&api.PushLiveImageResponse{AlreadyExists: true}) + } + + if err = os.MkdirAll(filepath.Dir(tarPath), 0o755); err != nil { + err = fmt.Errorf("failed to create live image upload dir: %w", err) + log.Error(err) + return err + } + + // Unique per upload: two clients streaming the same hash concurrently must + // not share a part file, or each O_TRUNC would truncate the other's inode + // and the two streams would interleave into one garbled tar. os.Rename onto + // tarPath is atomic on Linux, so whichever upload finishes last still wins + // with correct (for a given hash, identical) content. + uploadID := make([]byte, 8) + if _, err = rand.Read(uploadID); err != nil { + err = fmt.Errorf("failed to generate upload id: %w", err) + log.Error(err) + return err + } + partPath := fmt.Sprintf("%s.%s.part", tarPath, hex.EncodeToString(uploadID)) + f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + err = fmt.Errorf("failed to create %q: %w", partPath, err) + log.Error(err) + return err + } + defer f.Close() + + installed := false + defer func() { + if !installed { + if rmErr := os.Remove(partPath); rmErr != nil && !os.IsNotExist(rmErr) { + log.Warnf("Failed to remove partial live image upload %q: %v", + partPath, rmErr) + } + } + }() + + for { + chunk, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + err = fmt.Errorf("failed to receive live image chunk: %w", err) + log.Error(err) + return err + } + data := chunk.GetDataChunk() + if len(data) == 0 { + // The client's chunking io.Writer can legitimately emit a + // zero-length Write (e.g. from archive/tar or io.CopyBuffer); + // tolerate it rather than aborting a multi-gigabyte transfer + // over a message that carries no bytes. + continue + } + if _, err := f.Write(data); err != nil { + err = fmt.Errorf("failed to write %q: %w", partPath, err) + log.Error(err) + return err + } + } + + if err = f.Close(); err != nil { + err = fmt.Errorf("failed to close %q: %w", partPath, err) + log.Error(err) + return err + } + if err = os.Rename(partPath, tarPath); err != nil { + err = fmt.Errorf("failed to install uploaded live image %q: %w", tarPath, err) + log.Error(err) + return err + } + installed = true + + log.Infof("Received uploaded EVE live image %q at %q", sha, tarPath) + return stream.SendAndClose(&api.PushLiveImageResponse{ + AlreadyExists: false, + }) +} + // SetupDevices provisions and starts up EVE devices and SDN for the client. func (b *broker) SetupDevices( ctx context.Context, req *api.SetupDevicesRequest) (*api.SetupDevicesResponse, error) { @@ -1172,6 +1444,12 @@ func (b *broker) teardownDevices(ctx context.Context, clientSession *session) { deviceName, imageDir) } } + if dev.templateKey != "" { + if err := b.templates.removeRef(dev.templateKey, dev.providerDevName); err != nil { + log.Warnf("Failed to release template ref for EVE device %q: %v", + deviceName, err) + } + } } clientSession.eveDevices = nil diff --git a/evetest/broker/broker_test.go b/evetest/broker/broker_test.go new file mode 100644 index 00000000000..967ca4e0eed --- /dev/null +++ b/evetest/broker/broker_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/lf-edge/eve/evetest/broker/provider" + api "github.com/lf-edge/eve/evetest/grpcapi/go" + "github.com/lf-edge/eve/pkg/pillar/utils/generics" +) + +// TestBrokerCapabilitiesAdvertisesLocalLiveImage covers the decision that +// belongs to the broker, not to any device provider: a provider that does not +// build a per-device image (DiskImageOverlay/DiskImageStandalone) allows the +// broker to consume an uploaded live image directly, so the broker must +// advertise CAPABILITY_LOCAL_LIVE_IMAGE for it. A DiskImageLegacyBuild +// provider builds per device and cannot consume one, so the broker must not. +func TestBrokerCapabilitiesAdvertisesLocalLiveImage(t *testing.T) { + providerCaps := []api.Capability{api.Capability_CAPABILITY_TPM} + + cases := []struct { + name string + strategy provider.DiskImageStrategy + want bool + }{ + {"overlay strategy advertises the capability", provider.DiskImageOverlay, true}, + {"standalone strategy advertises the capability", provider.DiskImageStandalone, true}, + {"legacy build strategy does not advertise the capability", provider.DiskImageLegacyBuild, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := brokerCapabilities(providerCaps, c.strategy) + if has := generics.ContainsItem( + got, api.Capability_CAPABILITY_LOCAL_LIVE_IMAGE); has != c.want { + t.Errorf("brokerCapabilities(_, %v) contains CAPABILITY_LOCAL_LIVE_IMAGE = %v, want %v", + c.strategy, has, c.want) + } + }) + } +} + +// TestBrokerCapabilitiesPreservesProviderCapabilities covers that the +// provider's own capabilities are carried through unchanged, regardless of +// disk image strategy. +func TestBrokerCapabilitiesPreservesProviderCapabilities(t *testing.T) { + providerCaps := []api.Capability{ + api.Capability_CAPABILITY_FORWARD_LACP, api.Capability_CAPABILITY_TPM, + } + got := brokerCapabilities(providerCaps, provider.DiskImageLegacyBuild) + for _, want := range providerCaps { + if !generics.ContainsItem(got, want) { + t.Errorf("brokerCapabilities() = %v, missing provider capability %v", got, want) + } + } +} diff --git a/evetest/broker/gpt.go b/evetest/broker/gpt.go new file mode 100644 index 00000000000..612d87764ba --- /dev/null +++ b/evetest/broker/gpt.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "unicode/utf16" +) + +const ( + // gptSectorSize is the logical sector size EVE images are built with: + // pkg/mkimage-raw-efi/make-raw computes every partition offset as + // sectors * 512. + gptSectorSize = 512 + // gptHeaderLBA holds the primary GPT header; LBA 0 is the protective MBR. + gptHeaderLBA = 1 + // gptSignature is the magic at the start of a GPT header. + gptSignature = "EFI PART" + // gptHeadBytes is how much of the disk to read. The protective MBR, header + // and a standard 128 x 128 B entry array need only 16 KiB; 1 MiB is read + // instead because it is the first partition's alignment boundary, so it + // covers the whole GPT region however large the entry array is. + gptHeadBytes = 1 << 20 + // gptMinEntrySize is the size mandated by the UEFI spec; anything smaller + // means we are not looking at a GPT. + gptMinEntrySize = 128 + // gptConfigPartName is the GPT partition name EVE gives its config + // partition, in both the live (make-raw do_conf) and installer + // (do_conf_win) layouts. EVE itself finds it with `findfs PARTLABEL=CONFIG`. + gptConfigPartName = "CONFIG" +) + +// gptPartition is a located partition, as byte offset and length from the +// start of the disk. +type gptPartition struct { + Offset int64 + Length int64 +} + +// findGPTPartition locates a partition by its GPT name in the first +// gptHeadBytes of a disk. Matching on the name rather than the type GUID lets +// one code path serve both EVE layouts, which use different type GUIDs for the +// same CONFIG partition. +func findGPTPartition(head []byte, name string) (gptPartition, error) { + const headerMinLen = 92 + hdrOff := gptHeaderLBA * gptSectorSize + if len(head) < hdrOff+headerMinLen { + return gptPartition{}, fmt.Errorf( + "disk head is %d bytes, too short to contain a GPT header", len(head)) + } + hdr := head[hdrOff:] + if string(hdr[0:8]) != gptSignature { + return gptPartition{}, fmt.Errorf("no %q signature at LBA %d", + gptSignature, gptHeaderLBA) + } + entryLBA := int64(binary.LittleEndian.Uint64(hdr[72:80])) + numEntries := int64(binary.LittleEndian.Uint32(hdr[80:84])) + entrySize := int64(binary.LittleEndian.Uint32(hdr[84:88])) + if entrySize < gptMinEntrySize { + return gptPartition{}, fmt.Errorf( + "GPT entry size %d is below the %d-byte minimum", entrySize, gptMinEntrySize) + } + + base := entryLBA * gptSectorSize + for i := int64(0); i < numEntries; i++ { + off := base + i*entrySize + // entryLBA, entrySize and numEntries all come from the disk, so a + // corrupt or truncated image can make these products overflow into + // negative values. A negative offset passes an upper-bound-only check + // and then panics on the slice below, so check both ends. + if off < 0 || off+entrySize < off || off+entrySize > int64(len(head)) { + break + } + entry := head[off : off+entrySize] + firstLBA := int64(binary.LittleEndian.Uint64(entry[32:40])) + lastLBA := int64(binary.LittleEndian.Uint64(entry[40:48])) + if firstLBA == 0 && lastLBA == 0 { + continue + } + if decodeGPTName(entry[56:128]) != name { + continue + } + if lastLBA < firstLBA { + return gptPartition{}, fmt.Errorf( + "GPT partition %q has last LBA %d before first LBA %d", + name, lastLBA, firstLBA) + } + return gptPartition{ + Offset: firstLBA * gptSectorSize, + Length: (lastLBA - firstLBA + 1) * gptSectorSize, + }, nil + } + return gptPartition{}, fmt.Errorf("no GPT partition named %q", name) +} + +// decodeGPTName decodes the 72-byte UTF-16LE, NUL-padded partition name field. +func decodeGPTName(raw []byte) string { + units := make([]uint16, 0, len(raw)/2) + for i := 0; i+1 < len(raw); i += 2 { + c := binary.LittleEndian.Uint16(raw[i : i+2]) + if c == 0 { + break + } + units = append(units, c) + } + return string(utf16.Decode(units)) +} + +// readDiskHead reads the leading gptHeadBytes of a QCOW2 disk into memory. +// qemu-img is used rather than opening the file directly because the disk is +// QCOW2 (and its clusters are compressed), so the bytes are not at their +// nominal offsets on disk. +func readDiskHead(ctx context.Context, diskPath string) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "evetest-gpt-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + headPath := filepath.Join(tmpDir, "head.bin") + out, err := exec.CommandContext(ctx, "qemu-img", "dd", + "-f", "qcow2", "-O", "raw", + fmt.Sprintf("bs=%d", gptHeadBytes), "count=1", + "if="+diskPath, "of="+headPath).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("qemu-img dd of %q failed: %v: %s", diskPath, err, out) + } + return os.ReadFile(headPath) +} diff --git a/evetest/broker/gpt_test.go b/evetest/broker/gpt_test.go new file mode 100644 index 00000000000..c1f79e6e178 --- /dev/null +++ b/evetest/broker/gpt_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/binary" + "os" + "testing" + "unicode/utf16" +) + +// buildTestGPT lays out a minimal but structurally valid GPT: a protective MBR +// at LBA 0, a header at LBA 1, and a 128-entry array at LBA 2. +func buildTestGPT(t *testing.T, parts []struct { + Name string + FirstLBA uint64 + LastLBA uint64 +}) []byte { + t.Helper() + const entrySize = 128 + const numEntries = 128 + head := make([]byte, 1<<20) + + hdr := head[512:] + copy(hdr[0:8], []byte("EFI PART")) + binary.LittleEndian.PutUint64(hdr[72:80], 2) // partition entry LBA + binary.LittleEndian.PutUint32(hdr[80:84], numEntries) + binary.LittleEndian.PutUint32(hdr[84:88], entrySize) + + for i, p := range parts { + off := 2*512 + i*entrySize + e := head[off : off+entrySize] + e[0] = 0xAA // non-zero type GUID so the entry looks used + binary.LittleEndian.PutUint64(e[32:40], p.FirstLBA) + binary.LittleEndian.PutUint64(e[40:48], p.LastLBA) + for j, r := range utf16.Encode([]rune(p.Name)) { + binary.LittleEndian.PutUint16(e[56+j*2:58+j*2], r) + } + } + return head +} + +func TestFindGPTPartition(t *testing.T) { + head := buildTestGPT(t, []struct { + Name string + FirstLBA uint64 + LastLBA uint64 + }{ + {Name: "EFI", FirstLBA: 2048, LastLBA: 6143}, + {Name: "CONFIG", FirstLBA: 6144, LastLBA: 16383}, + {Name: "IMGA", FirstLBA: 16384, LastLBA: 65535}, + }) + + got, err := findGPTPartition(head, "CONFIG") + if err != nil { + t.Fatalf("findGPTPartition: %v", err) + } + if got.Offset != 6144*512 { + t.Errorf("Offset = %d, want %d", got.Offset, 6144*512) + } + if got.Length != (16383-6144+1)*512 { + t.Errorf("Length = %d, want %d", got.Length, (16383-6144+1)*512) + } +} + +func TestFindGPTPartitionNotFound(t *testing.T) { + head := buildTestGPT(t, []struct { + Name string + FirstLBA uint64 + LastLBA uint64 + }{ + {Name: "EFI", FirstLBA: 2048, LastLBA: 6143}, + }) + if _, err := findGPTPartition(head, "CONFIG"); err == nil { + t.Fatal("expected an error when no CONFIG partition exists") + } +} + +func TestFindGPTPartitionBadSignature(t *testing.T) { + head := buildTestGPT(t, nil) + copy(head[512:520], []byte("NOTAGPT!")) + if _, err := findGPTPartition(head, "CONFIG"); err == nil { + t.Fatal("expected an error for a bad GPT signature") + } +} + +func TestFindGPTPartitionShortInput(t *testing.T) { + if _, err := findGPTPartition(make([]byte, 100), "CONFIG"); err == nil { + t.Fatal("expected an error for a truncated disk head") + } +} + +// TestFindGPTPartitionRealImage runs the parser against bytes captured from a +// real EVE live image, so a change in the on-disk layout is caught here rather +// than at boot time. +func TestFindGPTPartitionRealImage(t *testing.T) { + head, err := os.ReadFile("testdata/gpt-head-live.bin") + if err != nil { + t.Skipf("fixture not available: %v", err) + } + got, err := findGPTPartition(head, gptConfigPartName) + if err != nil { + t.Fatalf("findGPTPartition: %v", err) + } + const configPartSize = 5 * 1024 * 1024 + if got.Length != configPartSize { + t.Errorf("Length = %d, want %d (make-raw CONF_PART_SIZE)", got.Length, configPartSize) + } + if got.Offset%(1<<20) != 0 { + t.Errorf("Offset = %d, expected 1 MiB alignment", got.Offset) + } +} + +// TestFindGPTPartitionOverflowingHeader covers a corrupt header whose entry +// array location overflows int64: the offset wraps negative, which an +// upper-bound-only check would let through into a panicking slice. +func TestFindGPTPartitionOverflowingHeader(t *testing.T) { + head := buildTestGPT(t, nil) + hdr := head[512:] + binary.LittleEndian.PutUint64(hdr[72:80], 1<<54) // entry array LBA + binary.LittleEndian.PutUint32(hdr[80:84], 128) // number of entries + binary.LittleEndian.PutUint32(hdr[84:88], 128) // entry size + if _, err := findGPTPartition(head, gptConfigPartName); err == nil { + t.Fatal("expected an error for a header whose entry array overflows int64") + } +} diff --git a/evetest/broker/image.go b/evetest/broker/image.go index d66a13e80bb..059475c5151 100644 --- a/evetest/broker/image.go +++ b/evetest/broker/image.go @@ -10,8 +10,10 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "github.com/google/uuid" "github.com/lf-edge/eve/evetest/broker/provider" api "github.com/lf-edge/eve/evetest/grpcapi/go" "github.com/lf-edge/eve/evetest/utils" @@ -117,13 +119,20 @@ type buildEVEImageParams struct { diskSize uint64 // installer, when true, builds a RAW installer image instead of a live QCOW2 image. installer bool + // softSerial is the device's soft serial number. Always non-empty; see + // resolveSoftSerial. + softSerial string } -// buildEVEImageResult holds the outputs of buildEVEImage (excluding the error). +// buildEVEImageResult holds the outputs of either device-image producer +// (buildEVEImage, the legacy per-device build, or makeDeviceImage, the +// template-backed overlay build) excluding the error. type buildEVEImageResult struct { - // installerImage is non-nil only for installer builds. It points to the RAW - // installer image that is prepended to disks for the first (installer) boot, - // then discarded — subsequent boots use only disks. + // installerImage is non-nil only for installer builds. It is prepended to + // disks for the first (installer) boot, then discarded — subsequent boots + // use only disks. Its Format depends on which producer filled this struct: + // RAW from the legacy buildEVEImage path, QCOW2 from the template-backed + // makeDeviceImage path, where it must be QCOW2 to be a template overlay. installerImage *provider.DiskImage // disks is the list of persistent disk images for the device. Currently always // a single disk (live QCOW2 for live builds, blank target QCOW2 for installer @@ -172,7 +181,7 @@ func buildEVEImage(ctx context.Context, log *logrus.Entry, // mounted at the same path) and can be passed to docker-out-of-docker. var configDir string configDir, err = makeEVEConfigDir( - params.imageDirPath, params.config, params.proxyCACerts) + params.imageDirPath, params.config, params.proxyCACerts, params.softSerial) if err != nil { err = fmt.Errorf("failed to prepare EVE config dir: %w", err) return result, err @@ -283,10 +292,13 @@ func buildEVEImage(ctx context.Context, log *logrus.Entry, // // Certificates are validated before writing. Proxy CA certificates passed in // proxyCACerts are appended to v2tlsbaseroot-certificates.pem. -func makeEVEConfigDir(parentDir string, - config *api.EveConfig, proxyCACerts []*pem.Block) (dirPath string, err error) { +// +// softSerial is passed separately rather than read from config because it is +// never empty: see resolveSoftSerial. +func makeEVEConfigDir(parentDir string, config *api.EveConfig, + proxyCACerts []*pem.Block, softSerial string) (dirPath string, err error) { - if config == nil && len(proxyCACerts) == 0 { + if config == nil && len(proxyCACerts) == 0 && softSerial == "" { return "", nil } @@ -316,42 +328,42 @@ func makeEVEConfigDir(parentDir string, return nil } - err = writeFile("server", []byte(config.ServerName)) + err = writeFile("soft_serial", []byte(softSerial)) if err != nil { return "", err } - err = writeFile("soft_serial", []byte(config.SoftSerial)) + err = writeFile("server", []byte(config.GetServerName())) if err != nil { return "", err } - if len(config.OnboardCertPem) > 0 { - _, err = utils.ValidatePEMCerts([]byte(config.OnboardCertPem), true) + if len(config.GetOnboardCertPem()) > 0 { + _, err = utils.ValidatePEMCerts([]byte(config.GetOnboardCertPem()), true) if err != nil { return "", fmt.Errorf("onboard certificate invalid: %w", err) } - err = writeFile("onboard.cert.pem", []byte(config.OnboardCertPem)) + err = writeFile("onboard.cert.pem", []byte(config.GetOnboardCertPem())) if err != nil { return "", err } } - if len(config.OnboardKeyPem) > 0 { - err = utils.ValidatePEMPrivateKeyECDSA([]byte(config.OnboardKeyPem)) + if len(config.GetOnboardKeyPem()) > 0 { + err = utils.ValidatePEMPrivateKeyECDSA([]byte(config.GetOnboardKeyPem())) if err != nil { return "", fmt.Errorf("onboard key invalid: %w", err) } - err = writeFile("onboard.key.pem", []byte(config.OnboardKeyPem)) + err = writeFile("onboard.key.pem", []byte(config.GetOnboardKeyPem())) if err != nil { return "", err } } - if len(config.RootCertPem) > 0 { - _, err = utils.ValidatePEMCerts([]byte(config.RootCertPem), true) + if len(config.GetRootCertPem()) > 0 { + _, err = utils.ValidatePEMCerts([]byte(config.GetRootCertPem()), true) if err != nil { return "", fmt.Errorf("root certificate invalid: %w", err) } - err = writeFile("root-certificate.pem", []byte(config.RootCertPem)) + err = writeFile("root-certificate.pem", []byte(config.GetRootCertPem())) if err != nil { return "", err } @@ -362,7 +374,7 @@ func makeEVEConfigDir(parentDir string, writeV2TLS := false // Validate and append V2TlsCertsPem - for _, pemStr := range config.V2TlsCertsPem { + for _, pemStr := range config.GetV2TlsCertsPem() { _, err = utils.ValidatePEMCerts([]byte(pemStr), true) if err != nil { return "", fmt.Errorf("v2 TLS certificate invalid: %w", err) @@ -392,32 +404,32 @@ func makeEVEConfigDir(parentDir string, } } - if len(config.SshKeys) > 0 { - keysData := strings.Join(config.SshKeys, "\n") + if len(config.GetSshKeys()) > 0 { + keysData := strings.Join(config.GetSshKeys(), "\n") err = writeFile("authorized_keys", []byte(keysData)) if err != nil { return "", err } } - if len(config.GrubOptions) > 0 { - grubConfig := strings.Join(config.GrubOptions, "\n") + if len(config.GetGrubOptions()) > 0 { + grubConfig := strings.Join(config.GetGrubOptions(), "\n") err = writeFile("grub.cfg", []byte(grubConfig)) if err != nil { return "", err } } - err = writeFile("GlobalConfig/global.json", []byte(config.GlobalJson)) + err = writeFile("GlobalConfig/global.json", []byte(config.GetGlobalJson())) if err != nil { return "", err } - err = writeFile("DevicePortConfig/override.json", []byte(config.OverrideJson)) + err = writeFile("DevicePortConfig/override.json", []byte(config.GetOverrideJson())) if err != nil { return "", err } - if len(config.BootstrapConfigPb) > 0 { - err = writeFile("bootstrap-config.pb", config.BootstrapConfigPb) + if len(config.GetBootstrapConfigPb()) > 0 { + err = writeFile("bootstrap-config.pb", config.GetBootstrapConfigPb()) if err != nil { return "", err } @@ -425,3 +437,395 @@ func makeEVEConfigDir(parentDir string, return dirPath, nil } + +// resolveSoftSerial returns the soft serial number to write into a device's +// config partition. +// +// pkg/eve/runme.sh:158-162 generates one whenever /bits/config.img has none, +// but that runs once per *template* now rather than once per device, so every +// device would otherwise inherit the same serial. Generating here keeps cluster +// nodes distinct. A serial explicitly requested by the test (RequireEdgeDevice. +// WithSoftSerial) is passed through unchanged. +func resolveSoftSerial(requested string) string { + if requested != "" { + return requested + } + return uuid.NewString() +} + +// resizeDeviceDisk grows a device's disk to wantBytes, whether that disk is a +// QCOW2 overlay on a template or a standalone copy of one. For an overlay the +// backing template is untouched -- verified: growing an overlay leaves the +// backing file's virtual size unchanged and allocates nothing, and reads past +// the backing file's end return zeros. Shrinking is refused because it would +// truncate the GPT and data. +func resizeDeviceDisk(ctx context.Context, diskPath string, wantBytes, haveBytes int64) error { + if wantBytes == 0 || wantBytes == haveBytes { + return nil + } + if wantBytes < haveBytes { + return fmt.Errorf( + "requested disk size %d is smaller than the EVE image's %d; shrinking "+ + "would truncate the partition table", wantBytes, haveBytes) + } + out, err := exec.CommandContext(ctx, "qemu-img", "resize", + diskPath, strconv.FormatInt(wantBytes, 10)).CombinedOutput() + if err != nil { + return fmt.Errorf("qemu-img resize of %q failed: %v: %s", diskPath, err, out) + } + return nil +} + +// mcopyArgs builds the mtools invocation that overlays a device's config files +// onto a copy of the pristine config partition image. It mirrors +// pkg/eve/runme.sh:337 -- `mcopy -o -i /bits/config.img -s /in/* ::/` -- with +// the shell glob replaced by an explicit list, so no shell is involved. +func mcopyArgs(cfgImgPath string, configDirEntries []string) []string { + args := make([]string, 0, len(configDirEntries)+5) + args = append(args, "-o", "-i", cfgImgPath, "-s") + args = append(args, configDirEntries...) + return append(args, "::/") +} + +// writeConfigPartition produces the device's config partition image at outPath: +// a copy of the template's pristine config.img with the device's config files +// overlaid onto it. +func writeConfigPartition(ctx context.Context, log *logrus.Entry, + templateConfigImg, configDir, outPath string) (err error) { + + if err = utils.CopyFile(templateConfigImg, outPath); err != nil { + return fmt.Errorf("failed to copy config partition image: %w", err) + } + defer func() { + if err != nil { + if removeErr := os.Remove(outPath); removeErr != nil { + log.Warnf("Failed to remove config partition image %q: %v", + outPath, removeErr) + } + } + }() + entries, err := os.ReadDir(configDir) + if err != nil { + return fmt.Errorf("failed to read config dir %q: %w", configDir, err) + } + if len(entries) == 0 { + return fmt.Errorf("config dir %q is empty", configDir) + } + paths := make([]string, 0, len(entries)) + for _, e := range entries { + paths = append(paths, filepath.Join(configDir, e.Name())) + } + cmd := exec.CommandContext(ctx, "mcopy", mcopyArgs(outPath, paths)...) + // The 5 MiB FAT image has a geometry mtools considers suspicious; the EVE + // build sets the same skip in /etc/mtools.conf (pkg/mkconf/make-config). + cmd.Env = append(os.Environ(), "MTOOLS_SKIP_CHECK=1") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mcopy into %q failed: %v: %s", outPath, err, out) + } + log.Debugf("Wrote config partition image %q from %d config entries", + outPath, len(paths)) + return nil +} + +// injectConfigPartition writes a config partition image into the CONFIG +// partition of a QCOW2 disk. qemu-io is used rather than nbd or libguestfs +// because it needs no kernel module, no /dev access and no privileged +// container -- and it ships in the same Alpine qemu-img package as qemu-img. +func injectConfigPartition(ctx context.Context, log *logrus.Entry, + diskPath, cfgImgPath string, part gptPartition) error { + + info, err := os.Stat(cfgImgPath) + if err != nil { + return fmt.Errorf("failed to stat config partition image: %w", err) + } + if info.Size() > part.Length { + return fmt.Errorf( + "config partition image is %d bytes, larger than the %d-byte CONFIG partition", + info.Size(), part.Length) + } + // qemu-io tokenizes the -c argument itself, so a path containing + // whitespace would be split into separate arguments. + if strings.ContainsAny(cfgImgPath, " \t") { + return fmt.Errorf("config partition image path %q contains whitespace", cfgImgPath) + } + script := fmt.Sprintf("write -s %s %d %d", cfgImgPath, part.Offset, info.Size()) + out, err := exec.CommandContext(ctx, "qemu-io", + "-f", "qcow2", "-c", script, diskPath).CombinedOutput() + if err != nil { + return fmt.Errorf("qemu-io write into %q failed: %v: %s", diskPath, err, out) + } + log.Debugf("Injected %d bytes of config partition at offset %d of %q", + info.Size(), part.Offset, diskPath) + return nil +} + +// buildTemplateDisk runs the EVE container once to produce a +// configuration-independent disk image, and extracts the pristine config +// partition image and UEFI firmware alongside it. This is the expensive step +// the template cache exists to avoid repeating. +// +// No /in volume is mounted, so the disk carries the EVE image's default config +// partition; per-device configuration is written into the working copy later. +func buildTemplateDisk(ctx context.Context, log *logrus.Entry, + dockerImageName string, diskSize uint64, installer bool, + dstDir string) (gptPartition, error) { + + var none gptPartition + + err := utils.ExtractFromDockerImage(ctx, log, dockerImageName, dstDir, "/bits/firmware") + if err != nil { + return none, fmt.Errorf("failed to extract UEFI firmware from %s: %w", + dockerImageName, err) + } + err = utils.ExtractFromDockerImage(ctx, log, dockerImageName, dstDir, "/bits/config.img") + if err != nil { + return none, fmt.Errorf("failed to extract config partition image from %s: %w", + dockerImageName, err) + } + + var builtName, cmd string + if installer { + // Built as QCOW2 rather than RAW so it can back an overlay -- and so a + // sparse 8 GiB installer stops materialising in full per device. + builtName = "installer.raw.qcow2" + cmd = "-f qcow2 installer_raw" + } else { + builtName = "live.raw.qcow2" + cmd = "-f qcow2 live" + if diskSize != 0 { + cmd += fmt.Sprintf(" %d", diskSize>>20) + } + } + + log.Infof("Building EVE image template disk in %q", dstDir) + dockerOutput, err := utils.RunDockerCommand( + ctx, log, dockerImageName, cmd, map[string]string{"/out": dstDir}, "") + if err != nil { + return none, fmt.Errorf("failed to run docker command for EVE image build: %w", err) + } + builtPath := filepath.Join(dstDir, builtName) + info, err := os.Stat(builtPath) + if err != nil { + log.Infof("Docker output:\n%s", dockerOutput) + return none, fmt.Errorf("expected EVE image file %q not found: %w", builtPath, err) + } + if info.Size() == 0 { + log.Infof("Docker output:\n%s", dockerOutput) + return none, fmt.Errorf("EVE image file %q is empty", builtPath) + } + diskPath := filepath.Join(dstDir, templateDiskFile) + if err := os.Rename(builtPath, diskPath); err != nil { + return none, fmt.Errorf("failed to rename %q to %q: %w", builtPath, diskPath, err) + } + + head, err := readDiskHead(ctx, diskPath) + if err != nil { + return none, fmt.Errorf("failed to read GPT of %q: %w", diskPath, err) + } + part, err := findGPTPartition(head, gptConfigPartName) + if err != nil { + return none, fmt.Errorf("failed to locate the CONFIG partition in %q: %w", diskPath, err) + } + cfgInfo, err := os.Stat(filepath.Join(dstDir, templateConfigImgFile)) + if err != nil { + return none, fmt.Errorf("failed to stat the extracted config partition image: %w", err) + } + if cfgInfo.Size() > part.Length { + return none, fmt.Errorf( + "config partition image is %d bytes but the CONFIG partition is only %d", + cfgInfo.Size(), part.Length) + } + log.Infof("EVE image template disk built: CONFIG partition at offset %d, length %d", + part.Offset, part.Length) + return part, nil +} + +// makeDeviceImageParams groups the inputs to makeDeviceImage. +type makeDeviceImageParams struct { + // imageDirPath is the per-device output directory. + imageDirPath string + // dockerImageName is the EVE container image to build the template from. + dockerImageName string + // dockerImageID is that image's content ID, which the template is keyed on. + dockerImageID string + // arch is the device architecture. + arch api.ArchType + // config provides server, certificates, keys and JSON configs. May be nil. + config *api.EveConfig + // proxyCACerts are trusted proxy CA certificates to add to the image. + proxyCACerts []*pem.Block + // softSerial is the device's soft serial. Always non-empty. + softSerial string + // diskSize is the desired disk size in bytes. Zero means the image default. + diskSize uint64 + // installer, when true, produces an installer image plus a blank target disk. + installer bool + // overlay selects a QCOW2 backing-file working copy over a standalone copy. + overlay bool + // liveImageSHA256, when non-empty, selects the live path: the template is + // installed by unpacking liveTarPath instead of running the EVE container, + // and dockerImageName/dockerImageID are ignored. + liveImageSHA256 string + // liveTarPath is the staged upload to unpack when liveImageSHA256 is set. + liveTarPath string + // liveSource, when set, points at the client's own live image files, which + // this broker can read directly; the template is installed from those and + // liveTarPath is never touched. + liveSource *api.LocalLiveImageSource +} + +// makeDeviceImage derives a device's disk image from a cached template: it +// creates the working copy, assembles the device's config partition and writes +// it into the disk's CONFIG partition. The returned templateKey must be passed +// to templateCache.removeRef when the device is torn down. +func makeDeviceImage(ctx context.Context, log *logrus.Entry, cache *templateCache, + refName string, params makeDeviceImageParams) ( + result buildEVEImageResult, templateKey string, err error) { + + build := func(ctx context.Context, log *logrus.Entry, dstDir string) (gptPartition, error) { + return buildTemplateDisk(ctx, log, params.dockerImageName, + params.diskSize, params.installer, dstDir) + } + keyParams := templateKeyParams{ + DockerImageID: params.dockerImageID, + DiskBytes: params.diskSize, + Installer: params.installer, + Arch: params.arch, + } + if params.liveImageSHA256 != "" { + build = unpackLiveTemplate(params.liveTarPath, params.liveImageSHA256) + if params.liveSource != nil { + build = installLocalLiveTemplate(params.liveSource, params.liveImageSHA256) + } + keyParams = liveTemplateKeyParams( + params.liveImageSHA256, params.arch, params.diskSize) + } + + tmpl, err := cache.ensureTemplate(ctx, log, keyParams, build) + if params.liveImageSHA256 != "" { + // Removed on both success and failure: a tar that failed to install is + // unusable and must not wedge this hash for every later request until the + // broker restarts. Attempted even when the template came from the client's + // own files, so an earlier run's upload of this same hash does not sit + // there unclaimed. On a cache hit, or with no upload involved at all, no + // tar was staged for this call, so the miss is expected and silent; + // anything else is worth a warning. + if rmErr := os.Remove(params.liveTarPath); rmErr != nil && !os.IsNotExist(rmErr) { + log.Warnf("Failed to remove staged live image upload %q: %v", + params.liveTarPath, rmErr) + } + } + if err != nil { + return result, "", err + } + + if err = os.MkdirAll(params.imageDirPath, 0o755); err != nil { + return result, "", fmt.Errorf("failed to create device image dir %q: %w", + params.imageDirPath, err) + } + // From here on the device dir and the template ref are both owned by the + // caller's teardown path only once we return successfully. + defer func() { + if err != nil { + if rmErr := os.RemoveAll(params.imageDirPath); rmErr != nil { + log.Warnf("Failed to remove device image dir %q: %v", + params.imageDirPath, rmErr) + } + if rmErr := cache.removeRef(tmpl.Key, refName); rmErr != nil { + log.Warnf("Failed to release template ref: %v", rmErr) + } + } + }() + if err = cache.addRef(tmpl.Key, refName); err != nil { + return result, "", err + } + + diskPath := filepath.Join(params.imageDirPath, "disk.qcow2") + if params.overlay { + out, cmdErr := exec.CommandContext(ctx, "qemu-img", "create", + "-f", "qcow2", "-b", tmpl.diskPath(), "-F", "qcow2", diskPath).CombinedOutput() + if cmdErr != nil { + err = fmt.Errorf("failed to create overlay %q: %v: %s", diskPath, cmdErr, out) + return result, "", err + } + } else if err = utils.CopyFile(tmpl.diskPath(), diskPath); err != nil { + err = fmt.Errorf("failed to copy template disk to %q: %w", diskPath, err) + return result, "", err + } + // Only the live path needs this, and it needs it for a standalone copy just + // as much as for an overlay: a live template is deliberately keyed without + // the disk size (see liveTemplateKeyParams), so one template serves every + // requested size and the per-device disk is what carries it. A template built + // from the EVE container is already built at params.diskSize. + if params.liveImageSHA256 != "" { + if resizeErr := resizeDeviceDisk(ctx, diskPath, + int64(params.diskSize), tmpl.Meta.DiskVirtualBytes); resizeErr != nil { + err = resizeErr + return result, "", err + } + } + + var configDir string + configDir, err = makeEVEConfigDir( + params.imageDirPath, params.config, params.proxyCACerts, params.softSerial) + if err != nil { + err = fmt.Errorf("failed to prepare EVE config dir: %w", err) + return result, "", err + } + defer os.RemoveAll(configDir) + + // The config partition image is staged outside the device directory: its + // path is passed through qemu-io's own tokenizer, and the device directory + // name embeds a test-supplied device name. + var cfgDir string + cfgDir, err = os.MkdirTemp("", "evetest-cfgpart-*") + if err != nil { + err = fmt.Errorf("failed to create temp dir for the config partition: %w", err) + return result, "", err + } + defer os.RemoveAll(cfgDir) + cfgImgPath := filepath.Join(cfgDir, "config.img") + if err = writeConfigPartition(ctx, log, tmpl.configImgPath(), configDir, cfgImgPath); err != nil { + return result, "", err + } + if err = injectConfigPartition(ctx, log, diskPath, cfgImgPath, tmpl.configPartition()); err != nil { + return result, "", err + } + + // OVMF_VARS.fd is attached as libvirt NVRAM and written by the running VM, + // so each device needs its own copy of the firmware directory. + result.firmwareDir = filepath.Join(params.imageDirPath, templateFirmwareDir) + if err = utils.CopyFolder(tmpl.firmwareDir(), result.firmwareDir); err != nil { + err = fmt.Errorf("failed to copy UEFI firmware: %w", err) + return result, "", err + } + + if !params.installer { + result.disks = []provider.DiskImage{ + {Format: provider.DiskImageFormatQcow2, Path: diskPath}, + } + return result, tmpl.Key, nil + } + + if params.diskSize == 0 { + err = fmt.Errorf("diskSize must be non-zero for installer builds") + return result, "", err + } + targetDiskPath := filepath.Join(params.imageDirPath, "installed.qcow2") + diskSizeMiB := params.diskSize >> 20 + out, cmdErr := exec.CommandContext(ctx, "qemu-img", "create", "-f", "qcow2", + targetDiskPath, fmt.Sprintf("%dM", diskSizeMiB)).CombinedOutput() + if cmdErr != nil { + err = fmt.Errorf("failed to create installer target disk %q: %v: %s", + targetDiskPath, cmdErr, out) + return result, "", err + } + installerImage := provider.DiskImage{ + Format: provider.DiskImageFormatQcow2, Path: diskPath} + result.installerImage = &installerImage + result.disks = []provider.DiskImage{ + {Format: provider.DiskImageFormatQcow2, Path: targetDiskPath}, + } + return result, tmpl.Key, nil +} diff --git a/evetest/broker/image_test.go b/evetest/broker/image_test.go new file mode 100644 index 00000000000..23e65e4a0d9 --- /dev/null +++ b/evetest/broker/image_test.go @@ -0,0 +1,234 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +func TestResolveSoftSerialGeneratesWhenEmpty(t *testing.T) { + got := resolveSoftSerial("") + if got == "" { + t.Fatal("resolveSoftSerial(\"\") returned an empty serial") + } + if _, err := uuid.Parse(got); err != nil { + t.Errorf("generated serial %q is not a UUID: %v", got, err) + } +} + +// TestResolveSoftSerialIsUniquePerCall is the property that keeps cluster nodes +// distinct: the EVE container used to generate a serial per build, but it now +// runs once per template, so the broker must generate one per working copy. +func TestResolveSoftSerialIsUniquePerCall(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 100; i++ { + s := resolveSoftSerial("") + if _, dup := seen[s]; dup { + t.Fatalf("resolveSoftSerial produced a duplicate serial %q", s) + } + seen[s] = struct{}{} + } +} + +func TestResolveSoftSerialHonoursRequested(t *testing.T) { + const want = "my-fixed-serial" + if got := resolveSoftSerial(want); got != want { + t.Errorf("resolveSoftSerial(%q) = %q, want it passed through", want, got) + } +} + +// TestMcopyArgs pins the command shape against pkg/eve/runme.sh:337, +// +// mcopy -o -i /bits/config.img -s /in/* ::/ +// +// with the shell glob replaced by an explicit list of the config dir's +// top-level entries. +func TestMcopyArgs(t *testing.T) { + got := mcopyArgs("/work/cfg.img", []string{"/in/server", "/in/GlobalConfig"}) + want := []string{"-o", "-i", "/work/cfg.img", "-s", "/in/server", "/in/GlobalConfig", "::/"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("mcopyArgs() = %v, want %v", got, want) + } +} + +func TestMakeEVEConfigDirWritesSoftSerial(t *testing.T) { + parent := t.TempDir() + dir, err := makeEVEConfigDir(parent, nil, nil, "serial-1234") + if err != nil { + t.Fatalf("makeEVEConfigDir: %v", err) + } + if dir == "" { + t.Fatal("makeEVEConfigDir returned no directory despite a soft serial") + } + data, err := os.ReadFile(filepath.Join(dir, "soft_serial")) + if err != nil { + t.Fatalf("read soft_serial: %v", err) + } + if string(data) != "serial-1234" { + t.Errorf("soft_serial = %q, want %q", data, "serial-1234") + } +} + +// selfSignedTestCertPEM generates a throwaway self-signed certificate PEM +// block, so tests needing a valid certificate don't depend on a fixture file. +func selfSignedTestCertPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "evetest-proxy-ca"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// TestMakeEVEConfigDirNilConfigStillWritesProxyCerts covers a nil per-request +// EveConfig combined with broker-wide proxy CA certificates: the certs must +// still be written, since b.proxyCACerts exists independently of any request. +func TestMakeEVEConfigDirNilConfigStillWritesProxyCerts(t *testing.T) { + certPEM := selfSignedTestCertPEM(t) + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("failed to decode the generated test CA PEM") + } + dir, err := makeEVEConfigDir(t.TempDir(), nil, []*pem.Block{block}, "serial-1") + if err != nil { + t.Fatalf("makeEVEConfigDir: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "v2tlsbaseroot-certificates.pem")) + if err != nil { + t.Fatalf("proxy CA certs were not written with a nil config: %v", err) + } + if len(data) == 0 { + t.Error("v2tlsbaseroot-certificates.pem is empty") + } +} + +// TestInjectConfigPartitionRejectsOversizedImage covers a config image larger +// than the CONFIG partition: it must fail rather than write past the partition. +func TestInjectConfigPartitionRejectsOversizedImage(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config.img") + if err := os.WriteFile(cfg, make([]byte, 1024), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + err := injectConfigPartition(context.Background(), log, + filepath.Join(dir, "disk.qcow2"), cfg, gptPartition{Offset: 0, Length: 512}) + if err == nil { + t.Fatal("expected an error when the config image exceeds the partition") + } +} + +// TestInjectConfigPartitionRejectsWhitespacePath covers a path that qemu-io's +// own tokenizer would split into separate arguments. +func TestInjectConfigPartitionRejectsWhitespacePath(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config file.img") + if err := os.WriteFile(cfg, make([]byte, 512), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + err := injectConfigPartition(context.Background(), log, + filepath.Join(dir, "disk.qcow2"), cfg, gptPartition{Offset: 0, Length: 5 << 20}) + if err == nil { + t.Fatal("expected an error for a config image path containing whitespace") + } + if !strings.Contains(err.Error(), "whitespace") { + t.Errorf("error should name the whitespace problem, got: %v", err) + } +} + +func TestResizeDeviceDiskRejectsShrink(t *testing.T) { + err := resizeDeviceDisk(context.Background(), "/nonexistent.qcow2", 1<<30, 4<<30) + if err == nil { + t.Fatal("expected an error when the requested size is smaller than the image") + } + if !strings.Contains(err.Error(), "smaller") { + t.Errorf("error should explain the shrink, got: %v", err) + } +} + +// TestResizeDeviceDiskNoopWhenEqual covers the common case: no qemu-img call at +// all, so a nonexistent path is fine. +func TestResizeDeviceDiskNoopWhenEqual(t *testing.T) { + if err := resizeDeviceDisk(context.Background(), "/nonexistent.qcow2", 4<<30, 4<<30); err != nil { + t.Fatalf("equal sizes should be a no-op, got: %v", err) + } + if err := resizeDeviceDisk(context.Background(), "/nonexistent.qcow2", 0, 4<<30); err != nil { + t.Fatalf("zero request should be a no-op, got: %v", err) + } +} + +// TestResizeDeviceDiskGrowsStandaloneCopy covers the standalone strategy, where +// the device disk is a plain copy of the template rather than an overlay on it. +// A live template is keyed without the disk size, so this resize is the only +// thing that gives such a device the size the test asked for -- a copy that is +// never grown silently boots at the template's size instead. +func TestResizeDeviceDiskGrowsStandaloneCopy(t *testing.T) { + const haveBytes = 64 << 20 + const wantBytes = 128 << 20 + diskPath := filepath.Join(t.TempDir(), "disk.qcow2") + out, err := exec.Command("qemu-img", "create", "-f", "qcow2", + diskPath, strconv.Itoa(haveBytes)).CombinedOutput() + if err != nil { + t.Fatalf("qemu-img create: %v: %s", err, out) + } + + if err := resizeDeviceDisk( + context.Background(), diskPath, wantBytes, haveBytes); err != nil { + t.Fatalf("resizeDeviceDisk: %v", err) + } + + out, err = exec.Command("qemu-img", "info", "--output=json", diskPath).Output() + if err != nil { + t.Fatalf("qemu-img info: %v", err) + } + var info struct { + VirtualSize int64 `json:"virtual-size"` + BackingFile string `json:"backing-filename"` + ActualSize int64 `json:"actual-size"` + ClusterSize int64 `json:"cluster-size"` + DirtyFlagFlag bool `json:"dirty-flag"` + } + if err := json.Unmarshal(out, &info); err != nil { + t.Fatalf("parse qemu-img info: %v", err) + } + if info.VirtualSize != wantBytes { + t.Errorf("virtual size = %d, want %d", info.VirtualSize, wantBytes) + } + if info.BackingFile != "" { + t.Errorf("a standalone copy must have no backing file, got %q", info.BackingFile) + } +} diff --git a/evetest/broker/imagecleanup.go b/evetest/broker/imagecleanup.go index cdfec3a88a2..3a065221c55 100644 --- a/evetest/broker/imagecleanup.go +++ b/evetest/broker/imagecleanup.go @@ -143,6 +143,7 @@ func (b *broker) runImageCleanupLoop() { defer ticker.Stop() for range ticker.C { b.cleanupDockerImages(context.Background()) + b.cleanupTemplates(context.Background()) } } @@ -267,17 +268,95 @@ func (b *broker) dockerDiskUsagePercent(ctx context.Context, cli *client.Client) err = fmt.Errorf("docker did not report its root directory") return 0, "", err } + percent, err = diskUsagePercent(dockerRoot) + return percent, dockerRoot, err +} + +// diskUsagePercent returns the used-space percentage of the filesystem backing path. +func diskUsagePercent(path string) (int, error) { var stat syscall.Statfs_t - if err := syscall.Statfs(dockerRoot, &stat); err != nil { - err = fmt.Errorf("failed to stat %q: %w", dockerRoot, err) - return 0, dockerRoot, err + if err := syscall.Statfs(path, &stat); err != nil { + return 0, fmt.Errorf("failed to stat %q: %w", path, err) } total := stat.Blocks * uint64(stat.Bsize) //nolint:unconvert free := stat.Bfree * uint64(stat.Bsize) //nolint:unconvert if total == 0 { - err = fmt.Errorf("statfs reported zero total blocks for %q", dockerRoot) - return 0, dockerRoot, err + return 0, fmt.Errorf("statfs reported zero total blocks for %q", path) + } + return int((total - free) * 100 / total), nil +} + +// cleanupTemplates runs one template cleanup pass, mirroring +// cleanupDockerImages: an age-based sweep (skipped when b.tmplRetention is +// non-positive, an explicit "disable age-based eviction"), then a +// disk-pressure sweep that removes the oldest remaining templates until usage +// is back under the threshold -- run regardless of b.tmplRetention, since a +// disabled age sweep must not also disable the broker's only protection +// against filling the disk. Templates still referenced by a live working copy +// are never candidates. +func (b *broker) cleanupTemplates(_ context.Context) { + log := b.globalLog + if b.templates == nil { + return + } + candidates := b.templates.candidates() + if len(candidates) == 0 { + return + } + + evict := func(key, reason string) bool { + if err := b.templates.evict(key); err != nil { + log.Warnf("Template cleanup: %v", err) + return false + } + log.Infof("Template cleanup: removed unused template %q (%s)", key, reason) + return true + } + + evicted := make(map[string]struct{}) + if b.tmplRetention > 0 { + ageExpired := selectAgeExpiredImages(candidates, time.Now(), b.tmplRetention) + for _, key := range ageExpired { + if evict(key, "age") { + evicted[key] = struct{}{} + } + } + } + + usagePercent, err := b.imageDirUsagePercent() + if err != nil { + log.Warnf("Template cleanup: failed to check disk usage: %v", err) + return } - used := total - free - return int(used * 100 / total), dockerRoot, nil + if usagePercent < b.tmplDiskThresholdPct { + return + } + log.Warnf("Template cleanup: disk usage at %d%% on %s (threshold %d%%), "+ + "evicting oldest unused templates", + usagePercent, b.imageDir, b.tmplDiskThresholdPct) + + var remaining []imageCandidate + for _, c := range candidates { + if _, done := evicted[c.ID]; !done { + remaining = append(remaining, c) + } + } + for _, key := range orderImagesOldestFirst(remaining) { + evict(key, "disk pressure") + usagePercent, err = b.imageDirUsagePercent() + if err != nil { + log.Warnf("Template cleanup: failed to re-check disk usage: %v", err) + return + } + if usagePercent < b.tmplDiskThresholdPct { + return + } + } +} + +// imageDirUsagePercent returns the disk usage percentage of the filesystem +// backing the broker's image directory, which is where templates and working +// copies live -- not necessarily the same filesystem as Docker's storage. +func (b *broker) imageDirUsagePercent() (int, error) { + return diskUsagePercent(b.imageDir) } diff --git a/evetest/broker/imagetemplate.go b/evetest/broker/imagetemplate.go new file mode 100644 index 00000000000..9fdd9a89fa4 --- /dev/null +++ b/evetest/broker/imagetemplate.go @@ -0,0 +1,744 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "math/rand/v2" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/sirupsen/logrus" + + "github.com/lf-edge/eve/evetest/constants" + api "github.com/lf-edge/eve/evetest/grpcapi/go" +) + +const ( + // templateFormatVersion is bumped whenever the template directory layout + // changes, or the way the template disk itself is built changes. It is part + // of the cache key, so bumping it makes every existing template a miss + // rather than a subtly wrong hit. + templateFormatVersion = 1 + + // templatesSubdir is where templates live under the broker's image dir. + // Deliberately a sibling of the per-device directories, which teardown + // removes wholesale. + templatesSubdir = "templates" + + // templateMetaFile records what a template is and where its CONFIG + // partition sits. + templateMetaFile = "meta.json" + // templateDiskFile is the config-independent EVE disk image. + templateDiskFile = "disk.qcow2" + // templateConfigImgFile is the pristine /bits/config.img extracted from the + // EVE container: the 5 MiB FAT image that per-device config is overlaid on. + templateConfigImgFile = "config.img" + // templateFirmwareDir holds the UEFI firmware extracted from /bits/firmware. + templateFirmwareDir = "firmware" + // templateRefsDir holds one empty marker file per live working copy backed + // by this template; a template with any refs is never evicted. + templateRefsDir = "refs" + // templateTmpPrefix marks an in-progress build, renamed into place on + // success. Leftovers are swept at broker startup. + templateTmpPrefix = ".tmp-" +) + +// templateKeyParams is everything that makes two template disks differ. +// +// Device configuration is deliberately absent: grub options, global.json, +// certificates and the soft serial all live in the CONFIG partition, which is +// written into the per-device working copy after the fact. Adding any of them +// here would defeat the cache. Platform is absent because the broker never +// passes -p to the EVE container; if that changes, it must be added here. +type templateKeyParams struct { + DockerImageID string + // LiveImageSHA256 identifies a locally built live image. Exactly one of + // this and DockerImageID is set. Note DiskBytes is left zero on this path: + // size is applied per device by resizing the overlay, so one template + // serves every requested size. + LiveImageSHA256 string + DiskBytes uint64 + Installer bool + Arch api.ArchType +} + +// computeTemplateKey derives the cache key. The hash is truncated to 32 hex +// characters: it names a directory, and collisions are not adversarial here. +func computeTemplateKey(p templateKeyParams) string { + h := sha256.New() + fmt.Fprintf(h, "v%d\n%s\n%s\n%d\n%t\n%s\n", + templateFormatVersion, p.DockerImageID, p.LiveImageSHA256, + p.DiskBytes, p.Installer, p.Arch) + return hex.EncodeToString(h.Sum(nil))[:32] +} + +// liveTemplateKeyParams builds the cache key inputs for a locally built live +// image. diskSize is accepted and deliberately dropped: the live path sizes +// each device by resizing its overlay, so one template serves every requested +// size. +func liveTemplateKeyParams(sha256 string, arch api.ArchType, diskSize uint64) templateKeyParams { + _ = diskSize + return templateKeyParams{LiveImageSHA256: sha256, Arch: arch} +} + +// templateMeta is the on-disk description of a built template. +type templateMeta struct { + FormatVersion int `json:"formatVersion"` + Key string `json:"key"` + DockerImageID string `json:"dockerImageId"` + LiveImageSHA256 string `json:"liveImageSha256"` + DiskBytes uint64 `json:"diskBytes"` + Installer bool `json:"installer"` + Arch string `json:"arch"` + ConfigOffset int64 `json:"configOffset"` + ConfigLength int64 `json:"configLength"` + DiskVirtualBytes int64 `json:"diskVirtualBytes"` + BuiltAt time.Time `json:"builtAt"` + LastUsed time.Time `json:"lastUsed"` +} + +// save writes the metadata into a template directory. +func (m templateMeta) save(dir string) error { + data, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("failed to marshal template metadata: %w", err) + } + path := filepath.Join(dir, templateMetaFile) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("failed to write %q: %w", path, err) + } + return nil +} + +// loadTemplateMeta reads and validates a template's metadata. Anything +// unreadable, malformed or produced by an older broker is an error, so that +// callers rebuild rather than trust it. +// +// Callers distinguishing "no template yet" from "corrupt template" must use +// errors.Is(err, fs.ErrNotExist), NOT os.IsNotExist: the latter only unwraps +// *PathError/*LinkError/*SyscallError and so cannot see through the %w wrap +// below. +func loadTemplateMeta(dir string) (templateMeta, error) { + var m templateMeta + path := filepath.Join(dir, templateMetaFile) + data, err := os.ReadFile(path) + if err != nil { + return m, fmt.Errorf("failed to read %q: %w", path, err) + } + if err := json.Unmarshal(data, &m); err != nil { + return m, fmt.Errorf("failed to parse %q: %w", path, err) + } + if m.FormatVersion != templateFormatVersion { + return m, fmt.Errorf("template %q has format version %d, want %d", + dir, m.FormatVersion, templateFormatVersion) + } + if m.ConfigLength <= 0 { + return m, fmt.Errorf("template %q records a non-positive CONFIG length %d", + dir, m.ConfigLength) + } + if m.DiskVirtualBytes <= 0 { + return m, fmt.Errorf("template %q has no recorded disk virtual size", dir) + } + return m, nil +} + +// templateRef points at an installed template directory. +type templateRef struct { + Key string + Dir string + Meta templateMeta +} + +func (t *templateRef) diskPath() string { return filepath.Join(t.Dir, templateDiskFile) } +func (t *templateRef) configImgPath() string { return filepath.Join(t.Dir, templateConfigImgFile) } +func (t *templateRef) firmwareDir() string { return filepath.Join(t.Dir, templateFirmwareDir) } +func (t *templateRef) refsDir() string { return filepath.Join(t.Dir, templateRefsDir) } + +// configPartition returns where the CONFIG partition sits inside the template +// disk, as recorded at build time. +func (t *templateRef) configPartition() gptPartition { + return gptPartition{Offset: t.Meta.ConfigOffset, Length: t.Meta.ConfigLength} +} + +// templateBuilder populates dstDir with templateDiskFile, templateConfigImgFile +// and templateFirmwareDir, and returns where the CONFIG partition sits inside +// the disk it produced. +type templateBuilder func( + ctx context.Context, log *logrus.Entry, dstDir string) (gptPartition, error) + +// templateCache stores config-independent EVE disk images, keyed by +// computeTemplateKey, so the expensive EVE container build runs once per +// distinct image rather than once per device. +type templateCache struct { + dir string + log *logrus.Logger + + // imageDir is the parent of dir; the ownership lock lives there because + // dir may not exist yet. + imageDir string + // owner is true when this process holds the exclusive lock on imageDir and + // may therefore do housekeeping (clearing stale refs, evicting templates). + // A second broker sharing the directory runs with owner false: it still + // reads and creates templates, but must never delete anything, or it can + // pull a backing file out from under the owner's running VMs. tryLock is + // only ever attempted once (from newBroker), so if the owning broker exits, + // a non-owner never becomes owner and housekeeping stays off for the + // lifetime of this process. + owner bool + lockFile *os.File + + // mutex protects inFlight and serializes cache lookups. It is never held + // while a build runs. + mutex sync.Mutex + inFlight map[string]*templateBuildState + + // refsMutex guards addRef, removeRef, and evict's check-and-delete of a + // template's refs directory, as one critical section. It is deliberately + // not mutex: hasRefs is reachable via loadInstalledLocked -> + // discardUnusable while mutex is held, and Go mutexes are not reentrant, + // so guarding hasRefs with mutex would self-deadlock. + // + // Lock ordering: mutex may be held while acquiring refsMutex (that is + // exactly the loadInstalledLocked -> discardUnusable -> hasRefs path). + // Nothing holding refsMutex may ever acquire mutex. + refsMutex sync.Mutex +} + +// templateBuildState lets concurrent callers wanting the same template wait on +// one build instead of each starting their own. +type templateBuildState struct { + done chan struct{} + ref *templateRef + err error +} + +func newTemplateCache(imageDir string, log *logrus.Logger) *templateCache { + return &templateCache{ + dir: filepath.Join(imageDir, templatesSubdir), + log: log, + imageDir: imageDir, + inFlight: make(map[string]*templateBuildState), + } +} + +// templateDir is where the template for a key lives once installed. +func (c *templateCache) templateDir(key string) string { + return filepath.Join(c.dir, key) +} + +// waiterLogInterval is how often a caller waiting on someone else's in-flight +// template build logs, so a long wait (builds can take most of +// brokerBuildImageTimeout) is not mistaken for a hang. +const waiterLogInterval = 30 * time.Second + +// ensureTemplate returns the template for params, building it with build if it +// is not already cached. Concurrent callers for the same key share one build. +func (c *templateCache) ensureTemplate(ctx context.Context, log *logrus.Entry, + params templateKeyParams, build templateBuilder) (*templateRef, error) { + return c.ensureTemplateAttempt(ctx, log, params, build, 0) +} + +// ensureTemplateAttempt is ensureTemplate's body. attempt distinguishes a +// waiter's one allowed retry (see below) from the original call, and is +// always 0 from ensureTemplate; it must never be threaded any further so the +// retry cannot recurse more than once. +func (c *templateCache) ensureTemplateAttempt(ctx context.Context, log *logrus.Entry, + params templateKeyParams, build templateBuilder, attempt int) (*templateRef, error) { + + key := computeTemplateKey(params) + + c.mutex.Lock() + if ref, ok := c.loadInstalledLocked(log, key, true); ok { + c.mutex.Unlock() + log.Infof("Reusing cached EVE image template %q", key) + return ref, nil + } + if state, building := c.inFlight[key]; building { + c.mutex.Unlock() + log.Infof("Waiting for an in-progress build of EVE image template %q", key) + ticker := time.NewTicker(waiterLogInterval) + defer ticker.Stop() + for { + select { + case <-state.done: + if state.err != nil && attempt == 0 { + // The client that started this build failed or disconnected; that + // is not this caller's failure. Retry once as a fresh caller rather + // than propagating an error that has nothing to do with this request. + log.Warnf("The in-progress build of EVE image template %q this call was "+ + "waiting on failed for the client that started it (%v); retrying once", + key, state.err) + return c.ensureTemplateAttempt(ctx, log, params, build, attempt+1) + } + return state.ref, state.err + case <-ticker.C: + log.Infof("Still waiting for an in-progress build of EVE image template %q", key) + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + state := &templateBuildState{done: make(chan struct{})} + c.inFlight[key] = state + c.mutex.Unlock() + + if !c.owner { + log.Warnf("This broker does not own the image directory %q and will not clear "+ + "stale references or evict templates; building without housekeeping", c.imageDir) + } + if params.LiveImageSHA256 != "" { + // Which live image source is used -- an upload, or the client's own files + // read in place -- is the builder's business; it logs that itself. + log.Infof("Installing EVE image template %q from an EVE live image", key) + } else { + log.Infof("Building EVE image template %q", key) + } + state.ref, state.err = c.buildAndInstall(ctx, log, key, params, build) + + c.mutex.Lock() + delete(c.inFlight, key) + c.mutex.Unlock() + close(state.done) + + return state.ref, state.err +} + +// hasTemplate reports whether a usable template for these params is +// installed. It is a read-only predicate: it must not refresh LastUsed, so it +// does not compete with real lookups for a meta.json write while c.mutex is +// held. +func (c *templateCache) hasTemplate(params templateKeyParams) bool { + c.mutex.Lock() + defer c.mutex.Unlock() + _, ok := c.loadInstalledLocked(c.log.WithField("check", "hasTemplate"), + computeTemplateKey(params), false) + return ok +} + +// loadInstalledLocked returns an already-installed template, or false if it is +// absent, incomplete or unreadable. Callers must hold c.mutex. touch is false +// only for hasTemplate's read-only check; every genuine lookup passes true so +// LastUsed keeps tracking real access, since age-based eviction depends on it. +func (c *templateCache) loadInstalledLocked(log *logrus.Entry, key string, touch bool) (*templateRef, bool) { + dir := c.templateDir(key) + meta, err := loadTemplateMeta(dir) + if err != nil { + // errors.Is, not os.IsNotExist: loadTemplateMeta wraps os.ReadFile's + // error with %w, which os.IsNotExist cannot see through. + if !errors.Is(err, fs.ErrNotExist) { + c.discardUnusable(log, key, err.Error()) + } + return nil, false + } + ref := &templateRef{Key: key, Dir: dir, Meta: meta} + for _, p := range []string{ref.diskPath(), ref.configImgPath(), ref.firmwareDir()} { + if _, err := os.Stat(p); err != nil { + c.discardUnusable(log, key, fmt.Sprintf("incomplete: %v", err)) + return nil, false + } + } + if touch { + meta.LastUsed = time.Now() + if err := meta.save(dir); err != nil { + log.Warnf("Failed to record last-used time for template %q: %v", key, err) + } + } + ref.Meta = meta + return ref, true +} + +// discardUnusable removes a template that cannot be used, unless doing so would +// be unsafe: a template with live references may still back a running VM, and a +// non-owner broker must never delete anything. In those cases it is left in +// place and the caller rebuilds instead. +func (c *templateCache) discardUnusable(log *logrus.Entry, key, reason string) { + if !c.owner { + log.Warnf("EVE image template %q is unusable (%s) but this broker does not own "+ + "the image directory; leaving it in place", key, reason) + return + } + if c.hasRefs(key) { + log.Warnf("EVE image template %q is unusable (%s) but is still referenced by a "+ + "working copy; leaving it in place", key, reason) + return + } + dir := c.templateDir(key) + if err := os.RemoveAll(dir); err != nil { + log.Warnf("Failed to remove unusable template %q: %v", dir, err) + return + } + log.Infof("Removed unusable EVE image template %q (%s)", key, reason) +} + +// readDiskVirtualSize reads the virtual size of a QCOW2 disk using qemu-img info. +func readDiskVirtualSize(ctx context.Context, diskPath string) (int64, error) { + out, err := exec.CommandContext(ctx, "qemu-img", "info", "--output=json", diskPath).CombinedOutput() + if err != nil { + return 0, fmt.Errorf("qemu-img info failed: %v: %s", err, out) + } + var info struct { + VirtualSize int64 `json:"virtual-size"` + } + if err := json.Unmarshal(out, &info); err != nil { + return 0, fmt.Errorf("failed to parse qemu-img output: %w", err) + } + return info.VirtualSize, nil +} + +// buildAndInstall builds into a temporary directory and renames it into place, +// so a partially built template is never visible to another caller or to a +// later broker run. +func (c *templateCache) buildAndInstall(ctx context.Context, log *logrus.Entry, + key string, params templateKeyParams, build templateBuilder) (*templateRef, error) { + + if err := os.MkdirAll(c.dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create template dir %q: %w", c.dir, err) + } + tmpDir := filepath.Join(c.dir, fmt.Sprintf("%s%s-%d", templateTmpPrefix, key, rand.Uint32())) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create temp template dir %q: %w", tmpDir, err) + } + installed := false + defer func() { + if !installed { + if err := os.RemoveAll(tmpDir); err != nil { + log.Warnf("Failed to remove temp template dir %q: %v", tmpDir, err) + } + } + }() + + configPart, err := build(ctx, log, tmpDir) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(tmpDir, templateRefsDir), 0o755); err != nil { + return nil, fmt.Errorf("failed to create refs dir: %w", err) + } + diskPath := filepath.Join(tmpDir, templateDiskFile) + diskVirtualSize, err := readDiskVirtualSize(ctx, diskPath) + if err != nil { + return nil, fmt.Errorf("failed to read disk virtual size: %w", err) + } + now := time.Now() + meta := templateMeta{ + FormatVersion: templateFormatVersion, + Key: key, + DockerImageID: params.DockerImageID, + LiveImageSHA256: params.LiveImageSHA256, + DiskBytes: params.DiskBytes, + Installer: params.Installer, + Arch: params.Arch.String(), + ConfigOffset: configPart.Offset, + ConfigLength: configPart.Length, + DiskVirtualBytes: diskVirtualSize, + BuiltAt: now, + LastUsed: now, + } + if err := meta.save(tmpDir); err != nil { + return nil, err + } + + dstDir := c.templateDir(key) + if err := os.Rename(tmpDir, dstDir); err != nil { + // A non-empty destination means another broker sharing this image + // directory installed this key first. Its template is complete and may + // already back a running VM, so adopt it and let the deferred cleanup + // discard ours. Never RemoveAll the destination to make room: that + // would delete a template out from under the other broker. + if !errors.Is(err, fs.ErrExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return nil, fmt.Errorf("failed to install template into %q: %w", dstDir, err) + } + c.mutex.Lock() + ref, ok := c.loadInstalledLocked(log, key, true) + c.mutex.Unlock() + if !ok { + return nil, fmt.Errorf( + "template %q already exists but is unusable and cannot be replaced "+ + "(it may still back a running VM)", key) + } + log.Infof("Adopted concurrently installed EVE image template %q", key) + return ref, nil + } + installed = true + log.Infof("Built EVE image template %q at %q", key, dstDir) + return &templateRef{Key: key, Dir: dstDir, Meta: meta}, nil +} + +// removeStaleTmpDirs deletes in-progress template builds left behind by a +// killed broker. Called once at startup. A non-owner broker must skip this: +// the .tmp-* directories it would sweep may belong to another broker's build +// currently in progress, not to a killed one. +func (c *templateCache) removeStaleTmpDirs() error { + if !c.owner { + return nil + } + entries, err := os.ReadDir(c.dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read template dir %q: %w", c.dir, err) + } + for _, e := range entries { + if !strings.HasPrefix(e.Name(), templateTmpPrefix) { + continue + } + path := filepath.Join(c.dir, e.Name()) + if err := os.RemoveAll(path); err != nil { + c.log.Warnf("Failed to remove stale temp template dir %q: %v", path, err) + continue + } + c.log.Infof("Removed stale temp template dir %q", path) + } + return nil +} + +// templateLockFile is the per-image-directory ownership lock. Exactly one +// broker may do template housekeeping for a given image directory. +const templateLockFile = "broker.lock" + +// tryLock attempts to claim exclusive ownership of the image directory. Failing +// to acquire the lock is not an error: the broker runs on without housekeeping. +// The returned error covers only failures to attempt the lock at all. +func (c *templateCache) tryLock() error { + if err := os.MkdirAll(c.imageDir, 0o755); err != nil { + return fmt.Errorf("failed to create image dir %q: %w", c.imageDir, err) + } + path := filepath.Join(c.imageDir, templateLockFile) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("failed to open %q: %w", path, err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + c.log.Warnf("Another broker owns the image directory %q: this broker will "+ + "use templates but will not clear stale references or evict them. "+ + "To run brokers in parallel, give each its own %s.", + c.imageDir, constants.BrokerImageDirEnv) + return nil + } + c.lockFile = f + c.owner = true + return nil +} + +// unlock releases the ownership lock. +func (c *templateCache) unlock() { + if c.lockFile == nil { + return + } + if err := c.lockFile.Close(); err != nil { + c.log.Warnf("Failed to release the image directory lock: %v", err) + } + c.lockFile = nil + c.owner = false +} + +// validRefName rejects names that are not a single path element, so a +// client-supplied device name cannot escape the refs directory. +func validRefName(refName string) error { + if refName == "" || refName != filepath.Base(refName) || refName == "." || refName == ".." { + return fmt.Errorf("invalid template ref name %q", refName) + } + return nil +} + +// validLiveImageSHA256 rejects anything that is not a plain hex digest. The +// value arrives from the client and is used as a path component, so it must be +// validated before it reaches filepath.Join -- the same reasoning as +// validRefName. +func validLiveImageSHA256(sha string) error { + if len(sha) != 64 { + return fmt.Errorf("invalid live image sha256 %q: expected 64 hex characters", sha) + } + for _, c := range sha { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return fmt.Errorf("invalid live image sha256 %q: not lowercase hex", sha) + } + } + return nil +} + +// addRef records that a live working copy is backed by this template. A +// template with any refs is never evicted. The template must already exist: +// otherwise this would create a bare refs directory that no template ever +// occupies, which candidates() would then skip forever as a permanent leak. +func (c *templateCache) addRef(key, refName string) error { + if err := validRefName(refName); err != nil { + return err + } + c.refsMutex.Lock() + defer c.refsMutex.Unlock() + templateDir := c.templateDir(key) + if _, err := os.Stat(templateDir); err != nil { + return fmt.Errorf("failed to add ref: template %q: %w", key, err) + } + dir := filepath.Join(templateDir, templateRefsDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create refs dir %q: %w", dir, err) + } + path := filepath.Join(dir, refName) + if err := os.WriteFile(path, nil, 0o600); err != nil { + return fmt.Errorf("failed to write template ref %q: %w", path, err) + } + return nil +} + +// removeRef releases a working copy's hold on a template. It is idempotent: +// teardown runs on paths where the working copy may never have been created. +func (c *templateCache) removeRef(key, refName string) error { + if err := validRefName(refName); err != nil { + return err + } + c.refsMutex.Lock() + defer c.refsMutex.Unlock() + path := filepath.Join(c.templateDir(key), templateRefsDir, refName) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove template ref %q: %w", path, err) + } + return nil +} + +// hasRefs reports whether any working copy still depends on this template. An +// unreadable refs dir is reported as referenced, so an I/O problem can never +// cause a template to be deleted out from under a running VM. +func (c *templateCache) hasRefs(key string) bool { + c.refsMutex.Lock() + defer c.refsMutex.Unlock() + return c.hasRefsLocked(key) +} + +// hasRefsLocked is hasRefs' body. Callers must hold refsMutex; evict uses this +// directly so its reference check and the delete that follows form one +// critical section with addRef. +func (c *templateCache) hasRefsLocked(key string) bool { + dir := filepath.Join(c.templateDir(key), templateRefsDir) + entries, err := os.ReadDir(dir) + if err != nil { + return !os.IsNotExist(err) + } + return len(entries) > 0 +} + +// clearAllRefs drops every ref marker. Called once at broker startup: client +// sessions do not survive a restart, so all markers are stale and would +// otherwise pin their templates forever. A non-owner broker must skip this: +// the refs it would clear belong to the owner's live VMs. +// +// This guarantees no *harness-managed* VM ever loses its backing file, not an +// absolute one: libvirt domains do survive a broker restart, and the provider +// does no startup reconciliation against them, so an orphaned domain (e.g. one +// left behind by a failed teardown) can have its template evicted out from +// under it after a restart. That domain is already unmanaged by this point, +// so this is not a regression to fix here. +func (c *templateCache) clearAllRefs() error { + if !c.owner { + return nil + } + entries, err := os.ReadDir(c.dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read template dir %q: %w", c.dir, err) + } + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), templateTmpPrefix) { + continue + } + refsDir := filepath.Join(c.dir, e.Name(), templateRefsDir) + refs, err := os.ReadDir(refsDir) + if err != nil { + continue + } + for _, r := range refs { + path := filepath.Join(refsDir, r.Name()) + if err := os.Remove(path); err != nil { + c.log.Warnf("Failed to clear stale template ref %q: %v", path, err) + continue + } + c.log.Infof("Cleared stale template ref %q", path) + } + } + return nil +} + +// candidates lists templates eligible for eviction: installed, readable and +// unreferenced. imageCandidate is reused so the age and disk-pressure +// selection helpers in imagecleanup.go serve both docker images and templates. +// A non-owner broker never offers candidates: only the owner evicts. +// +// Deliberately unguarded by refsMutex: this only builds a snapshot, which can +// go stale the instant it is taken regardless of locking, and evict() +// re-checks authoritatively before it deletes anything. Do not add refsMutex +// here -- candidates() runs under no lock that could create an ordering cycle +// with mutex today, and it should stay that way. +func (c *templateCache) candidates() []imageCandidate { + if !c.owner { + return nil + } + entries, err := os.ReadDir(c.dir) + if err != nil { + if !os.IsNotExist(err) { + c.log.Warnf("Template cleanup: failed to read %q: %v", c.dir, err) + } + return nil + } + var out []imageCandidate + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), templateTmpPrefix) { + continue + } + key := e.Name() + if c.hasRefs(key) { + continue + } + meta, err := loadTemplateMeta(c.templateDir(key)) + if err != nil { + continue + } + out = append(out, imageCandidate{ID: key, Name: key, LastUsed: meta.LastUsed}) + } + return out +} + +// evict deletes a template directory. +// +// It re-checks the reference count rather than trusting the caller: candidates() +// returns a snapshot, and a client can acquire a reference after that snapshot +// while the sweep is still running. It also re-checks ownership so the +// non-owner guarantee does not depend on candidates() being the only entry point. +// +// The check and the delete happen under a single refsMutex critical section +// so no addRef can land between them: without that, hasRefs could see an +// empty refs dir, addRef could then create a marker, and this would still +// delete a template a device just took a reference on. +func (c *templateCache) evict(key string) error { + if !c.owner { + return nil + } + c.refsMutex.Lock() + defer c.refsMutex.Unlock() + if c.hasRefsLocked(key) { + c.log.Infof("Template cleanup: %q acquired a reference during the sweep; keeping it", key) + return nil + } + dir := c.templateDir(key) + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("failed to remove template %q: %w", dir, err) + } + return nil +} diff --git a/evetest/broker/imagetemplate_test.go b/evetest/broker/imagetemplate_test.go new file mode 100644 index 00000000000..0f994958ae2 --- /dev/null +++ b/evetest/broker/imagetemplate_test.go @@ -0,0 +1,830 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sirupsen/logrus" + + api "github.com/lf-edge/eve/evetest/grpcapi/go" +) + +func baseKeyParams() templateKeyParams { + return templateKeyParams{ + DockerImageID: "sha256:aaaa", + DiskBytes: 30 << 30, + Installer: false, + Arch: api.ArchType_ARCH_AMD64, + } +} + +func TestComputeTemplateKeyIsStable(t *testing.T) { + if computeTemplateKey(baseKeyParams()) != computeTemplateKey(baseKeyParams()) { + t.Fatal("same inputs produced different keys") + } +} + +// TestTemplateKeyParamsFields locks down the property the whole design depends +// on: device configuration must never reach the cache key, so that a test +// changing grub options, global.json, certificates or the soft serial still +// reuses the cached template. Those are not fields of templateKeyParams, and +// this asserts nobody adds one -- comparing two equal structs could not detect +// that, since a new field would be zero in both. +func TestTemplateKeyParamsFields(t *testing.T) { + want := []string{"DockerImageID", "LiveImageSHA256", "DiskBytes", "Installer", "Arch"} + typ := reflect.TypeOf(templateKeyParams{}) + var got []string + for i := 0; i < typ.NumField(); i++ { + got = append(got, typ.Field(i).Name) + } + if !slices.Equal(got, want) { + t.Errorf("templateKeyParams fields = %v, want %v.\n"+ + "Device configuration must not enter the cache key: it is injected "+ + "into the CONFIG partition of the working copy instead. If this is a "+ + "genuinely image-wide input, add it here and bump templateFormatVersion.", + got, want) + } +} + +// TestLiveTemplateKeyParamsIgnoresDiskSize pins the live path's sizing model: +// disk size is applied per device by resizing the overlay, so two devices +// asking for different sizes must resolve to the SAME template. +// +// Note this tests the params helper, not computeTemplateKey -- the hash does +// include DiskBytes, and it is the live path's job never to set it. +func TestLiveTemplateKeyParamsIgnoresDiskSize(t *testing.T) { + small := liveTemplateKeyParams("abc", api.ArchType_ARCH_AMD64, 8<<30) + large := liveTemplateKeyParams("abc", api.ArchType_ARCH_AMD64, 64<<30) + if small.DiskBytes != 0 { + t.Errorf("DiskBytes = %d, want 0: the live path sizes per device", small.DiskBytes) + } + if computeTemplateKey(small) != computeTemplateKey(large) { + t.Error("two disk sizes produced two templates; they must share one") + } + other := liveTemplateKeyParams("def", api.ArchType_ARCH_AMD64, 8<<30) + if computeTemplateKey(small) == computeTemplateKey(other) { + t.Error("different live image hashes produced the same key") + } +} + +func TestComputeTemplateKeyVaries(t *testing.T) { + base := computeTemplateKey(baseKeyParams()) + + tests := []struct { + name string + mutate func(*templateKeyParams) + }{ + {"docker image ID", func(p *templateKeyParams) { p.DockerImageID = "sha256:bbbb" }}, + {"disk size", func(p *templateKeyParams) { p.DiskBytes = 40 << 30 }}, + {"installer flag", func(p *templateKeyParams) { p.Installer = true }}, + {"arch", func(p *templateKeyParams) { p.Arch = api.ArchType_ARCH_ARM64 }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := baseKeyParams() + tc.mutate(&p) + if computeTemplateKey(p) == base { + t.Errorf("changing %s did not change the key", tc.name) + } + }) + } +} + +func TestTemplateMetaRoundTrip(t *testing.T) { + dir := t.TempDir() + want := templateMeta{ + FormatVersion: templateFormatVersion, + Key: "abc123", + DockerImageID: "sha256:aaaa", + DiskBytes: 30 << 30, + Installer: false, + Arch: api.ArchType_ARCH_AMD64.String(), + ConfigOffset: 6291456, + ConfigLength: 5 << 20, + DiskVirtualBytes: 30 << 30, + BuiltAt: time.Now().UTC().Truncate(time.Second), + LastUsed: time.Now().UTC().Truncate(time.Second), + } + if err := want.save(dir); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadTemplateMeta(dir) + if err != nil { + t.Fatalf("loadTemplateMeta: %v", err) + } + if got.Key != want.Key || got.ConfigOffset != want.ConfigOffset || + got.ConfigLength != want.ConfigLength || got.DockerImageID != want.DockerImageID { + t.Errorf("round trip mismatch:\n got %+v\nwant %+v", got, want) + } +} + +func TestLoadTemplateMetaRejectsOldFormatVersion(t *testing.T) { + dir := t.TempDir() + m := templateMeta{FormatVersion: templateFormatVersion - 1, Key: "abc123"} + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, templateMetaFile), data, 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := loadTemplateMeta(dir); err == nil { + t.Fatal("expected an error for an older format version") + } +} + +func TestLoadTemplateMetaRejectsCorruptFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, templateMetaFile), []byte("{not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := loadTemplateMeta(dir); err == nil { + t.Fatal("expected an error for a corrupt meta file") + } +} + +// TestLoadTemplateMetaMissingFile pins the error contract callers rely +// on to tell a cold cache apart from a corrupt one. Note errors.Is, not +// os.IsNotExist: the latter cannot see through loadTemplateMeta's %w wrap and +// would report false here. +func TestLoadTemplateMetaMissingFile(t *testing.T) { + _, err := loadTemplateMeta(t.TempDir()) + if err == nil { + t.Fatal("expected an error for a missing meta file") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("errors.Is(err, fs.ErrNotExist) = false for a missing meta file; "+ + "callers cannot distinguish a cold cache from a corrupt one. err = %v", err) + } + if os.IsNotExist(err) { + t.Error("os.IsNotExist unexpectedly matched the wrapped error; if this " + + "starts passing, the %w wrap was removed and the doc comment on " + + "loadTemplateMeta needs updating") + } +} + +// TestLoadTemplateMetaRejectsMissingDiskVirtualBytes covers a template written +// before the field existed: it must be a clean miss and get rebuilt, rather +// than loading with a zero baseline that silently defeats resizeDeviceDisk's +// shrink check. +func TestLoadTemplateMetaRejectsMissingDiskVirtualBytes(t *testing.T) { + dir := t.TempDir() + m := templateMeta{ + FormatVersion: templateFormatVersion, + Key: "k", + ConfigOffset: 6291456, + ConfigLength: 5 << 20, + } + if err := m.save(dir); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := loadTemplateMeta(dir); err == nil { + t.Fatal("expected an error for a meta with no recorded disk virtual size") + } +} + +func newTestCache(t *testing.T) *templateCache { + t.Helper() + log := logrus.New() + log.SetOutput(io.Discard) + c := newTemplateCache(t.TempDir(), log) + if err := c.tryLock(); err != nil { + t.Fatalf("tryLock: %v", err) + } + t.Cleanup(c.unlock) + return c +} + +// stubBuilder writes the files a real template build produces, and records how +// many times it ran. +func stubBuilder(calls *int32, delay time.Duration) templateBuilder { + return func(_ context.Context, _ *logrus.Entry, dstDir string) (gptPartition, error) { + atomic.AddInt32(calls, 1) + time.Sleep(delay) + for _, name := range []string{templateDiskFile, templateConfigImgFile} { + if err := os.WriteFile(filepath.Join(dstDir, name), []byte("x"), 0o600); err != nil { + return gptPartition{}, err + } + } + if err := os.MkdirAll(filepath.Join(dstDir, templateFirmwareDir), 0o755); err != nil { + return gptPartition{}, err + } + return gptPartition{Offset: 6291456, Length: 5 << 20}, nil + } +} + +func TestEnsureTemplateBuildsOnceThenHits(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + first, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("first ensureTemplate: %v", err) + } + second, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("second ensureTemplate: %v", err) + } + if calls != 1 { + t.Errorf("builder ran %d times, want 1", calls) + } + if first.Dir != second.Dir { + t.Errorf("cache returned different dirs: %q vs %q", first.Dir, second.Dir) + } + if second.Meta.ConfigOffset != 6291456 || second.Meta.ConfigLength != 5<<20 { + t.Errorf("CONFIG partition not persisted: %+v", second.Meta) + } + if _, err := os.Stat(second.diskPath()); err != nil { + t.Errorf("template disk missing: %v", err) + } + if _, err := os.Stat(second.refsDir()); err != nil { + t.Errorf("refs dir missing: %v", err) + } +} + +func TestEnsureTemplateSingleFlight(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + const concurrency = 5 + var wg sync.WaitGroup + errs := make([]error, concurrency) + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = c.ensureTemplate(context.Background(), log, + baseKeyParams(), stubBuilder(&calls, 50*time.Millisecond)) + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("goroutine %d: %v", i, err) + } + } + if calls != 1 { + t.Errorf("builder ran %d times under concurrency, want 1", calls) + } +} + +// TestEnsureTemplateWaiterRetriesAfterLeaderFailure covers the client that +// started a shared build failing or disconnecting: its error must not be +// reported to the other clients waiting on that build. +func TestEnsureTemplateWaiterRetriesAfterLeaderFailure(t *testing.T) { + c := newTestCache(t) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + leaderIn := make(chan struct{}) + var calls int32 + builder := func(_ context.Context, _ *logrus.Entry, dstDir string) (gptPartition, error) { + if atomic.AddInt32(&calls, 1) == 1 { + close(leaderIn) + return gptPartition{}, errors.New("leader disconnected") + } + for _, name := range []string{templateDiskFile, templateConfigImgFile} { + if err := os.WriteFile(filepath.Join(dstDir, name), []byte("x"), 0o600); err != nil { + return gptPartition{}, err + } + } + if err := os.MkdirAll(filepath.Join(dstDir, templateFirmwareDir), 0o755); err != nil { + return gptPartition{}, err + } + return gptPartition{Offset: 6291456, Length: 5 << 20}, nil + } + + var wg sync.WaitGroup + var waiterRef *templateRef + var waiterErr error + wg.Add(1) + go func() { + defer wg.Done() + <-leaderIn + waiterRef, waiterErr = c.ensureTemplate(context.Background(), log, baseKeyParams(), builder) + }() + + _, leaderErr := c.ensureTemplate(context.Background(), log, baseKeyParams(), builder) + if leaderErr == nil { + t.Fatal("the leader's build was supposed to fail") + } + wg.Wait() + if waiterErr != nil { + t.Fatalf("waiter inherited the leader's failure instead of retrying: %v", waiterErr) + } + if waiterRef == nil { + t.Fatal("waiter got no template") + } +} + +func TestEnsureTemplateFailedBuildLeavesNothingBehind(t *testing.T) { + c := newTestCache(t) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + failing := func(_ context.Context, _ *logrus.Entry, dstDir string) (gptPartition, error) { + _ = os.WriteFile(filepath.Join(dstDir, templateDiskFile), []byte("partial"), 0o600) + return gptPartition{}, errors.New("boom") + } + if _, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), failing); err == nil { + t.Fatal("expected the build error to propagate") + } + entries, err := os.ReadDir(c.dir) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read templates dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("failed build left %d entries behind: %v", len(entries), entries) + } + + // A later successful build for the same key must still work. + var calls int32 + if _, err := c.ensureTemplate(context.Background(), log, + baseKeyParams(), stubBuilder(&calls, 0)); err != nil { + t.Fatalf("retry after failure: %v", err) + } +} + +func TestEnsureTemplateRebuildsWhenMetaIsCorrupt(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := os.WriteFile(filepath.Join(ref.Dir, templateMetaFile), []byte("{bad"), 0o600); err != nil { + t.Fatalf("corrupt meta: %v", err) + } + if _, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)); err != nil { + t.Fatalf("ensureTemplate after corruption: %v", err) + } + if calls != 2 { + t.Errorf("builder ran %d times, want 2 (corrupt template must be rebuilt)", calls) + } +} + +func TestEnsureTemplateRebuildsWhenDiskIsMissing(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := os.Remove(ref.diskPath()); err != nil { + t.Fatalf("remove disk: %v", err) + } + if _, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)); err != nil { + t.Fatalf("ensureTemplate after disk removal: %v", err) + } + if calls != 2 { + t.Errorf("builder ran %d times, want 2", calls) + } +} + +func TestRemoveStaleTmpDirs(t *testing.T) { + c := newTestCache(t) + if err := os.MkdirAll(filepath.Join(c.dir, templateTmpPrefix+"abc-1"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(filepath.Join(c.dir, "realkey"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := c.removeStaleTmpDirs(); err != nil { + t.Fatalf("removeStaleTmpDirs: %v", err) + } + if _, err := os.Stat(filepath.Join(c.dir, templateTmpPrefix+"abc-1")); !os.IsNotExist(err) { + t.Error("stale tmp dir was not removed") + } + if _, err := os.Stat(filepath.Join(c.dir, "realkey")); err != nil { + t.Errorf("real template dir was removed: %v", err) + } +} + +// TestNonOwnerDoesNotRemoveStaleTmpDirs covers a second broker starting on a +// shared image directory: the .tmp-* dirs it would sweep may be the owner's +// build in progress, so it must leave them alone. +func TestNonOwnerDoesNotRemoveStaleTmpDirs(t *testing.T) { + imageDir := t.TempDir() + log := logrus.New() + log.SetOutput(io.Discard) + + owner := newTemplateCache(imageDir, log) + if err := owner.tryLock(); err != nil { + t.Fatalf("first tryLock must succeed: %v", err) + } + t.Cleanup(owner.unlock) + + second := newTemplateCache(imageDir, log) + if err := second.tryLock(); err != nil { + t.Fatalf("second tryLock must not error: %v", err) + } + if second.owner { + t.Fatal("second cache claimed ownership while the first holds the lock") + } + + inProgress := filepath.Join(second.dir, templateTmpPrefix+"somekey-1") + if err := os.MkdirAll(inProgress, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := second.removeStaleTmpDirs(); err != nil { + t.Fatalf("removeStaleTmpDirs: %v", err) + } + if _, err := os.Stat(inProgress); err != nil { + t.Errorf("a non-owner removed an in-progress build directory: %v", err) + } + // The owner must still sweep it. + if err := owner.removeStaleTmpDirs(); err != nil { + t.Fatalf("owner removeStaleTmpDirs: %v", err) + } + if _, err := os.Stat(inProgress); !os.IsNotExist(err) { + t.Errorf("the owner failed to sweep the stale dir: %v", err) + } +} + +// TestEnsureTemplateDifferentKeysDoNotBlock proves the cache does not simply +// serialise every call. Both builders wait for the other to start, so an +// implementation holding one lock across all of ensureTemplate -- which would +// still pass TestEnsureTemplateSingleFlight -- cannot get both in flight. +func TestEnsureTemplateDifferentKeysDoNotBlock(t *testing.T) { + c := newTestCache(t) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + started := make(chan struct{}, 2) + release := make(chan struct{}) + builder := func(_ context.Context, _ *logrus.Entry, dstDir string) (gptPartition, error) { + started <- struct{}{} + <-release + for _, name := range []string{templateDiskFile, templateConfigImgFile} { + if err := os.WriteFile(filepath.Join(dstDir, name), []byte("x"), 0o600); err != nil { + return gptPartition{}, err + } + } + if err := os.MkdirAll(filepath.Join(dstDir, templateFirmwareDir), 0o755); err != nil { + return gptPartition{}, err + } + return gptPartition{Offset: 6291456, Length: 5 << 20}, nil + } + + paramsB := baseKeyParams() + paramsB.DiskBytes = 40 << 30 + + var wg sync.WaitGroup + errs := make([]error, 2) + for i, p := range []templateKeyParams{baseKeyParams(), paramsB} { + wg.Add(1) + go func(i int, p templateKeyParams) { + defer wg.Done() + _, errs[i] = c.ensureTemplate(context.Background(), log, p, builder) + }(i, p) + } + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(10 * time.Second): + close(release) + wg.Wait() + t.Fatal("only one build ran at a time: unrelated keys block each other") + } + } + close(release) + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("goroutine %d: %v", i, err) + } + } +} + +func TestTemplateRefsPinAndRelease(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if c.hasRefs(ref.Key) { + t.Error("a fresh template must have no refs") + } + if err := c.addRef(ref.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("addRef: %v", err) + } + if err := c.addRef(ref.Key, "eve-abcd-node2"); err != nil { + t.Fatalf("addRef: %v", err) + } + if !c.hasRefs(ref.Key) { + t.Error("template with two refs reported as unreferenced") + } + if err := c.removeRef(ref.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("removeRef: %v", err) + } + if !c.hasRefs(ref.Key) { + t.Error("template still has one ref but reported as unreferenced") + } + if err := c.removeRef(ref.Key, "eve-abcd-node2"); err != nil { + t.Fatalf("removeRef: %v", err) + } + if c.hasRefs(ref.Key) { + t.Error("template with all refs released reported as referenced") + } +} + +// TestRemoveRefIsIdempotent matters because teardown runs on paths where the +// working copy may never have been created. +func TestRemoveRefIsIdempotent(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := c.removeRef(ref.Key, "never-added"); err != nil { + t.Errorf("removeRef on a missing ref should succeed, got %v", err) + } + if err := c.removeRef("no-such-template", "whatever"); err != nil { + t.Errorf("removeRef on a missing template should succeed, got %v", err) + } +} + +// TestClearAllRefs covers broker restart: client sessions do not survive it, so +// every ref marker is stale and must be dropped, or the template stays pinned +// forever. +func TestClearAllRefs(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := c.addRef(ref.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("addRef: %v", err) + } + if err := c.clearAllRefs(); err != nil { + t.Fatalf("clearAllRefs: %v", err) + } + if c.hasRefs(ref.Key) { + t.Error("refs survived clearAllRefs") + } + if _, err := os.Stat(ref.diskPath()); err != nil { + t.Errorf("clearAllRefs must not touch the template itself: %v", err) + } +} + +func TestCandidatesSkipsReferencedTemplates(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + pinnedParams := baseKeyParams() + freeParams := baseKeyParams() + freeParams.DiskBytes = 40 << 30 + + pinned, err := c.ensureTemplate(context.Background(), log, pinnedParams, stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + free, err := c.ensureTemplate(context.Background(), log, freeParams, stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := c.addRef(pinned.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("addRef: %v", err) + } + + got := c.candidates() + if len(got) != 1 { + t.Fatalf("candidates() returned %d entries, want 1: %+v", len(got), got) + } + if got[0].ID != free.Key { + t.Errorf("candidates() returned %q, want the unreferenced %q", got[0].ID, free.Key) + } +} + +// TestNonOwnerCacheDoesNotHousekeep covers a second broker sharing an image +// directory: it must not clear the first broker's refs or evict its templates, +// or it can delete a backing file out from under a running VM. +func TestNonOwnerCacheDoesNotHousekeep(t *testing.T) { + imageDir := t.TempDir() + log := logrus.New() + log.SetOutput(io.Discard) + + owner := newTemplateCache(imageDir, log) + if err := owner.tryLock(); err != nil { + t.Fatalf("first tryLock must succeed: %v", err) + } + t.Cleanup(owner.unlock) + + second := newTemplateCache(imageDir, log) + if err := second.tryLock(); err != nil { + t.Fatalf("second tryLock must not error, only decline ownership: %v", err) + } + if second.owner { + t.Fatal("second cache claimed ownership while the first holds the lock") + } + + var calls int32 + entry := logrus.NewEntry(log) + ref, err := owner.ensureTemplate(context.Background(), entry, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := owner.addRef(ref.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("addRef: %v", err) + } + + if err := second.clearAllRefs(); err != nil { + t.Fatalf("clearAllRefs: %v", err) + } + if !owner.hasRefs(ref.Key) { + t.Error("a non-owner cache cleared the owner's refs") + } + if got := second.candidates(); got != nil { + t.Errorf("a non-owner cache offered %d eviction candidates, want none", len(got)) + } +} + +func TestEvictRemovesTemplateDir(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + if err := c.evict(ref.Key); err != nil { + t.Fatalf("evict: %v", err) + } + if _, err := os.Stat(ref.Dir); !os.IsNotExist(err) { + t.Errorf("template dir still present after evict: %v", err) + } +} + +// TestEvictRefusesReferencedTemplate covers a reference acquired after +// candidates() snapshotted the list: evict must re-check rather than trust it. +func TestEvictRefusesReferencedTemplate(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + // Snapshot first, exactly as the sweep does, then acquire a reference. + if got := c.candidates(); len(got) != 1 { + t.Fatalf("candidates() = %d entries, want 1", len(got)) + } + if err := c.addRef(ref.Key, "eve-abcd-node1"); err != nil { + t.Fatalf("addRef: %v", err) + } + if err := c.evict(ref.Key); err != nil { + t.Fatalf("evict: %v", err) + } + if _, err := os.Stat(ref.diskPath()); err != nil { + t.Errorf("evict deleted a template that gained a reference after the snapshot: %v", err) + } +} + +// TestEvictAddRefRace stresses the interleaving between evict's +// check-and-delete and a concurrent addRef: before refsMutex serialized them, +// evict could observe an empty refs dir, a concurrent addRef could then land +// its marker, and evict would still proceed to RemoveAll -- deleting a +// template the device had just taken a reference on. This is a stress loop, +// not a forced interleaving (the window is only a few instructions wide), so +// passing does not prove the race is impossible, only that it did not fire in +// these iterations; run with -race and a high -count for confidence. +func TestEvictAddRefRace(t *testing.T) { + c := newTestCache(t) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + const iterations = 200 + for i := 0; i < iterations; i++ { + var calls int32 + params := baseKeyParams() + params.DockerImageID = fmt.Sprintf("sha256:race-%d", i) + ref, err := c.ensureTemplate(context.Background(), log, params, stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("iteration %d: ensureTemplate: %v", i, err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + var addErr error + wg.Add(2) + go func() { + defer wg.Done() + <-start + addErr = c.addRef(ref.Key, "eve-race-node") + }() + go func() { + defer wg.Done() + <-start + if err := c.evict(ref.Key); err != nil { + t.Errorf("iteration %d: evict: %v", i, err) + } + }() + close(start) + wg.Wait() + + if addErr == nil { + if _, err := os.Stat(ref.Dir); err != nil { + t.Fatalf("iteration %d: addRef returned nil but the template dir is gone: %v", i, err) + } + } + } +} + +// TestRefNameRejectsPathTraversal covers a client-supplied device name that +// tries to escape the refs directory: both addRef and removeRef must reject +// it outright rather than joining it into a path. +func TestRefNameRejectsPathTraversal(t *testing.T) { + c := newTestCache(t) + var calls int32 + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + ref, err := c.ensureTemplate(context.Background(), log, baseKeyParams(), stubBuilder(&calls, 0)) + if err != nil { + t.Fatalf("ensureTemplate: %v", err) + } + + for _, bad := range []string{"../../escape", "", "a/b"} { + if err := c.addRef(ref.Key, bad); err == nil { + t.Errorf("addRef(%q) should have been rejected", bad) + } + if err := c.removeRef(ref.Key, bad); err == nil { + t.Errorf("removeRef(%q) should have been rejected", bad) + } + } + + // Nothing must have escaped the refs directory or the image dir itself. + if _, err := os.Stat(filepath.Join(c.imageDir, "escape")); !os.IsNotExist(err) { + t.Errorf("a file escaped the refs directory: %v", err) + } + entries, err := os.ReadDir(ref.refsDir()) + if err != nil { + t.Fatalf("read refs dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("refs dir has %d unexpected entries: %v", len(entries), entries) + } +} + +// TestValidLiveImageSHA256RejectsBadInput covers the client-supplied sha256 +// that liveUploadPath joins straight into a path: a path-traversal payload, an +// empty string, an uppercase digest (hex.EncodeToString never produces one, so +// this rejects a hand-crafted request) and one hex character short of 64 must +// all be rejected before they reach filepath.Join. +func TestValidLiveImageSHA256RejectsBadInput(t *testing.T) { + valid64 := strings.Repeat("a", 64) + bad := []string{ + "../../escape", + "", + strings.ToUpper(valid64), + valid64[:63], + } + for _, sha := range bad { + if err := validLiveImageSHA256(sha); err == nil { + t.Errorf("validLiveImageSHA256(%q) should have been rejected", sha) + } + } + if err := validLiveImageSHA256(valid64); err != nil { + t.Errorf("validLiveImageSHA256(%q) rejected a valid digest: %v", valid64, err) + } +} diff --git a/evetest/broker/livechunk_test.go b/evetest/broker/livechunk_test.go new file mode 100644 index 00000000000..6d6fc72742e --- /dev/null +++ b/evetest/broker/livechunk_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "io" + "os" + "testing" + + api "github.com/lf-edge/eve/evetest/grpcapi/go" + "github.com/sirupsen/logrus" + "google.golang.org/grpc/metadata" +) + +// fakeLiveImageStream implements grpc.ClientStreamingServer[PushLiveImageChunk, +// PushLiveImageResponse] by replaying a canned sequence of messages, so +// PushEVELiveImage's receive loop can be driven without a real gRPC server. +type fakeLiveImageStream struct { + msgs []*api.PushLiveImageChunk + idx int + resp *api.PushLiveImageResponse +} + +func (f *fakeLiveImageStream) Recv() (*api.PushLiveImageChunk, error) { + if f.idx >= len(f.msgs) { + return nil, io.EOF + } + m := f.msgs[f.idx] + f.idx++ + return m, nil +} + +func (f *fakeLiveImageStream) SendAndClose(resp *api.PushLiveImageResponse) error { + f.resp = resp + return nil +} + +func (f *fakeLiveImageStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeLiveImageStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeLiveImageStream) SetTrailer(metadata.MD) {} +func (f *fakeLiveImageStream) Context() context.Context { return context.Background() } +func (f *fakeLiveImageStream) SendMsg(m any) error { return nil } +func (f *fakeLiveImageStream) RecvMsg(m any) error { return nil } + +// TestPushEVELiveImageSkipsEmptyChunk reproduces the real failure: a metadata +// message followed by a zero-length data chunk (as the client's chunking +// writer used to emit) and then a real chunk. The upload must succeed and the +// staged file must contain exactly the real bytes -- the empty chunk must +// leave no trace. +func TestPushEVELiveImageSkipsEmptyChunk(t *testing.T) { + dir := t.TempDir() + b := &broker{ + globalLog: logrus.New(), + imageDir: dir, + sessions: map[string]*session{ + "client1": {clientID: "client1", log: logrus.NewEntry(logrus.New())}, + }, + } + + const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + realChunk := []byte("hello world, this is the real chunk") + stream := &fakeLiveImageStream{msgs: []*api.PushLiveImageChunk{ + {Payload: &api.PushLiveImageChunk_Request{Request: &api.PushLiveImageRequest{ + ClientId: "client1", + LiveImage: &api.LiveImageRef{Sha256: sha, Version: "1"}, + }}}, + {Payload: &api.PushLiveImageChunk_DataChunk{DataChunk: nil}}, + {Payload: &api.PushLiveImageChunk_DataChunk{DataChunk: realChunk}}, + }} + + if err := b.PushEVELiveImage(stream); err != nil { + t.Fatalf("PushEVELiveImage: %v", err) + } + if stream.resp == nil || stream.resp.AlreadyExists { + t.Fatalf("unexpected response: %+v", stream.resp) + } + + got, err := os.ReadFile(liveUploadPath(dir, sha)) + if err != nil { + t.Fatalf("read staged file: %v", err) + } + if string(got) != string(realChunk) { + t.Fatalf("staged file = %q, want %q", got, realChunk) + } +} diff --git a/evetest/broker/livetemplate.go b/evetest/broker/livetemplate.go new file mode 100644 index 00000000000..fe002e4bc58 --- /dev/null +++ b/evetest/broker/livetemplate.go @@ -0,0 +1,327 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + api "github.com/lf-edge/eve/evetest/grpcapi/go" + "github.com/lf-edge/eve/evetest/utils" + "github.com/sirupsen/logrus" +) + +// liveUploadsSubdir holds uploaded-but-not-yet-installed live images. A sibling +// of templates/ so the template sweep never sees a partial upload. +const liveUploadsSubdir = ".live-uploads" + +// liveUploadsDir is where staged live image uploads (and their in-progress +// ".part" files) live, under the broker's image dir. +func liveUploadsDir(imageDir string) string { + return filepath.Join(imageDir, liveUploadsSubdir) +} + +// liveUploadPath is where an uploaded live image tar is staged before install. +func liveUploadPath(imageDir, sha string) string { + return filepath.Join(liveUploadsDir(imageDir), sha+".tar") +} + +// removeStaleLiveUploads deletes every staged live-image upload -- both +// completed tars and in-progress ".part" files -- left behind by a killed +// broker, or by a client that aborted before a successful BuildImage retry +// ever consumed and removed the tar. Called once at startup, alongside +// removeStaleTmpDirs. A non-owner broker must skip this: the uploads it would +// sweep may belong to another broker's upload currently in progress, not to a +// killed one. +func (c *templateCache) removeStaleLiveUploads() error { + if !c.owner { + return nil + } + dir := liveUploadsDir(c.imageDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read live upload dir %q: %w", dir, err) + } + for _, e := range entries { + path := filepath.Join(dir, e.Name()) + if err := os.RemoveAll(path); err != nil { + c.log.Warnf("Failed to remove stale live image upload %q: %v", path, err) + continue + } + c.log.Infof("Removed stale live image upload %q", path) + } + return nil +} + +// localLiveSourceUsable reports whether the broker can install a template by +// reading the client's own live image files, skipping the upload entirely. True +// only when the broker and the client share a filesystem, which is not +// something the client can be asked: it is decided here, by looking. +// +// Every reason to say no is benign -- the caller then reports +// missing_eve_live_image and the client uploads exactly as it always has -- so +// this is deliberately strict and silent. The size check is what keeps a +// mismatch cheap: without it, a same-path-different-content file on a remote +// broker would be read in full only to fail the hash. +func localLiveSourceUsable(log *logrus.Entry, src *api.LocalLiveImageSource) bool { + if src == nil { + return false + } + // A relative path would resolve against the broker's working directory, + // which has nothing to do with the client's. + for _, p := range []string{ + src.GetDiskPath(), src.GetConfigImgPath(), src.GetFirmwareDir()} { + if p == "" || !filepath.IsAbs(p) { + return false + } + } + disk, err := os.Stat(src.GetDiskPath()) + if err != nil || !disk.Mode().IsRegular() { + log.Debugf("Live image %q is not readable here, taking the upload: %v", + src.GetDiskPath(), err) + return false + } + if uint64(disk.Size()) != src.GetDiskBytes() { + log.Debugf("Live image %q is %d bytes here, client declared %d, "+ + "taking the upload", + src.GetDiskPath(), disk.Size(), src.GetDiskBytes()) + return false + } + cfg, err := os.Stat(src.GetConfigImgPath()) + if err != nil || !cfg.Mode().IsRegular() { + log.Debugf("Config image %q is not readable here, taking the upload: %v", + src.GetConfigImgPath(), err) + return false + } + fw, err := os.Stat(src.GetFirmwareDir()) + if err != nil || !fw.IsDir() { + log.Debugf("Firmware dir %q is not readable here, taking the upload: %v", + src.GetFirmwareDir(), err) + return false + } + return true +} + +// installLocalLiveTemplate returns a templateBuilder that installs a template +// straight from the client's own files, for a broker that shares the client's +// filesystem (all-in-one mode, or a broker run by hand on the developer's +// machine). The alternative is a client streaming a 2 GB tar to a process that +// could already open the file, then the broker writing those bytes twice -- +// once as the staged tar, once as the template. +// +// The disk is still hashed against wantSHA256, for the same reason the upload +// path hashes it: the hash is the template's cache key, so installing content +// that does not match it would serve the wrong EVE build to every later run +// that asks for that key. Hashing is nearly free here because the bytes are +// being read for the copy anyway -- what it rules out is a stale client-side +// hash (an in-place `LIVE_UPDATE=1 make live` rebuild that happens to land on +// the same file size), not a corrupted transfer. +func installLocalLiveTemplate( + src *api.LocalLiveImageSource, wantSHA256 string) templateBuilder { + + return func(ctx context.Context, log *logrus.Entry, dstDir string) (gptPartition, error) { + var none gptPartition + + diskPath := filepath.Join(dstDir, templateDiskFile) + gotSHA256, err := copyAndHash(src.GetDiskPath(), diskPath) + if err != nil { + return none, err + } + if gotSHA256 != wantSHA256 { + return none, fmt.Errorf( + "local live image %q hash mismatch: got %s, want %s", + src.GetDiskPath(), gotSHA256, wantSHA256) + } + if err := utils.CopyFile(src.GetConfigImgPath(), + filepath.Join(dstDir, templateConfigImgFile)); err != nil { + return none, fmt.Errorf("failed to copy the config image %q: %w", + src.GetConfigImgPath(), err) + } + if err := utils.CopyFolder(src.GetFirmwareDir(), + filepath.Join(dstDir, templateFirmwareDir)); err != nil { + return none, fmt.Errorf("failed to copy the firmware dir %q: %w", + src.GetFirmwareDir(), err) + } + + head, err := readDiskHead(ctx, diskPath) + if err != nil { + return none, fmt.Errorf("failed to read GPT of %q: %w", diskPath, err) + } + part, err := findGPTPartition(head, gptConfigPartName) + if err != nil { + return none, fmt.Errorf("failed to locate the CONFIG partition in %q: %w", + diskPath, err) + } + log.Infof("Installed EVE live image template by reading %q in place "+ + "(no upload): CONFIG partition at offset %d, length %d", + src.GetDiskPath(), part.Offset, part.Length) + return part, nil + } +} + +// copyAndHash copies srcPath to dstPath and returns the hex sha256 of the bytes +// copied, hashing as it writes so the file is read only once. +func copyAndHash(srcPath, dstPath string) (string, error) { + in, err := os.Open(srcPath) + if err != nil { + return "", fmt.Errorf("failed to open %q: %w", srcPath, err) + } + defer in.Close() + out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return "", fmt.Errorf("failed to create %q: %w", dstPath, err) + } + hasher := sha256.New() + if _, err := io.Copy(io.MultiWriter(out, hasher), in); err != nil { + out.Close() + return "", fmt.Errorf("failed to copy %q to %q: %w", srcPath, dstPath, err) + } + if err := out.Close(); err != nil { + return "", fmt.Errorf("failed to write %q: %w", dstPath, err) + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// liveTemplateMembers maps every member an uploaded live image may contain to +// the path it is written to, relative to the template directory. The values are +// this package's own constants, so an entry's name only ever selects a +// destination and never builds one: an archive is attacker-shaped input, and a +// name that cannot reach a file operation cannot direct one out of the template +// directory however the ".." components in it are spelled. +// +// The firmware members are the files the device providers actually consume +// (qemu and libvirt both open OVMF_CODE.fd and OVMF_VARS.fd, and +// resolveLocalLiveImage requires all three); anything else a build happens to +// leave in its firmware directory is unused, so it is skipped rather than +// installed. +var liveTemplateMembers = map[string]string{ + templateDiskFile: templateDiskFile, + templateConfigImgFile: templateConfigImgFile, + templateFirmwareDir + "/OVMF.fd": templateFirmwareDir + "/OVMF.fd", + templateFirmwareDir + "/OVMF_CODE.fd": templateFirmwareDir + "/OVMF_CODE.fd", + templateFirmwareDir + "/OVMF_VARS.fd": templateFirmwareDir + "/OVMF_VARS.fd", +} + +// unpackLiveTemplate returns a templateBuilder that installs a template from an +// uploaded tar instead of building one with the EVE container. Everything the +// container path produces is already in the tar, because `make live` emits +// live.qcow2, config.img and the OVMF firmware as separate files. +// +// wantSHA256 is the hash the client declared for disk.qcow2 -- the same value +// that was used to compute the template's cache key and the upload's path on +// disk. It is verified against the bytes actually received, not merely +// asserted by the client: the cache key, the tar's storage path and the +// content itself must all agree, or a mismatched or corrupted upload would +// otherwise be installed as if it were the image the client claimed. +func unpackLiveTemplate(tarPath, wantSHA256 string) templateBuilder { + return func(ctx context.Context, log *logrus.Entry, dstDir string) (gptPartition, error) { + var none gptPartition + + f, err := os.Open(tarPath) + if err != nil { + return none, fmt.Errorf("failed to open the uploaded live image %q: %w", + tarPath, err) + } + defer f.Close() + + dstDir, err = filepath.Abs(dstDir) + if err != nil { + return none, fmt.Errorf("failed to resolve the destination directory %q: %w", + dstDir, err) + } + dstDir = filepath.Clean(dstDir) + + seen := map[string]bool{} + diskHasher := sha256.New() + tr := tar.NewReader(f) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return none, fmt.Errorf("failed to read the uploaded live image: %w", err) + } + clean := filepath.Clean(hdr.Name) + dest, expected := liveTemplateMembers[clean] + if !expected { + // Nothing outside the table is installed, so an escape is already + // impossible; it is still called out rather than skipped quietly, + // because a legitimate upload has no reason to contain one. + if filepath.IsAbs(hdr.Name) || clean == ".." || + strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return none, fmt.Errorf("upload contains an unsafe path %q", hdr.Name) + } + log.Debugf("Ignoring unexpected member %q in the uploaded live image", + hdr.Name) + continue + } + // Every table entry names a file, so a directory carrying one of those + // names is malformed rather than something to create. + if hdr.Typeflag == tar.TypeDir { + return none, fmt.Errorf("upload member %q is a directory, expected a file", + hdr.Name) + } + target := filepath.Join(dstDir, dest) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return none, err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return none, err + } + // The 2 GB disk image is hashed as it is written, not re-read + // afterwards: a second pass over the whole file would double the + // I/O this check costs. + var dst io.Writer = out + if dest == templateDiskFile { + dst = io.MultiWriter(out, diskHasher) + } + if _, err := io.Copy(dst, tr); err != nil { + out.Close() + return none, fmt.Errorf("failed to write %q: %w", target, err) + } + if err := out.Close(); err != nil { + return none, err + } + seen[dest] = true + } + + for _, required := range []string{templateDiskFile, templateConfigImgFile} { + if !seen[required] { + return none, fmt.Errorf("upload is missing %q", required) + } + } + + if gotSHA256 := hex.EncodeToString(diskHasher.Sum(nil)); gotSHA256 != wantSHA256 { + return none, fmt.Errorf( + "uploaded live image disk hash mismatch: got %s, want %s", + gotSHA256, wantSHA256) + } + + diskPath := filepath.Join(dstDir, templateDiskFile) + head, err := readDiskHead(ctx, diskPath) + if err != nil { + return none, fmt.Errorf("failed to read GPT of %q: %w", diskPath, err) + } + part, err := findGPTPartition(head, gptConfigPartName) + if err != nil { + return none, fmt.Errorf("failed to locate the CONFIG partition in %q: %w", + diskPath, err) + } + log.Infof("Installed EVE live image template from the uploaded tar: "+ + "CONFIG partition at offset %d, length %d", part.Offset, part.Length) + return part, nil + } +} diff --git a/evetest/broker/livetemplate_test.go b/evetest/broker/livetemplate_test.go new file mode 100644 index 00000000000..8180e3233c6 --- /dev/null +++ b/evetest/broker/livetemplate_test.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + api "github.com/lf-edge/eve/evetest/grpcapi/go" + "github.com/sirupsen/logrus" +) + +// writeLiveTar builds an upload tar with the given members. +func writeLiveTar(t *testing.T, path string, members map[string][]byte) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create tar: %v", err) + } + defer f.Close() + tw := tar.NewWriter(f) + for name, data := range members { + if err := tw.WriteHeader(&tar.Header{ + Name: name, Mode: 0o600, Size: int64(len(data)), + }); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write(data); err != nil { + t.Fatalf("tar write: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } +} + +// qcow2FromRaw wraps raw bytes into a real qcow2 image via qemu-img, the same +// tool unpackLiveTemplate's readDiskHead shells out to. Tests that exercise +// the full path (including the GPT read) need disk.qcow2 to actually be +// qcow2-formatted, not just GPT-shaped raw bytes. +func qcow2FromRaw(t *testing.T, raw []byte) []byte { + t.Helper() + dir := t.TempDir() + rawPath := filepath.Join(dir, "raw.bin") + if err := os.WriteFile(rawPath, raw, 0o600); err != nil { + t.Fatalf("write raw fixture: %v", err) + } + qcowPath := filepath.Join(dir, "disk.qcow2") + out, err := exec.Command("qemu-img", "convert", + "-f", "raw", "-O", "qcow2", rawPath, qcowPath).CombinedOutput() + if err != nil { + t.Fatalf("qemu-img convert: %v: %s", err, out) + } + data, err := os.ReadFile(qcowPath) + if err != nil { + t.Fatalf("read converted qcow2: %v", err) + } + return data +} + +func liveTarMembers(t *testing.T) map[string][]byte { + t.Helper() + head, err := os.ReadFile("testdata/gpt-head-live.bin") + if err != nil { + t.Fatalf("fixture: %v", err) + } + return map[string][]byte{ + templateDiskFile: qcow2FromRaw(t, head), + templateConfigImgFile: make([]byte, 5<<20), + filepath.Join(templateFirmwareDir, "OVMF.fd"): []byte("fw"), + } +} + +// liveTarDiskSHA256 computes the hash unpackLiveTemplate is expected to verify +// disk.qcow2 against, from the same bytes a test tar was built from. +func liveTarDiskSHA256(members map[string][]byte) string { + sum := sha256.Sum256(members[templateDiskFile]) + return hex.EncodeToString(sum[:]) +} + +func TestUnpackLiveTemplateRejectsMissingMember(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + members := liveTarMembers(t) + wantSHA256 := liveTarDiskSHA256(members) + delete(members, templateConfigImgFile) + writeLiveTar(t, tarPath, members) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + _, err := unpackLiveTemplate(tarPath, wantSHA256)(context.Background(), log, t.TempDir()) + if err == nil { + t.Fatal("expected an error when config.img is absent from the upload") + } +} + +func TestUnpackLiveTemplateRejectsPathTraversal(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + writeLiveTar(t, tarPath, map[string][]byte{"../escape": []byte("x")}) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + dst := t.TempDir() + _, err := unpackLiveTemplate(tarPath, strings.Repeat("0", 64))(context.Background(), log, dst) + if err == nil { + t.Fatal("expected an error for a tar member escaping the destination") + } + if _, statErr := os.Stat(filepath.Join(filepath.Dir(dst), "escape")); statErr == nil { + t.Fatal("a tar member was written outside the destination directory") + } +} + +func TestUnpackLiveTemplateRejectsAbsolutePath(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + writeLiveTar(t, tarPath, map[string][]byte{"/etc/passwd": []byte("x")}) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + dst := t.TempDir() + _, err := unpackLiveTemplate(tarPath, strings.Repeat("0", 64))(context.Background(), log, dst) + if err == nil { + t.Fatal("expected an error for a tar member with an absolute path") + } + if _, statErr := os.Stat(filepath.Join(dst, "etc", "passwd")); statErr == nil { + t.Fatal("a tar member with an absolute path was written") + } +} + +// TestUnpackLiveTemplateVerifiesDiskHash covers the success path: a correctly +// hashed upload must install cleanly and report the CONFIG partition location. +func TestUnpackLiveTemplateVerifiesDiskHash(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + members := liveTarMembers(t) + writeLiveTar(t, tarPath, members) + wantSHA256 := liveTarDiskSHA256(members) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + part, err := unpackLiveTemplate(tarPath, wantSHA256)(context.Background(), log, t.TempDir()) + if err != nil { + t.Fatalf("unpackLiveTemplate: %v", err) + } + if part.Length == 0 { + t.Error("expected a non-zero CONFIG partition length") + } +} + +// TestUnpackLiveTemplateRejectsHashMismatch covers the trust boundary this +// fix closes: the declared sha256 is the cache key and the storage path, but +// until this check it was never verified against the bytes actually received. +func TestUnpackLiveTemplateRejectsHashMismatch(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + members := liveTarMembers(t) + writeLiveTar(t, tarPath, members) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + wrongSHA256 := strings.Repeat("0", 64) + if wrongSHA256 == liveTarDiskSHA256(members) { + t.Fatal("test bug: wrongSHA256 collides with the real hash") + } + _, err := unpackLiveTemplate(tarPath, wrongSHA256)(context.Background(), log, t.TempDir()) + if err == nil { + t.Fatal("expected an error for a disk hash mismatch") + } +} + +// writeLocalLiveSource lays out a client-side live build (live.qcow2, +// installer/config.img, installer/firmware/*) and returns the source message +// describing it plus the disk's real sha256. +func writeLocalLiveSource(t *testing.T, dir string) (*api.LocalLiveImageSource, string) { + t.Helper() + members := liveTarMembers(t) + disk := members[templateDiskFile] + diskPath := filepath.Join(dir, "live.qcow2") + if err := os.WriteFile(diskPath, disk, 0o600); err != nil { + t.Fatalf("write live.qcow2: %v", err) + } + cfgPath := filepath.Join(dir, "config.img") + if err := os.WriteFile(cfgPath, members[templateConfigImgFile], 0o600); err != nil { + t.Fatalf("write config.img: %v", err) + } + fwDir := filepath.Join(dir, "firmware") + if err := os.MkdirAll(fwDir, 0o755); err != nil { + t.Fatalf("mkdir firmware: %v", err) + } + if err := os.WriteFile(filepath.Join(fwDir, "OVMF.fd"), []byte("fw"), 0o600); err != nil { + t.Fatalf("write OVMF.fd: %v", err) + } + return &api.LocalLiveImageSource{ + DiskPath: diskPath, + DiskBytes: uint64(len(disk)), + ConfigImgPath: cfgPath, + FirmwareDir: fwDir, + }, liveTarDiskSHA256(members) +} + +// TestLocalLiveSourceUsable covers the decision that replaces an upload with a +// direct read. Every rejection must be a plain false, not an error: the caller +// falls back to the upload, which is always correct if slower. +func TestLocalLiveSourceUsable(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + src, _ := writeLocalLiveSource(t, t.TempDir()) + + if !localLiveSourceUsable(log, src) { + t.Fatal("expected a complete local source to be usable") + } + if localLiveSourceUsable(log, nil) { + t.Error("expected a nil source to be unusable") + } + + tests := []struct { + name string + mutate func(*api.LocalLiveImageSource) + }{ + {"relative disk path", func(s *api.LocalLiveImageSource) { + s.DiskPath = "dist/live.qcow2" + }}, + {"empty firmware dir", func(s *api.LocalLiveImageSource) { + s.FirmwareDir = "" + }}, + {"disk not present on this host", func(s *api.LocalLiveImageSource) { + s.DiskPath = filepath.Join(t.TempDir(), "absent.qcow2") + }}, + {"disk size disagrees", func(s *api.LocalLiveImageSource) { + s.DiskBytes++ + }}, + {"config image absent", func(s *api.LocalLiveImageSource) { + s.ConfigImgPath = filepath.Join(t.TempDir(), "absent.img") + }}, + {"firmware dir is a file", func(s *api.LocalLiveImageSource) { + s.FirmwareDir = s.ConfigImgPath + }}, + {"disk is a directory", func(s *api.LocalLiveImageSource) { + s.DiskPath = s.FirmwareDir + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + broken, _ := writeLocalLiveSource(t, t.TempDir()) + tt.mutate(broken) + if localLiveSourceUsable(log, broken) { + t.Errorf("expected %s to make the source unusable", tt.name) + } + }) + } +} + +// TestInstallLocalLiveTemplate covers the success path: the template is +// populated from the client's own files, with no upload staged anywhere. +func TestInstallLocalLiveTemplate(t *testing.T) { + src, wantSHA256 := writeLocalLiveSource(t, t.TempDir()) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + dst := t.TempDir() + part, err := installLocalLiveTemplate(src, wantSHA256)(context.Background(), log, dst) + if err != nil { + t.Fatalf("installLocalLiveTemplate: %v", err) + } + if part.Length == 0 { + t.Error("expected a non-zero CONFIG partition length") + } + for _, f := range []string{ + templateDiskFile, + templateConfigImgFile, + filepath.Join(templateFirmwareDir, "OVMF.fd"), + } { + if _, err := os.Stat(filepath.Join(dst, f)); err != nil { + t.Errorf("template is missing %q: %v", f, err) + } + } +} + +// TestInstallLocalLiveTemplateRejectsHashMismatch is why reading the client's +// file directly is safe: content that does not match the declared hash -- the +// template's cache key -- must never be installed under it, or every later run +// asking for that key silently gets the wrong EVE build. +func TestInstallLocalLiveTemplateRejectsHashMismatch(t *testing.T) { + src, realSHA256 := writeLocalLiveSource(t, t.TempDir()) + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + + wrongSHA256 := strings.Repeat("0", 64) + if wrongSHA256 == realSHA256 { + t.Fatal("test bug: wrongSHA256 collides with the real hash") + } + _, err := installLocalLiveTemplate(src, wrongSHA256)( + context.Background(), log, t.TempDir()) + if err == nil { + t.Fatal("expected an error for a local disk hash mismatch") + } +} + +// TestRemoveStaleLiveUploadsSweepsOwner covers a broker restarting on its own +// image directory: any staged tar or leftover ".part" file belongs to a +// session that did not survive the restart, so an owner must remove both. +func TestRemoveStaleLiveUploadsSweepsOwner(t *testing.T) { + c := newTestCache(t) + uploadsDir := liveUploadsDir(c.imageDir) + if err := os.MkdirAll(uploadsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + stagedTar := filepath.Join(uploadsDir, "abc.tar") + if err := os.WriteFile(stagedTar, []byte("tar"), 0o600); err != nil { + t.Fatalf("write staged tar: %v", err) + } + partFile := filepath.Join(uploadsDir, "abc.tar.deadbeef.part") + if err := os.WriteFile(partFile, []byte("partial"), 0o600); err != nil { + t.Fatalf("write part file: %v", err) + } + + if err := c.removeStaleLiveUploads(); err != nil { + t.Fatalf("removeStaleLiveUploads: %v", err) + } + if _, err := os.Stat(stagedTar); !os.IsNotExist(err) { + t.Error("owner did not remove the staged tar") + } + if _, err := os.Stat(partFile); !os.IsNotExist(err) { + t.Error("owner did not remove the leftover .part file") + } +} + +// TestNonOwnerDoesNotRemoveStaleLiveUploads covers a second broker starting on +// a shared image directory: the staged tar or ".part" file it would sweep may +// belong to the owner's upload currently in progress, not to a killed one, so +// a non-owner must leave both alone. This is the half that actually matters -- +// a method that deletes nothing at all would also pass a test that only checks +// the owner case. +func TestNonOwnerDoesNotRemoveStaleLiveUploads(t *testing.T) { + imageDir := t.TempDir() + log := logrus.New() + log.SetOutput(io.Discard) + + owner := newTemplateCache(imageDir, log) + if err := owner.tryLock(); err != nil { + t.Fatalf("first tryLock must succeed: %v", err) + } + t.Cleanup(owner.unlock) + + second := newTemplateCache(imageDir, log) + if err := second.tryLock(); err != nil { + t.Fatalf("second tryLock must not error: %v", err) + } + if second.owner { + t.Fatal("second cache claimed ownership while the first holds the lock") + } + + uploadsDir := liveUploadsDir(imageDir) + if err := os.MkdirAll(uploadsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + stagedTar := filepath.Join(uploadsDir, "abc.tar") + if err := os.WriteFile(stagedTar, []byte("tar"), 0o600); err != nil { + t.Fatalf("write staged tar: %v", err) + } + partFile := filepath.Join(uploadsDir, "abc.tar.deadbeef.part") + if err := os.WriteFile(partFile, []byte("partial"), 0o600); err != nil { + t.Fatalf("write part file: %v", err) + } + + if err := second.removeStaleLiveUploads(); err != nil { + t.Fatalf("removeStaleLiveUploads: %v", err) + } + if _, err := os.Stat(stagedTar); err != nil { + t.Errorf("a non-owner removed another broker's staged tar: %v", err) + } + if _, err := os.Stat(partFile); err != nil { + t.Errorf("a non-owner removed another broker's .part file: %v", err) + } + + // The owner must still sweep both. + if err := owner.removeStaleLiveUploads(); err != nil { + t.Fatalf("owner removeStaleLiveUploads: %v", err) + } + if _, err := os.Stat(stagedTar); !os.IsNotExist(err) { + t.Error("the owner failed to sweep the staged tar") + } + if _, err := os.Stat(partFile); !os.IsNotExist(err) { + t.Error("the owner failed to sweep the .part file") + } +} + +// TestUnpackLiveTemplateRejectsRelocatedAbsolutePath guards the subtlety that +// filepath.Join(dst, "/etc/passwd") does not fail -- it quietly yields +// dst/etc/passwd. Rebuilding the destination from a verified relative path is +// what keeps an escape out, but on its own it would turn an absolute member into +// a silent relocation instead of a rejection. +func TestUnpackLiveTemplateRejectsRelocatedAbsolutePath(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "u.tar") + writeLiveTar(t, tarPath, map[string][]byte{"/etc/passwd": []byte("x")}) + + log := logrus.NewEntry(logrus.New()) + log.Logger.SetOutput(io.Discard) + dst := t.TempDir() + _, err := unpackLiveTemplate(tarPath, strings.Repeat("0", 64))( + context.Background(), log, dst) + if err == nil { + t.Fatal("expected an error for an absolute tar member") + } + if _, statErr := os.Stat(filepath.Join(dst, "etc", "passwd")); statErr == nil { + t.Error("an absolute member was relocated under the destination " + + "instead of being rejected") + } +} diff --git a/evetest/broker/main.go b/evetest/broker/main.go index 7c4d3c832e3..65d6c967ecf 100644 --- a/evetest/broker/main.go +++ b/evetest/broker/main.go @@ -118,8 +118,10 @@ func main() { maxClients := viper.GetInt(constants.BrokerMaxClientsEnv) imgRetention := time.Duration(viper.GetInt(constants.BrokerDockerImageRetentionEnv)) * time.Minute diskThresholdPct := viper.GetInt(constants.BrokerDockerDiskUsageThresholdEnv) + tmplRetention := time.Duration(viper.GetInt(constants.BrokerTemplateRetentionEnv)) * time.Minute + tmplDiskThresholdPct := viper.GetInt(constants.BrokerTemplateDiskUsageThresholdEnv) broker, err := newBroker(log, deviceProvider, providerName, imageDir, sdnGrpcPort, maxClients, - imgRetention, diskThresholdPct) + imgRetention, diskThresholdPct, tmplRetention, tmplDiskThresholdPct) if err != nil { log.Fatal(err) } diff --git a/evetest/broker/provider/libvirt.go b/evetest/broker/provider/libvirt.go index 121e74e4062..3c4b43d25bd 100644 --- a/evetest/broker/provider/libvirt.go +++ b/evetest/broker/provider/libvirt.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -127,6 +128,13 @@ func (p *LibvirtProvider) Capabilities() []api.Capability { return fullCapabilitySet() } +// DiskImageStrategy returns DiskImageOverlay: libvirt attaches local files +// directly, so a QCOW2 backed by a cached template works and is near-instant +// to create. +func (p *LibvirtProvider) DiskImageStrategy() DiskImageStrategy { + return DiskImageOverlay +} + // SetupDevice creates a VM in a powered-off state. func (p *LibvirtProvider) SetupDevice( ctx context.Context, name string, spec DeviceSpec) error { @@ -433,6 +441,17 @@ func (p *LibvirtProvider) SetupDevice( return nil } +// nvramTagPattern matches a domain XML "" element opening tag, with or +// without attributes. Real libvirt always emits attributes on this element (e.g. +// format='raw', template='...', type='file'), so the match must not require a bare +// "" -- that never occurs in practice and would silently defeat detection. +var nvramTagPattern = regexp.MustCompile(`/]`) + +// domainHasNvram reports whether a domain's XML description declares an NVRAM element. +func domainHasNvram(xmlDesc string) bool { + return nvramTagPattern.MatchString(xmlDesc) +} + // TeardownDevice stops the device (if running) and removes it completely, // including all associated resources (disks, network interfaces, NVRAM, etc.). func (p *LibvirtProvider) TeardownDevice(ctx context.Context, name string) error { @@ -463,16 +482,20 @@ func (p *LibvirtProvider) TeardownDevice(ctx context.Context, name string) error log.Warnf("Failed to get XML description for domain %q: %v", name, err) } - hasNvram := strings.Contains(xmlDesc, "") + hasNvram := domainHasNvram(xmlDesc) log.Debugf("Domain %q has NVRAM: %t", name, hasNvram) - // Undefine domain, including NVRAM if present + // Undefine domain, including NVRAM if present. libvirt errors out either way if the + // flag doesn't match reality: DOMAIN_UNDEFINE_NVRAM on a domain with no nvram fails + // with "cannot undefine domain with no nvram", and omitting it on a domain that has + // one fails with "cannot undefine domain with nvram" -- so this must be conditional. var undefineFlags libvirt.DomainUndefineFlagsValues if hasNvram { undefineFlags = libvirt.DOMAIN_UNDEFINE_NVRAM } if err := dom.UndefineFlags(undefineFlags); err != nil { - err = fmt.Errorf("failed to undefine domain %q: %w", name, err) + err = fmt.Errorf("failed to undefine domain %q, its definition leaked and will "+ + "collide with \"already exists\" on a later run of the same test: %w", name, err) log.Error(err) return err } diff --git a/evetest/broker/provider/libvirt_stub.go b/evetest/broker/provider/libvirt_stub.go index 0261e9be862..6399b6da04d 100644 --- a/evetest/broker/provider/libvirt_stub.go +++ b/evetest/broker/provider/libvirt_stub.go @@ -40,6 +40,11 @@ func (p *LibvirtProvider) Capabilities() []api.Capability { panic("unreachable") } +// DiskImageStrategy is not implemented in CGO-disabled builds. +func (p *LibvirtProvider) DiskImageStrategy() DiskImageStrategy { + panic("unreachable") +} + // SetupDevice is not implemented in CGO-disabled builds. func (p *LibvirtProvider) SetupDevice(_ context.Context, _ string, _ DeviceSpec) error { panic("unreachable") diff --git a/evetest/broker/provider/libvirt_test.go b/evetest/broker/provider/libvirt_test.go new file mode 100644 index 00000000000..2233f66d659 --- /dev/null +++ b/evetest/broker/provider/libvirt_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build cgo + +package provider + +import "testing" + +func TestDomainHasNvram(t *testing.T) { + cases := []struct { + name string + xml string + want bool + }{ + { + name: "format attribute (real form seen on the broker host)", + xml: `/home/eve-broker/images/eve-x/firmware/OVMF_VARS.fd`, + want: true, + }, + { + name: "template attribute", + xml: `/path`, + want: true, + }, + { + name: "type attribute, self-closing-ish", + xml: ``, + want: true, + }, + { + name: "bare tag", + xml: `/path`, + want: true, + }, + { + name: "no nvram element at all", + xml: `/usr/share/OVMF/OVMF_CODE.fd`, + want: false, + }, + { + name: "over-broad prefix guard", + xml: `bar`, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := domainHasNvram(tc.xml) + if got != tc.want { + t.Errorf("domainHasNvram(%q) = %v, want %v", tc.xml, got, tc.want) + } + }) + } +} diff --git a/evetest/broker/provider/provider.go b/evetest/broker/provider/provider.go index 9eba4df7cc2..d7d57064ae1 100644 --- a/evetest/broker/provider/provider.go +++ b/evetest/broker/provider/provider.go @@ -26,6 +26,10 @@ type DeviceProvider interface { // active provider does not advertise them. Capabilities() []api.Capability + // DiskImageStrategy reports how the broker should produce per-device disk + // images for this provider. See DiskImageStrategy. + DiskImageStrategy() DiskImageStrategy + // SetupDevice creates a new device in a powered-off state with the specified configuration. // The device must be created but not started, allowing for additional configuration // before power-on if needed. @@ -128,6 +132,23 @@ const ( DiskImageFormatRaw ) +// DiskImageStrategy describes how the broker should produce a per-device disk +// image for a given provider. +type DiskImageStrategy int + +const ( + // DiskImageLegacyBuild runs a full per-device EVE container build. The + // default for providers not yet validated against the template cache. + DiskImageLegacyBuild DiskImageStrategy = iota + // DiskImageStandalone derives the device disk from a cached template by + // copying it, for providers that move the image elsewhere before use -- + // Proxmox uploads it to the PVE node, where a backing file would not exist. + DiskImageStandalone + // DiskImageOverlay derives the device disk as a QCOW2 whose backing file is + // the cached template, for providers that attach local files directly. + DiskImageOverlay +) + // DiskImage describes a single disk to attach to a device. type DiskImage struct { // Format is the on-disk format of the image file. diff --git a/evetest/broker/provider/proxmox.go b/evetest/broker/provider/proxmox.go index 6782eb654a0..d585aa0d5bb 100644 --- a/evetest/broker/provider/proxmox.go +++ b/evetest/broker/provider/proxmox.go @@ -301,6 +301,14 @@ func (p *ProxmoxProvider) Capabilities() []api.Capability { return fullCapabilitySet() } +// DiskImageStrategy returns DiskImageStandalone: uploadDiskImages ships each +// device's disk to the PVE node, where a backing file would not exist, so an +// overlay is out -- but a standalone copy of a cached template is self-contained +// and still skips the per-device container build. +func (p *ProxmoxProvider) DiskImageStrategy() DiskImageStrategy { + return DiskImageStandalone +} + // SetupDevice creates a VM in a powered-off state with the given configuration. func (p *ProxmoxProvider) SetupDevice( ctx context.Context, name string, spec DeviceSpec) error { diff --git a/evetest/broker/provider/qemu.go b/evetest/broker/provider/qemu.go index 5325c7e5af9..e8ef2a72ade 100644 --- a/evetest/broker/provider/qemu.go +++ b/evetest/broker/provider/qemu.go @@ -341,6 +341,13 @@ func (p *QemuProvider) Capabilities() []api.Capability { return fullCapabilitySet() } +// DiskImageStrategy returns DiskImageOverlay: qemu attaches local files +// directly, so a device disk can be a QCOW2 overlay on a shared template +// (-drive file=,format=qcow2 resolves the backing file itself). +func (p *QemuProvider) DiskImageStrategy() DiskImageStrategy { + return DiskImageOverlay +} + // SetupDevice creates a VM configuration and prepares network resources, // but does not start the VM (powered-off state). func (p *QemuProvider) SetupDevice( diff --git a/evetest/broker/testdata/gpt-head-live.bin b/evetest/broker/testdata/gpt-head-live.bin new file mode 100644 index 00000000000..723859d9f73 Binary files /dev/null and b/evetest/broker/testdata/gpt-head-live.bin differ diff --git a/evetest/constants/config.go b/evetest/constants/config.go index 05b79577ec8..bc7dfa1b322 100644 --- a/evetest/constants/config.go +++ b/evetest/constants/config.go @@ -67,9 +67,13 @@ const ( // This is read by the evetest container. PauseOnFailureEnv = "PAUSE_ON_FAILURE" - // EVEVersionEnv specifies the version of EVE to test. + // EVEVersionEnv specifies the version of EVE to test. This is the *which + // build* setting, independent of how its bits are delivered (see + // EVELiveImageEnv). // If unset, the EVE version from the local repository is used (including - // any uncommitted changes). + // any uncommitted changes) -- except under EVELiveImageEnv, where unset + // means the newest local build (dist//current), which reports its + // own version. // This is read by the evetest container. EVEVersionEnv = "EVE_VERSION" @@ -77,6 +81,25 @@ const ( // This is read by the evetest container. EVERepoEnv = "EVE_REPO" + // EVELiveImageEnv selects how EVE's bits reach a device: with it set, from + // the artifacts `make live` wrote under EVEDistDirEnv; unset or false, from + // an EVE container image. A boolean and nothing more -- *which* build to run + // is EVEVersionEnv's business, so this deliberately carries no path. A + // non-boolean value is an error rather than a silent fallback. + // This is read by the evetest container. + EVELiveImageEnv = "EVE_LIVE_IMAGE" + + // EVEFirmwareDirEnv overrides firmware discovery, which otherwise looks in + // /installer/firmware. + EVEFirmwareDirEnv = "EVE_FIRMWARE_DIR" + + // EVEDistDirEnv locates the EVE build output directory, whose + // // subdirectories (and the /current symlink) hold the + // local builds EVELiveImageEnv delivers. It must be an absolute path: the + // harness runs inside a container, so a relative path would resolve against + // the container's working directory rather than the developer's checkout. + EVEDistDirEnv = "EVE_DIST_DIR" + // HomeDirEnv specifies the evetest data directory on the host ($HOME/.evetest). // It is passed by the Makefile as EVETEST_HOME=$(HOME)/.evetest and must be // bind-mounted into the container at the same path so that Docker bind-mounts @@ -191,6 +214,24 @@ const ( // Read by evetest-broker. BrokerDockerDiskUsageThresholdEnv = "BROKER_DOCKER_DISK_USAGE_THRESHOLD" + // BrokerTemplateRetentionEnv specifies how long (in minutes) an unused EVE + // disk-image template is kept before the broker's periodic cleanup removes + // it. Templates let consecutive test runs against the same EVE version skip + // the image build entirely, so this is deliberately generous; a template + // still backing a live VM is never removed regardless of this value. + // Zero or negative disables age-based eviction entirely; the + // disk-usage-based eviction (BrokerTemplateDiskUsageThresholdEnv) still + // applies regardless, so this cannot be used to disable all cleanup. + // Read by evetest-broker. + BrokerTemplateRetentionEnv = "BROKER_TEMPLATE_RETENTION" + + // BrokerTemplateDiskUsageThresholdEnv specifies the disk usage percentage + // (on the filesystem backing the broker's image directory) at or above + // which the broker evicts the oldest unreferenced EVE image templates, + // regardless of BrokerTemplateRetentionEnv, until usage drops back under + // it. Read by evetest-broker. + BrokerTemplateDiskUsageThresholdEnv = "BROKER_TEMPLATE_DISK_USAGE_THRESHOLD" + // ExternalArtifactDirEnv specifies a host-side directory path where all test // artifacts should be collected. // This variable is optional and may be set by the user. @@ -315,6 +356,22 @@ const ( // image cleanup once the filesystem backing Docker's storage is at least // this full. DefaultBrokerDockerDiskUsageThresholdPercent = 80 + + // DefaultBrokerTemplateRetentionMinutes is 7 days, matching the Docker + // image retention default. + DefaultBrokerTemplateRetentionMinutes = 7 * 24 * 60 + + // DefaultBrokerTemplateDiskUsageThresholdPercent triggers aggressive + // template eviction once the filesystem backing the broker's image + // directory is at least this full. + // + // Deliberately higher than DefaultBrokerDockerDiskUsageThresholdPercent: a + // broker host with ample free space can still idle above 80% full, and at + // that threshold every unreferenced template would be evicted on every + // pass, so the cache would never be warm. Templates are also the wrong + // thing to give up first -- one is 1-2 GB, where the Docker image store is + // tens of GB. + DefaultBrokerTemplateDiskUsageThresholdPercent = 90 ) // InitViperConfig initializes the Viper configuration with default values. @@ -343,6 +400,9 @@ func InitViperConfig() { // EVE image config viper.SetDefault(EVEVersionEnv, "") // Empty = derive from repo viper.SetDefault(EVERepoEnv, DefaultEVERepo) + viper.SetDefault(EVELiveImageEnv, "") + viper.SetDefault(EVEFirmwareDirEnv, "") + viper.SetDefault(EVEDistDirEnv, "") viper.SetDefault(PreferredArchEnv, DefaultPreferredArch) // Adam image config @@ -363,6 +423,8 @@ func InitViperConfig() { viper.SetDefault(BrokerMaxClientsEnv, DefaultBrokerMaxClients) viper.SetDefault(BrokerDockerImageRetentionEnv, DefaultBrokerDockerImageRetentionMinutes) viper.SetDefault(BrokerDockerDiskUsageThresholdEnv, DefaultBrokerDockerDiskUsageThresholdPercent) + viper.SetDefault(BrokerTemplateRetentionEnv, DefaultBrokerTemplateRetentionMinutes) + viper.SetDefault(BrokerTemplateDiskUsageThresholdEnv, DefaultBrokerTemplateDiskUsageThresholdPercent) // Per-registry pull-through cache mirrors for _, e := range RegistryMirrorEntries { diff --git a/evetest/edgedevice.go b/evetest/edgedevice.go index 45ac71dffb7..206622299a6 100644 --- a/evetest/edgedevice.go +++ b/evetest/edgedevice.go @@ -370,6 +370,17 @@ func (d *EdgeDevice) UpgradeEVE(targetEVEVersion string, targetEVEHypervisor Hyp currentImageRef := devState.imageRef d.th.devicesM.Unlock() + // The live transport delivers an upgrade as the raw rootfs the local build + // already contains, rather than pulling a container image to extract the same + // file from. Which build that is comes from the version axis exactly as it + // does for a fresh device, so an explicitly requested target version is + // honoured (and must be built locally) while an unset one means the newest. + if LocalLiveImageRequested() { + d.upgradeEVEFromLocalBuild(targetEVEVersion, currentImageRef.Arch, + currentImageRef.Hypervisor, waitUntilUpgraded, expectRevert) + return + } + targetImageRef := &api.ImageRef{ Repo: currentImageRef.Repo, Version: targetEVEVersion, @@ -428,6 +439,81 @@ func (d *EdgeDevice) UpgradeEVE(targetEVEVersion string, targetEVEHypervisor Hyp d.th.log.Infof("Reusing cached rootfs %s", rootfsFilename) } + d.applyUpgrade(rootfsPath, rootfsFilename, shortVersion, + waitUntilUpgraded, expectRevert) +} + +// upgradeEVEFromLocalBuild delivers an upgrade from a local build's own +// installer/rootfs.img instead of pulling a container image to extract the same +// file out of. targetEVEVersion selects which local build, empty meaning the +// newest; the target hypervisor is not a choice here, since a build is delivered +// as it was built. +func (d *EdgeDevice) upgradeEVEFromLocalBuild(targetEVEVersion string, + arch api.ArchType, runningHypervisor api.HypervisorType, + waitUntilUpgraded, expectRevert bool) { + + zarch, err := zarchDirName(arch) + if err != nil { + d.th.t.Fatalf("UpgradeEVE: %v", err) + } + img, err := resolveLocalLiveImage(zarch, targetEVEVersion) + if err != nil { + d.th.t.Fatalf("UpgradeEVE: failed to resolve the local EVE build: %v", err) + } + if img == nil { + // Unreachable: this path is only taken when the live transport is on. + d.th.t.Fatalf("UpgradeEVE: the live image transport is not configured") + } + if img.RootfsPath == "" { + d.th.t.Fatalf("UpgradeEVE: local EVE build %q has no installer/rootfs.img "+ + "to upgrade to", img.Version) + } + if img.ShortVersion == "" { + d.th.t.Fatalf("UpgradeEVE: local EVE build %q records no "+ + "installer/eve_version, so the upgraded version could not be "+ + "recognised in the device's reported software list", img.Version) + } + // Whether an upgrade may change hypervisor flavor is EVE's call, not the test + // framework's -- today EVE rejects it (the rootfs has to fit partitions sized + // for the flavor that made them), and that is expected to change. So this + // delivers the build either way and only leaves a breadcrumb, because EVE + // reports the rejection as a FAILED base OS with an empty sub-status, which + // says nothing about the cause. + if buildHV, known := liveImageHypervisor(img.ShortVersion); known { + if runningHV := hypervisorFromAPIType(runningHypervisor); runningHV != buildHV { + d.th.log.Warnf("Device %q runs %s and the local EVE build being "+ + "delivered (%s) is %s; EVE may reject a base OS that changes "+ + "hypervisor flavor", d.devName, runningHV, img.Version, buildHV) + } + } + + // Staged as a copy rather than a link: the file is served for the whole + // download, and rebuilding in place (LIVE_UPDATE=1) would otherwise change it + // under the device mid-transfer. Named by version so consecutive tests + // against the same build reuse it, exactly as the container path does. + rootfsFilename := "rootfs-" + img.ShortVersion + ".img" + rootfsPath := filepath.Join(d.th.imgServerDir, rootfsFilename) + if _, statErr := os.Stat(rootfsPath); os.IsNotExist(statErr) { + d.th.log.Infof("Staging local EVE rootfs %s for the upgrade", img.RootfsPath) + if copyErr := utils.CopyFile(img.RootfsPath, rootfsPath); copyErr != nil { + d.th.t.Fatalf("UpgradeEVE: failed to stage rootfs %q: %v", + img.RootfsPath, copyErr) + } + } else { + d.th.log.Infof("Reusing staged rootfs %s", rootfsFilename) + } + + d.applyUpgrade(rootfsPath, rootfsFilename, img.ShortVersion, + waitUntilUpgraded, expectRevert) +} + +// applyUpgrade points the device's BaseOS config at a rootfs image already +// staged in the harness's HTTP image server and, optionally, waits for the +// outcome. Shared by both transports: they differ only in how rootfsPath got +// there. +func (d *EdgeDevice) applyUpgrade(rootfsPath, rootfsFilename, shortVersion string, + waitUntilUpgraded, expectRevert bool) { + sha256hex, fileSize, err := utils.FileHashAndSize(rootfsPath) if err != nil { d.th.t.Fatalf("UpgradeEVE: failed to hash rootfs %s: %v", rootfsPath, err) diff --git a/evetest/grpcapi/go/broker.pb.go b/evetest/grpcapi/go/broker.pb.go index d003c16cb38..01bf11d2d7a 100644 --- a/evetest/grpcapi/go/broker.pb.go +++ b/evetest/grpcapi/go/broker.pb.go @@ -558,8 +558,15 @@ type BuildImageRequest struct { MakeInstaller bool `protobuf:"varint,4,opt,name=make_installer,json=makeInstaller,proto3" json:"make_installer,omitempty"` // "live" image otherwise DiskBytes uint64 `protobuf:"varint,5,opt,name=disk_bytes,json=diskBytes,proto3" json:"disk_bytes,omitempty"` Config *EveConfig `protobuf:"bytes,6,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When set, the broker builds from this local live image and ignores `image`. + LiveImage *LiveImageRef `protobuf:"bytes,7,opt,name=live_image,json=liveImage,proto3" json:"live_image,omitempty"` + // Where the client's live image files are, so a broker sharing the + // filesystem installs the template by reading them instead of having the + // client upload bytes it can already see. Advisory only -- see + // LocalLiveImageSource. + LiveImageSource *LocalLiveImageSource `protobuf:"bytes,8,opt,name=live_image_source,json=liveImageSource,proto3" json:"live_image_source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BuildImageRequest) Reset() { @@ -634,17 +641,108 @@ func (x *BuildImageRequest) GetConfig() *EveConfig { return nil } +func (x *BuildImageRequest) GetLiveImage() *LiveImageRef { + if x != nil { + return x.LiveImage + } + return nil +} + +func (x *BuildImageRequest) GetLiveImageSource() *LocalLiveImageSource { + if x != nil { + return x.LiveImageSource + } + return nil +} + +// LocalLiveImageSource points at the files behind a LiveImageRef on the +// client's filesystem. It is purely an optimization hint: a broker that cannot +// use these paths -- they do not exist, the size disagrees, or the content does +// not hash to the declared sha256 -- reports missing_eve_live_image and takes +// the upload instead. The content check is what makes trusting the paths safe: +// a file is only ever installed as the template when it hashes to the value the +// client already declared, so a wrong or hostile path cannot substitute a +// different image, it can only fail. +type LocalLiveImageSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + DiskPath string `protobuf:"bytes,1,opt,name=disk_path,json=diskPath,proto3" json:"disk_path,omitempty"` // absolute path to live.qcow2 + DiskBytes uint64 `protobuf:"varint,2,opt,name=disk_bytes,json=diskBytes,proto3" json:"disk_bytes,omitempty"` // its size, checked before its content is read + ConfigImgPath string `protobuf:"bytes,3,opt,name=config_img_path,json=configImgPath,proto3" json:"config_img_path,omitempty"` // absolute path to installer/config.img + FirmwareDir string `protobuf:"bytes,4,opt,name=firmware_dir,json=firmwareDir,proto3" json:"firmware_dir,omitempty"` // absolute path to the dir holding OVMF*.fd + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalLiveImageSource) Reset() { + *x = LocalLiveImageSource{} + mi := &file_broker_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalLiveImageSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalLiveImageSource) ProtoMessage() {} + +func (x *LocalLiveImageSource) ProtoReflect() protoreflect.Message { + mi := &file_broker_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalLiveImageSource.ProtoReflect.Descriptor instead. +func (*LocalLiveImageSource) Descriptor() ([]byte, []int) { + return file_broker_proto_rawDescGZIP(), []int{10} +} + +func (x *LocalLiveImageSource) GetDiskPath() string { + if x != nil { + return x.DiskPath + } + return "" +} + +func (x *LocalLiveImageSource) GetDiskBytes() uint64 { + if x != nil { + return x.DiskBytes + } + return 0 +} + +func (x *LocalLiveImageSource) GetConfigImgPath() string { + if x != nil { + return x.ConfigImgPath + } + return "" +} + +func (x *LocalLiveImageSource) GetFirmwareDir() string { + if x != nil { + return x.FirmwareDir + } + return "" +} + // Response to a BuildImageRequest, indicating if broker is missing the EVE container image. type BuildImageResponse struct { state protoimpl.MessageState `protogen:"open.v1"` MissingEveContainerImage bool `protobuf:"varint,1,opt,name=missing_eve_container_image,json=missingEveContainerImage,proto3" json:"missing_eve_container_image,omitempty"` + MissingEveLiveImage bool `protobuf:"varint,2,opt,name=missing_eve_live_image,json=missingEveLiveImage,proto3" json:"missing_eve_live_image,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BuildImageResponse) Reset() { *x = BuildImageResponse{} - mi := &file_broker_proto_msgTypes[10] + mi := &file_broker_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -656,7 +754,7 @@ func (x *BuildImageResponse) String() string { func (*BuildImageResponse) ProtoMessage() {} func (x *BuildImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[10] + mi := &file_broker_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -669,7 +767,7 @@ func (x *BuildImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BuildImageResponse.ProtoReflect.Descriptor instead. func (*BuildImageResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{10} + return file_broker_proto_rawDescGZIP(), []int{11} } func (x *BuildImageResponse) GetMissingEveContainerImage() bool { @@ -679,6 +777,13 @@ func (x *BuildImageResponse) GetMissingEveContainerImage() bool { return false } +func (x *BuildImageResponse) GetMissingEveLiveImage() bool { + if x != nil { + return x.MissingEveLiveImage + } + return false +} + // PushImageChunk represents a single message in the EVE docker image upload stream. // The first message MUST contain a PushImageRequest to provide image metadata. // Subsequent messages contain chunks of the gzipped EVE container image data. @@ -695,7 +800,7 @@ type PushImageChunk struct { func (x *PushImageChunk) Reset() { *x = PushImageChunk{} - mi := &file_broker_proto_msgTypes[11] + mi := &file_broker_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -707,7 +812,7 @@ func (x *PushImageChunk) String() string { func (*PushImageChunk) ProtoMessage() {} func (x *PushImageChunk) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[11] + mi := &file_broker_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -720,7 +825,7 @@ func (x *PushImageChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PushImageChunk.ProtoReflect.Descriptor instead. func (*PushImageChunk) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{11} + return file_broker_proto_rawDescGZIP(), []int{12} } func (x *PushImageChunk) GetPayload() isPushImageChunk_Payload { @@ -777,7 +882,7 @@ type PushImageRequest struct { func (x *PushImageRequest) Reset() { *x = PushImageRequest{} - mi := &file_broker_proto_msgTypes[12] + mi := &file_broker_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -789,7 +894,7 @@ func (x *PushImageRequest) String() string { func (*PushImageRequest) ProtoMessage() {} func (x *PushImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[12] + mi := &file_broker_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -802,7 +907,7 @@ func (x *PushImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushImageRequest.ProtoReflect.Descriptor instead. func (*PushImageRequest) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{12} + return file_broker_proto_rawDescGZIP(), []int{13} } func (x *PushImageRequest) GetClientId() string { @@ -829,7 +934,7 @@ type PushImageResponse struct { func (x *PushImageResponse) Reset() { *x = PushImageResponse{} - mi := &file_broker_proto_msgTypes[13] + mi := &file_broker_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -841,7 +946,7 @@ func (x *PushImageResponse) String() string { func (*PushImageResponse) ProtoMessage() {} func (x *PushImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[13] + mi := &file_broker_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -854,7 +959,7 @@ func (x *PushImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushImageResponse.ProtoReflect.Descriptor instead. func (*PushImageResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{13} + return file_broker_proto_rawDescGZIP(), []int{14} } func (x *PushImageResponse) GetAlreadyExists() bool { @@ -864,6 +969,191 @@ func (x *PushImageResponse) GetAlreadyExists() bool { return false } +// PushLiveImageChunk is one message in the live-image upload stream. The first +// message MUST be a PushLiveImageRequest; the rest are tar bytes. +type PushLiveImageChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *PushLiveImageChunk_Request + // *PushLiveImageChunk_DataChunk + Payload isPushLiveImageChunk_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushLiveImageChunk) Reset() { + *x = PushLiveImageChunk{} + mi := &file_broker_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushLiveImageChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushLiveImageChunk) ProtoMessage() {} + +func (x *PushLiveImageChunk) ProtoReflect() protoreflect.Message { + mi := &file_broker_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushLiveImageChunk.ProtoReflect.Descriptor instead. +func (*PushLiveImageChunk) Descriptor() ([]byte, []int) { + return file_broker_proto_rawDescGZIP(), []int{15} +} + +func (x *PushLiveImageChunk) GetPayload() isPushLiveImageChunk_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *PushLiveImageChunk) GetRequest() *PushLiveImageRequest { + if x != nil { + if x, ok := x.Payload.(*PushLiveImageChunk_Request); ok { + return x.Request + } + } + return nil +} + +func (x *PushLiveImageChunk) GetDataChunk() []byte { + if x != nil { + if x, ok := x.Payload.(*PushLiveImageChunk_DataChunk); ok { + return x.DataChunk + } + } + return nil +} + +type isPushLiveImageChunk_Payload interface { + isPushLiveImageChunk_Payload() +} + +type PushLiveImageChunk_Request struct { + Request *PushLiveImageRequest `protobuf:"bytes,1,opt,name=request,proto3,oneof"` +} + +type PushLiveImageChunk_DataChunk struct { + // Raw tar bytes -- deliberately not gzipped. The qcow2 inside is already + // zlib-compressed by `qemu-img convert -c`, so re-compressing costs CPU + // for almost nothing. + DataChunk []byte `protobuf:"bytes,2,opt,name=data_chunk,json=dataChunk,proto3,oneof"` +} + +func (*PushLiveImageChunk_Request) isPushLiveImageChunk_Payload() {} + +func (*PushLiveImageChunk_DataChunk) isPushLiveImageChunk_Payload() {} + +// PushLiveImageRequest contains metadata describing the EVE live image to be uploaded. +type PushLiveImageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + LiveImage *LiveImageRef `protobuf:"bytes,2,opt,name=live_image,json=liveImage,proto3" json:"live_image,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushLiveImageRequest) Reset() { + *x = PushLiveImageRequest{} + mi := &file_broker_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushLiveImageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushLiveImageRequest) ProtoMessage() {} + +func (x *PushLiveImageRequest) ProtoReflect() protoreflect.Message { + mi := &file_broker_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushLiveImageRequest.ProtoReflect.Descriptor instead. +func (*PushLiveImageRequest) Descriptor() ([]byte, []int) { + return file_broker_proto_rawDescGZIP(), []int{16} +} + +func (x *PushLiveImageRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *PushLiveImageRequest) GetLiveImage() *LiveImageRef { + if x != nil { + return x.LiveImage + } + return nil +} + +// PushLiveImageResponse is returned after the EVE live image upload completes. +type PushLiveImageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushLiveImageResponse) Reset() { + *x = PushLiveImageResponse{} + mi := &file_broker_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushLiveImageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushLiveImageResponse) ProtoMessage() {} + +func (x *PushLiveImageResponse) ProtoReflect() protoreflect.Message { + mi := &file_broker_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushLiveImageResponse.ProtoReflect.Descriptor instead. +func (*PushLiveImageResponse) Descriptor() ([]byte, []int) { + return file_broker_proto_rawDescGZIP(), []int{17} +} + +func (x *PushLiveImageResponse) GetAlreadyExists() bool { + if x != nil { + return x.AlreadyExists + } + return false +} + // Request to setup a group of EVE devices and the SDN. type SetupDevicesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -876,7 +1166,7 @@ type SetupDevicesRequest struct { func (x *SetupDevicesRequest) Reset() { *x = SetupDevicesRequest{} - mi := &file_broker_proto_msgTypes[14] + mi := &file_broker_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -888,7 +1178,7 @@ func (x *SetupDevicesRequest) String() string { func (*SetupDevicesRequest) ProtoMessage() {} func (x *SetupDevicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[14] + mi := &file_broker_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -901,7 +1191,7 @@ func (x *SetupDevicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetupDevicesRequest.ProtoReflect.Descriptor instead. func (*SetupDevicesRequest) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{14} + return file_broker_proto_rawDescGZIP(), []int{18} } func (x *SetupDevicesRequest) GetClientId() string { @@ -935,7 +1225,7 @@ type SetupDevicesResponse struct { func (x *SetupDevicesResponse) Reset() { *x = SetupDevicesResponse{} - mi := &file_broker_proto_msgTypes[15] + mi := &file_broker_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -947,7 +1237,7 @@ func (x *SetupDevicesResponse) String() string { func (*SetupDevicesResponse) ProtoMessage() {} func (x *SetupDevicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[15] + mi := &file_broker_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -960,7 +1250,7 @@ func (x *SetupDevicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetupDevicesResponse.ProtoReflect.Descriptor instead. func (*SetupDevicesResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{15} + return file_broker_proto_rawDescGZIP(), []int{19} } func (x *SetupDevicesResponse) GetSdnUplinkIps() []string { @@ -982,7 +1272,7 @@ type TeardownDevicesRequest struct { func (x *TeardownDevicesRequest) Reset() { *x = TeardownDevicesRequest{} - mi := &file_broker_proto_msgTypes[16] + mi := &file_broker_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -994,7 +1284,7 @@ func (x *TeardownDevicesRequest) String() string { func (*TeardownDevicesRequest) ProtoMessage() {} func (x *TeardownDevicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[16] + mi := &file_broker_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1007,7 +1297,7 @@ func (x *TeardownDevicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TeardownDevicesRequest.ProtoReflect.Descriptor instead. func (*TeardownDevicesRequest) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{16} + return file_broker_proto_rawDescGZIP(), []int{20} } func (x *TeardownDevicesRequest) GetClientId() string { @@ -1026,7 +1316,7 @@ type TeardownDevicesResponse struct { func (x *TeardownDevicesResponse) Reset() { *x = TeardownDevicesResponse{} - mi := &file_broker_proto_msgTypes[17] + mi := &file_broker_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1038,7 +1328,7 @@ func (x *TeardownDevicesResponse) String() string { func (*TeardownDevicesResponse) ProtoMessage() {} func (x *TeardownDevicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[17] + mi := &file_broker_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1051,7 +1341,7 @@ func (x *TeardownDevicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TeardownDevicesResponse.ProtoReflect.Descriptor instead. func (*TeardownDevicesResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{17} + return file_broker_proto_rawDescGZIP(), []int{21} } // Request to control a specific device (power on/off/reboot). @@ -1065,7 +1355,7 @@ type DeviceControlRequest struct { func (x *DeviceControlRequest) Reset() { *x = DeviceControlRequest{} - mi := &file_broker_proto_msgTypes[18] + mi := &file_broker_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1077,7 +1367,7 @@ func (x *DeviceControlRequest) String() string { func (*DeviceControlRequest) ProtoMessage() {} func (x *DeviceControlRequest) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[18] + mi := &file_broker_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1090,7 +1380,7 @@ func (x *DeviceControlRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceControlRequest.ProtoReflect.Descriptor instead. func (*DeviceControlRequest) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{18} + return file_broker_proto_rawDescGZIP(), []int{22} } func (x *DeviceControlRequest) GetClientId() string { @@ -1116,7 +1406,7 @@ type DeviceControlResponse struct { func (x *DeviceControlResponse) Reset() { *x = DeviceControlResponse{} - mi := &file_broker_proto_msgTypes[19] + mi := &file_broker_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1128,7 +1418,7 @@ func (x *DeviceControlResponse) String() string { func (*DeviceControlResponse) ProtoMessage() {} func (x *DeviceControlResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[19] + mi := &file_broker_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1141,7 +1431,7 @@ func (x *DeviceControlResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceControlResponse.ProtoReflect.Descriptor instead. func (*DeviceControlResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{19} + return file_broker_proto_rawDescGZIP(), []int{23} } // ConnectConsoleRequest carries data sent from the client to the broker @@ -1159,7 +1449,7 @@ type ConnectConsoleRequest struct { func (x *ConnectConsoleRequest) Reset() { *x = ConnectConsoleRequest{} - mi := &file_broker_proto_msgTypes[20] + mi := &file_broker_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1171,7 +1461,7 @@ func (x *ConnectConsoleRequest) String() string { func (*ConnectConsoleRequest) ProtoMessage() {} func (x *ConnectConsoleRequest) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[20] + mi := &file_broker_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1184,7 +1474,7 @@ func (x *ConnectConsoleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConnectConsoleRequest.ProtoReflect.Descriptor instead. func (*ConnectConsoleRequest) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{20} + return file_broker_proto_rawDescGZIP(), []int{24} } func (x *ConnectConsoleRequest) GetPayload() isConnectConsoleRequest_Payload { @@ -1246,7 +1536,7 @@ type ConnectConsoleResponse struct { func (x *ConnectConsoleResponse) Reset() { *x = ConnectConsoleResponse{} - mi := &file_broker_proto_msgTypes[21] + mi := &file_broker_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1258,7 +1548,7 @@ func (x *ConnectConsoleResponse) String() string { func (*ConnectConsoleResponse) ProtoMessage() {} func (x *ConnectConsoleResponse) ProtoReflect() protoreflect.Message { - mi := &file_broker_proto_msgTypes[21] + mi := &file_broker_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1271,7 +1561,7 @@ func (x *ConnectConsoleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConnectConsoleResponse.ProtoReflect.Descriptor instead. func (*ConnectConsoleResponse) Descriptor() ([]byte, []int) { - return file_broker_proto_rawDescGZIP(), []int{21} + return file_broker_proto_rawDescGZIP(), []int{25} } func (x *ConnectConsoleResponse) GetPayload() isConnectConsoleResponse_Payload { @@ -1358,7 +1648,7 @@ const file_broker_proto_rawDesc = "" + "globalJson\x12#\n" + "\roverride_json\x18\n" + " \x01(\tR\foverrideJson\x12.\n" + - "\x13bootstrap_config_pb\x18\v \x01(\fR\x11bootstrapConfigPb\"\x82\x02\n" + + "\x13bootstrap_config_pb\x18\v \x01(\fR\x11bootstrapConfigPb\"\x99\x03\n" + "\x11BuildImageRequest\x12\x1b\n" + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12\x1f\n" + "\vdevice_name\x18\x02 \x01(\tR\n" + @@ -1367,9 +1657,19 @@ const file_broker_proto_rawDesc = "" + "\x0emake_installer\x18\x04 \x01(\bR\rmakeInstaller\x12\x1d\n" + "\n" + "disk_bytes\x18\x05 \x01(\x04R\tdiskBytes\x125\n" + - "\x06config\x18\x06 \x01(\v2\x1d.org.lfedge.evetest.EveConfigR\x06config\"S\n" + + "\x06config\x18\x06 \x01(\v2\x1d.org.lfedge.evetest.EveConfigR\x06config\x12?\n" + + "\n" + + "live_image\x18\a \x01(\v2 .org.lfedge.evetest.LiveImageRefR\tliveImage\x12T\n" + + "\x11live_image_source\x18\b \x01(\v2(.org.lfedge.evetest.LocalLiveImageSourceR\x0fliveImageSource\"\x9d\x01\n" + + "\x14LocalLiveImageSource\x12\x1b\n" + + "\tdisk_path\x18\x01 \x01(\tR\bdiskPath\x12\x1d\n" + + "\n" + + "disk_bytes\x18\x02 \x01(\x04R\tdiskBytes\x12&\n" + + "\x0fconfig_img_path\x18\x03 \x01(\tR\rconfigImgPath\x12!\n" + + "\ffirmware_dir\x18\x04 \x01(\tR\vfirmwareDir\"\x88\x01\n" + "\x12BuildImageResponse\x12=\n" + - "\x1bmissing_eve_container_image\x18\x01 \x01(\bR\x18missingEveContainerImage\"\x87\x01\n" + + "\x1bmissing_eve_container_image\x18\x01 \x01(\bR\x18missingEveContainerImage\x123\n" + + "\x16missing_eve_live_image\x18\x02 \x01(\bR\x13missingEveLiveImage\"\x87\x01\n" + "\x0ePushImageChunk\x12@\n" + "\arequest\x18\x01 \x01(\v2$.org.lfedge.evetest.PushImageRequestH\x00R\arequest\x12(\n" + "\x0fdata_gzip_chunk\x18\x02 \x01(\fH\x00R\rdataGzipChunkB\t\n" + @@ -1378,6 +1678,17 @@ const file_broker_proto_rawDesc = "" + "\tclient_id\x18\x01 \x01(\tR\bclientId\x122\n" + "\x05image\x18\x02 \x01(\v2\x1c.org.lfedge.evetest.ImageRefR\x05image\":\n" + "\x11PushImageResponse\x12%\n" + + "\x0ealready_exists\x18\x01 \x01(\bR\ralreadyExists\"\x86\x01\n" + + "\x12PushLiveImageChunk\x12D\n" + + "\arequest\x18\x01 \x01(\v2(.org.lfedge.evetest.PushLiveImageRequestH\x00R\arequest\x12\x1f\n" + + "\n" + + "data_chunk\x18\x02 \x01(\fH\x00R\tdataChunkB\t\n" + + "\apayload\"t\n" + + "\x14PushLiveImageRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12?\n" + + "\n" + + "live_image\x18\x02 \x01(\v2 .org.lfedge.evetest.LiveImageRefR\tliveImage\">\n" + + "\x15PushLiveImageResponse\x12%\n" + "\x0ealready_exists\x18\x01 \x01(\bR\ralreadyExists\"\xa9\x01\n" + "\x13SetupDevicesRequest\x12\x1b\n" + "\tclient_id\x18\x01 \x01(\tR\bclientId\x127\n" + @@ -1401,8 +1712,7 @@ const file_broker_proto_rawDesc = "" + "\x16ConnectConsoleResponse\x12L\n" + "\rconnect_reply\x18\x01 \x01(\v2%.org.lfedge.evetest.ConsolePropertiesH\x00R\fconnectReply\x12\x14\n" + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + - "\apayload2\xf3\n" + - "\n" + + "\apayload2\xdc\v\n" + "\x06Broker\x12R\n" + "\aConnect\x12\".org.lfedge.evetest.ConnectRequest\x1a#.org.lfedge.evetest.ConnectResponse\x12L\n" + "\x05Close\x12 .org.lfedge.evetest.CloseRequest\x1a!.org.lfedge.evetest.CloseResponse\x12U\n" + @@ -1411,7 +1721,8 @@ const file_broker_proto_rawDesc = "" + "StreamLogs\x12\x1f.org.lfedge.evetest.LogsRequest\x1a\x1e.org.lfedge.evetest.LogMessage0\x01\x12[\n" + "\n" + "BuildImage\x12%.org.lfedge.evetest.BuildImageRequest\x1a&.org.lfedge.evetest.BuildImageResponse\x12d\n" + - "\x15PushEVEContainerImage\x12\".org.lfedge.evetest.PushImageChunk\x1a%.org.lfedge.evetest.PushImageResponse(\x01\x12a\n" + + "\x15PushEVEContainerImage\x12\".org.lfedge.evetest.PushImageChunk\x1a%.org.lfedge.evetest.PushImageResponse(\x01\x12g\n" + + "\x10PushEVELiveImage\x12&.org.lfedge.evetest.PushLiveImageChunk\x1a).org.lfedge.evetest.PushLiveImageResponse(\x01\x12a\n" + "\fSetupDevices\x12'.org.lfedge.evetest.SetupDevicesRequest\x1a(.org.lfedge.evetest.SetupDevicesResponse\x12j\n" + "\x0fTeardownDevices\x12*.org.lfedge.evetest.TeardownDevicesRequest\x1a+.org.lfedge.evetest.TeardownDevicesResponse\x12d\n" + "\rPowerOnDevice\x12(.org.lfedge.evetest.DeviceControlRequest\x1a).org.lfedge.evetest.DeviceControlResponse\x12e\n" + @@ -1433,7 +1744,7 @@ func file_broker_proto_rawDescGZIP() []byte { return file_broker_proto_rawDescData } -var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_broker_proto_goTypes = []any{ (*ConnectRequest)(nil), // 0: org.lfedge.evetest.ConnectRequest (*ConnectResponse)(nil), // 1: org.lfedge.evetest.ConnectResponse @@ -1445,74 +1756,85 @@ var file_broker_proto_goTypes = []any{ (*LogsRequest)(nil), // 7: org.lfedge.evetest.LogsRequest (*EveConfig)(nil), // 8: org.lfedge.evetest.EveConfig (*BuildImageRequest)(nil), // 9: org.lfedge.evetest.BuildImageRequest - (*BuildImageResponse)(nil), // 10: org.lfedge.evetest.BuildImageResponse - (*PushImageChunk)(nil), // 11: org.lfedge.evetest.PushImageChunk - (*PushImageRequest)(nil), // 12: org.lfedge.evetest.PushImageRequest - (*PushImageResponse)(nil), // 13: org.lfedge.evetest.PushImageResponse - (*SetupDevicesRequest)(nil), // 14: org.lfedge.evetest.SetupDevicesRequest - (*SetupDevicesResponse)(nil), // 15: org.lfedge.evetest.SetupDevicesResponse - (*TeardownDevicesRequest)(nil), // 16: org.lfedge.evetest.TeardownDevicesRequest - (*TeardownDevicesResponse)(nil), // 17: org.lfedge.evetest.TeardownDevicesResponse - (*DeviceControlRequest)(nil), // 18: org.lfedge.evetest.DeviceControlRequest - (*DeviceControlResponse)(nil), // 19: org.lfedge.evetest.DeviceControlResponse - (*ConnectConsoleRequest)(nil), // 20: org.lfedge.evetest.ConnectConsoleRequest - (*ConnectConsoleResponse)(nil), // 21: org.lfedge.evetest.ConnectConsoleResponse - (ArchType)(0), // 22: org.lfedge.evetest.ArchType - (Capability)(0), // 23: org.lfedge.evetest.Capability - (*ImageRef)(nil), // 24: org.lfedge.evetest.ImageRef - (*EVEDevice)(nil), // 25: org.lfedge.evetest.EVEDevice - (*SDNConfig)(nil), // 26: org.lfedge.evetest.SDNConfig - (*ConsoleProperties)(nil), // 27: org.lfedge.evetest.ConsoleProperties - (*ConnectTunnelToSDNRequest)(nil), // 28: org.lfedge.evetest.ConnectTunnelToSDNRequest - (*LogMessage)(nil), // 29: org.lfedge.evetest.LogMessage - (*ConsoleOutputResponse)(nil), // 30: org.lfedge.evetest.ConsoleOutputResponse - (*ConnectTunnelToSDNResponse)(nil), // 31: org.lfedge.evetest.ConnectTunnelToSDNResponse + (*LocalLiveImageSource)(nil), // 10: org.lfedge.evetest.LocalLiveImageSource + (*BuildImageResponse)(nil), // 11: org.lfedge.evetest.BuildImageResponse + (*PushImageChunk)(nil), // 12: org.lfedge.evetest.PushImageChunk + (*PushImageRequest)(nil), // 13: org.lfedge.evetest.PushImageRequest + (*PushImageResponse)(nil), // 14: org.lfedge.evetest.PushImageResponse + (*PushLiveImageChunk)(nil), // 15: org.lfedge.evetest.PushLiveImageChunk + (*PushLiveImageRequest)(nil), // 16: org.lfedge.evetest.PushLiveImageRequest + (*PushLiveImageResponse)(nil), // 17: org.lfedge.evetest.PushLiveImageResponse + (*SetupDevicesRequest)(nil), // 18: org.lfedge.evetest.SetupDevicesRequest + (*SetupDevicesResponse)(nil), // 19: org.lfedge.evetest.SetupDevicesResponse + (*TeardownDevicesRequest)(nil), // 20: org.lfedge.evetest.TeardownDevicesRequest + (*TeardownDevicesResponse)(nil), // 21: org.lfedge.evetest.TeardownDevicesResponse + (*DeviceControlRequest)(nil), // 22: org.lfedge.evetest.DeviceControlRequest + (*DeviceControlResponse)(nil), // 23: org.lfedge.evetest.DeviceControlResponse + (*ConnectConsoleRequest)(nil), // 24: org.lfedge.evetest.ConnectConsoleRequest + (*ConnectConsoleResponse)(nil), // 25: org.lfedge.evetest.ConnectConsoleResponse + (ArchType)(0), // 26: org.lfedge.evetest.ArchType + (Capability)(0), // 27: org.lfedge.evetest.Capability + (*ImageRef)(nil), // 28: org.lfedge.evetest.ImageRef + (*LiveImageRef)(nil), // 29: org.lfedge.evetest.LiveImageRef + (*EVEDevice)(nil), // 30: org.lfedge.evetest.EVEDevice + (*SDNConfig)(nil), // 31: org.lfedge.evetest.SDNConfig + (*ConsoleProperties)(nil), // 32: org.lfedge.evetest.ConsoleProperties + (*ConnectTunnelToSDNRequest)(nil), // 33: org.lfedge.evetest.ConnectTunnelToSDNRequest + (*LogMessage)(nil), // 34: org.lfedge.evetest.LogMessage + (*ConsoleOutputResponse)(nil), // 35: org.lfedge.evetest.ConsoleOutputResponse + (*ConnectTunnelToSDNResponse)(nil), // 36: org.lfedge.evetest.ConnectTunnelToSDNResponse } var file_broker_proto_depIdxs = []int32{ - 22, // 0: org.lfedge.evetest.ConnectResponse.supported_archs:type_name -> org.lfedge.evetest.ArchType - 23, // 1: org.lfedge.evetest.ConnectResponse.provider_capabilities:type_name -> org.lfedge.evetest.Capability + 26, // 0: org.lfedge.evetest.ConnectResponse.supported_archs:type_name -> org.lfedge.evetest.ArchType + 27, // 1: org.lfedge.evetest.ConnectResponse.provider_capabilities:type_name -> org.lfedge.evetest.Capability 2, // 2: org.lfedge.evetest.ConnectResponse.registry_mirrors:type_name -> org.lfedge.evetest.RegistryMirror - 24, // 3: org.lfedge.evetest.BuildImageRequest.image:type_name -> org.lfedge.evetest.ImageRef + 28, // 3: org.lfedge.evetest.BuildImageRequest.image:type_name -> org.lfedge.evetest.ImageRef 8, // 4: org.lfedge.evetest.BuildImageRequest.config:type_name -> org.lfedge.evetest.EveConfig - 12, // 5: org.lfedge.evetest.PushImageChunk.request:type_name -> org.lfedge.evetest.PushImageRequest - 24, // 6: org.lfedge.evetest.PushImageRequest.image:type_name -> org.lfedge.evetest.ImageRef - 25, // 7: org.lfedge.evetest.SetupDevicesRequest.devices:type_name -> org.lfedge.evetest.EVEDevice - 26, // 8: org.lfedge.evetest.SetupDevicesRequest.sdn_config:type_name -> org.lfedge.evetest.SDNConfig - 18, // 9: org.lfedge.evetest.ConnectConsoleRequest.connect:type_name -> org.lfedge.evetest.DeviceControlRequest - 27, // 10: org.lfedge.evetest.ConnectConsoleResponse.connect_reply:type_name -> org.lfedge.evetest.ConsoleProperties - 0, // 11: org.lfedge.evetest.Broker.Connect:input_type -> org.lfedge.evetest.ConnectRequest - 3, // 12: org.lfedge.evetest.Broker.Close:input_type -> org.lfedge.evetest.CloseRequest - 5, // 13: org.lfedge.evetest.Broker.KeepAlive:input_type -> org.lfedge.evetest.KeepAlivePing - 7, // 14: org.lfedge.evetest.Broker.StreamLogs:input_type -> org.lfedge.evetest.LogsRequest - 9, // 15: org.lfedge.evetest.Broker.BuildImage:input_type -> org.lfedge.evetest.BuildImageRequest - 11, // 16: org.lfedge.evetest.Broker.PushEVEContainerImage:input_type -> org.lfedge.evetest.PushImageChunk - 14, // 17: org.lfedge.evetest.Broker.SetupDevices:input_type -> org.lfedge.evetest.SetupDevicesRequest - 16, // 18: org.lfedge.evetest.Broker.TeardownDevices:input_type -> org.lfedge.evetest.TeardownDevicesRequest - 18, // 19: org.lfedge.evetest.Broker.PowerOnDevice:input_type -> org.lfedge.evetest.DeviceControlRequest - 18, // 20: org.lfedge.evetest.Broker.PowerOffDevice:input_type -> org.lfedge.evetest.DeviceControlRequest - 18, // 21: org.lfedge.evetest.Broker.RebootDevice:input_type -> org.lfedge.evetest.DeviceControlRequest - 18, // 22: org.lfedge.evetest.Broker.GetDeviceConsoleOutput:input_type -> org.lfedge.evetest.DeviceControlRequest - 20, // 23: org.lfedge.evetest.Broker.ConnectConsoleToDevice:input_type -> org.lfedge.evetest.ConnectConsoleRequest - 28, // 24: org.lfedge.evetest.Broker.ConnectTunnelToSDN:input_type -> org.lfedge.evetest.ConnectTunnelToSDNRequest - 1, // 25: org.lfedge.evetest.Broker.Connect:output_type -> org.lfedge.evetest.ConnectResponse - 4, // 26: org.lfedge.evetest.Broker.Close:output_type -> org.lfedge.evetest.CloseResponse - 6, // 27: org.lfedge.evetest.Broker.KeepAlive:output_type -> org.lfedge.evetest.KeepAlivePong - 29, // 28: org.lfedge.evetest.Broker.StreamLogs:output_type -> org.lfedge.evetest.LogMessage - 10, // 29: org.lfedge.evetest.Broker.BuildImage:output_type -> org.lfedge.evetest.BuildImageResponse - 13, // 30: org.lfedge.evetest.Broker.PushEVEContainerImage:output_type -> org.lfedge.evetest.PushImageResponse - 15, // 31: org.lfedge.evetest.Broker.SetupDevices:output_type -> org.lfedge.evetest.SetupDevicesResponse - 17, // 32: org.lfedge.evetest.Broker.TeardownDevices:output_type -> org.lfedge.evetest.TeardownDevicesResponse - 19, // 33: org.lfedge.evetest.Broker.PowerOnDevice:output_type -> org.lfedge.evetest.DeviceControlResponse - 19, // 34: org.lfedge.evetest.Broker.PowerOffDevice:output_type -> org.lfedge.evetest.DeviceControlResponse - 19, // 35: org.lfedge.evetest.Broker.RebootDevice:output_type -> org.lfedge.evetest.DeviceControlResponse - 30, // 36: org.lfedge.evetest.Broker.GetDeviceConsoleOutput:output_type -> org.lfedge.evetest.ConsoleOutputResponse - 21, // 37: org.lfedge.evetest.Broker.ConnectConsoleToDevice:output_type -> org.lfedge.evetest.ConnectConsoleResponse - 31, // 38: org.lfedge.evetest.Broker.ConnectTunnelToSDN:output_type -> org.lfedge.evetest.ConnectTunnelToSDNResponse - 25, // [25:39] is the sub-list for method output_type - 11, // [11:25] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 29, // 5: org.lfedge.evetest.BuildImageRequest.live_image:type_name -> org.lfedge.evetest.LiveImageRef + 10, // 6: org.lfedge.evetest.BuildImageRequest.live_image_source:type_name -> org.lfedge.evetest.LocalLiveImageSource + 13, // 7: org.lfedge.evetest.PushImageChunk.request:type_name -> org.lfedge.evetest.PushImageRequest + 28, // 8: org.lfedge.evetest.PushImageRequest.image:type_name -> org.lfedge.evetest.ImageRef + 16, // 9: org.lfedge.evetest.PushLiveImageChunk.request:type_name -> org.lfedge.evetest.PushLiveImageRequest + 29, // 10: org.lfedge.evetest.PushLiveImageRequest.live_image:type_name -> org.lfedge.evetest.LiveImageRef + 30, // 11: org.lfedge.evetest.SetupDevicesRequest.devices:type_name -> org.lfedge.evetest.EVEDevice + 31, // 12: org.lfedge.evetest.SetupDevicesRequest.sdn_config:type_name -> org.lfedge.evetest.SDNConfig + 22, // 13: org.lfedge.evetest.ConnectConsoleRequest.connect:type_name -> org.lfedge.evetest.DeviceControlRequest + 32, // 14: org.lfedge.evetest.ConnectConsoleResponse.connect_reply:type_name -> org.lfedge.evetest.ConsoleProperties + 0, // 15: org.lfedge.evetest.Broker.Connect:input_type -> org.lfedge.evetest.ConnectRequest + 3, // 16: org.lfedge.evetest.Broker.Close:input_type -> org.lfedge.evetest.CloseRequest + 5, // 17: org.lfedge.evetest.Broker.KeepAlive:input_type -> org.lfedge.evetest.KeepAlivePing + 7, // 18: org.lfedge.evetest.Broker.StreamLogs:input_type -> org.lfedge.evetest.LogsRequest + 9, // 19: org.lfedge.evetest.Broker.BuildImage:input_type -> org.lfedge.evetest.BuildImageRequest + 12, // 20: org.lfedge.evetest.Broker.PushEVEContainerImage:input_type -> org.lfedge.evetest.PushImageChunk + 15, // 21: org.lfedge.evetest.Broker.PushEVELiveImage:input_type -> org.lfedge.evetest.PushLiveImageChunk + 18, // 22: org.lfedge.evetest.Broker.SetupDevices:input_type -> org.lfedge.evetest.SetupDevicesRequest + 20, // 23: org.lfedge.evetest.Broker.TeardownDevices:input_type -> org.lfedge.evetest.TeardownDevicesRequest + 22, // 24: org.lfedge.evetest.Broker.PowerOnDevice:input_type -> org.lfedge.evetest.DeviceControlRequest + 22, // 25: org.lfedge.evetest.Broker.PowerOffDevice:input_type -> org.lfedge.evetest.DeviceControlRequest + 22, // 26: org.lfedge.evetest.Broker.RebootDevice:input_type -> org.lfedge.evetest.DeviceControlRequest + 22, // 27: org.lfedge.evetest.Broker.GetDeviceConsoleOutput:input_type -> org.lfedge.evetest.DeviceControlRequest + 24, // 28: org.lfedge.evetest.Broker.ConnectConsoleToDevice:input_type -> org.lfedge.evetest.ConnectConsoleRequest + 33, // 29: org.lfedge.evetest.Broker.ConnectTunnelToSDN:input_type -> org.lfedge.evetest.ConnectTunnelToSDNRequest + 1, // 30: org.lfedge.evetest.Broker.Connect:output_type -> org.lfedge.evetest.ConnectResponse + 4, // 31: org.lfedge.evetest.Broker.Close:output_type -> org.lfedge.evetest.CloseResponse + 6, // 32: org.lfedge.evetest.Broker.KeepAlive:output_type -> org.lfedge.evetest.KeepAlivePong + 34, // 33: org.lfedge.evetest.Broker.StreamLogs:output_type -> org.lfedge.evetest.LogMessage + 11, // 34: org.lfedge.evetest.Broker.BuildImage:output_type -> org.lfedge.evetest.BuildImageResponse + 14, // 35: org.lfedge.evetest.Broker.PushEVEContainerImage:output_type -> org.lfedge.evetest.PushImageResponse + 17, // 36: org.lfedge.evetest.Broker.PushEVELiveImage:output_type -> org.lfedge.evetest.PushLiveImageResponse + 19, // 37: org.lfedge.evetest.Broker.SetupDevices:output_type -> org.lfedge.evetest.SetupDevicesResponse + 21, // 38: org.lfedge.evetest.Broker.TeardownDevices:output_type -> org.lfedge.evetest.TeardownDevicesResponse + 23, // 39: org.lfedge.evetest.Broker.PowerOnDevice:output_type -> org.lfedge.evetest.DeviceControlResponse + 23, // 40: org.lfedge.evetest.Broker.PowerOffDevice:output_type -> org.lfedge.evetest.DeviceControlResponse + 23, // 41: org.lfedge.evetest.Broker.RebootDevice:output_type -> org.lfedge.evetest.DeviceControlResponse + 35, // 42: org.lfedge.evetest.Broker.GetDeviceConsoleOutput:output_type -> org.lfedge.evetest.ConsoleOutputResponse + 25, // 43: org.lfedge.evetest.Broker.ConnectConsoleToDevice:output_type -> org.lfedge.evetest.ConnectConsoleResponse + 36, // 44: org.lfedge.evetest.Broker.ConnectTunnelToSDN:output_type -> org.lfedge.evetest.ConnectTunnelToSDNResponse + 30, // [30:45] is the sub-list for method output_type + 15, // [15:30] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_broker_proto_init() } @@ -1522,15 +1844,19 @@ func file_broker_proto_init() { } file_common_proto_init() file_sdn_proto_init() - file_broker_proto_msgTypes[11].OneofWrappers = []any{ + file_broker_proto_msgTypes[12].OneofWrappers = []any{ (*PushImageChunk_Request)(nil), (*PushImageChunk_DataGzipChunk)(nil), } - file_broker_proto_msgTypes[20].OneofWrappers = []any{ + file_broker_proto_msgTypes[15].OneofWrappers = []any{ + (*PushLiveImageChunk_Request)(nil), + (*PushLiveImageChunk_DataChunk)(nil), + } + file_broker_proto_msgTypes[24].OneofWrappers = []any{ (*ConnectConsoleRequest_Connect)(nil), (*ConnectConsoleRequest_Data)(nil), } - file_broker_proto_msgTypes[21].OneofWrappers = []any{ + file_broker_proto_msgTypes[25].OneofWrappers = []any{ (*ConnectConsoleResponse_ConnectReply)(nil), (*ConnectConsoleResponse_Data)(nil), } @@ -1540,7 +1866,7 @@ func file_broker_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_broker_proto_rawDesc), len(file_broker_proto_rawDesc)), NumEnums: 0, - NumMessages: 22, + NumMessages: 26, NumExtensions: 0, NumServices: 1, }, diff --git a/evetest/grpcapi/go/broker_grpc.pb.go b/evetest/grpcapi/go/broker_grpc.pb.go index fb112879754..9241cd62698 100644 --- a/evetest/grpcapi/go/broker_grpc.pb.go +++ b/evetest/grpcapi/go/broker_grpc.pb.go @@ -28,6 +28,7 @@ const ( Broker_StreamLogs_FullMethodName = "/org.lfedge.evetest.Broker/StreamLogs" Broker_BuildImage_FullMethodName = "/org.lfedge.evetest.Broker/BuildImage" Broker_PushEVEContainerImage_FullMethodName = "/org.lfedge.evetest.Broker/PushEVEContainerImage" + Broker_PushEVELiveImage_FullMethodName = "/org.lfedge.evetest.Broker/PushEVELiveImage" Broker_SetupDevices_FullMethodName = "/org.lfedge.evetest.Broker/SetupDevices" Broker_TeardownDevices_FullMethodName = "/org.lfedge.evetest.Broker/TeardownDevices" Broker_PowerOnDevice_FullMethodName = "/org.lfedge.evetest.Broker/PowerOnDevice" @@ -61,6 +62,9 @@ type BrokerClient interface { BuildImage(ctx context.Context, in *BuildImageRequest, opts ...grpc.CallOption) (*BuildImageResponse, error) // PushEVEContainerImage streams a pre-built EVE container image to the broker. PushEVEContainerImage(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushImageChunk, PushImageResponse], error) + // Uploads a locally built EVE live image as a tar stream containing + // live.qcow2, config.img and firmware/*. + PushEVELiveImage(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushLiveImageChunk, PushLiveImageResponse], error) // Provision and start up EVE devices and SDN for the client. SetupDevices(ctx context.Context, in *SetupDevicesRequest, opts ...grpc.CallOption) (*SetupDevicesResponse, error) // Tear down and clean up resources for the client's devices and SDN. @@ -173,6 +177,19 @@ func (c *brokerClient) PushEVEContainerImage(ctx context.Context, opts ...grpc.C // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type Broker_PushEVEContainerImageClient = grpc.ClientStreamingClient[PushImageChunk, PushImageResponse] +func (c *brokerClient) PushEVELiveImage(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushLiveImageChunk, PushLiveImageResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Broker_ServiceDesc.Streams[3], Broker_PushEVELiveImage_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[PushLiveImageChunk, PushLiveImageResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Broker_PushEVELiveImageClient = grpc.ClientStreamingClient[PushLiveImageChunk, PushLiveImageResponse] + func (c *brokerClient) SetupDevices(ctx context.Context, in *SetupDevicesRequest, opts ...grpc.CallOption) (*SetupDevicesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetupDevicesResponse) @@ -235,7 +252,7 @@ func (c *brokerClient) GetDeviceConsoleOutput(ctx context.Context, in *DeviceCon func (c *brokerClient) ConnectConsoleToDevice(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ConnectConsoleRequest, ConnectConsoleResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &Broker_ServiceDesc.Streams[3], Broker_ConnectConsoleToDevice_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &Broker_ServiceDesc.Streams[4], Broker_ConnectConsoleToDevice_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -248,7 +265,7 @@ type Broker_ConnectConsoleToDeviceClient = grpc.BidiStreamingClient[ConnectConso func (c *brokerClient) ConnectTunnelToSDN(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ConnectTunnelToSDNRequest, ConnectTunnelToSDNResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &Broker_ServiceDesc.Streams[4], Broker_ConnectTunnelToSDN_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &Broker_ServiceDesc.Streams[5], Broker_ConnectTunnelToSDN_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -282,6 +299,9 @@ type BrokerServer interface { BuildImage(context.Context, *BuildImageRequest) (*BuildImageResponse, error) // PushEVEContainerImage streams a pre-built EVE container image to the broker. PushEVEContainerImage(grpc.ClientStreamingServer[PushImageChunk, PushImageResponse]) error + // Uploads a locally built EVE live image as a tar stream containing + // live.qcow2, config.img and firmware/*. + PushEVELiveImage(grpc.ClientStreamingServer[PushLiveImageChunk, PushLiveImageResponse]) error // Provision and start up EVE devices and SDN for the client. SetupDevices(context.Context, *SetupDevicesRequest) (*SetupDevicesResponse, error) // Tear down and clean up resources for the client's devices and SDN. @@ -337,6 +357,9 @@ func (UnimplementedBrokerServer) BuildImage(context.Context, *BuildImageRequest) func (UnimplementedBrokerServer) PushEVEContainerImage(grpc.ClientStreamingServer[PushImageChunk, PushImageResponse]) error { return status.Errorf(codes.Unimplemented, "method PushEVEContainerImage not implemented") } +func (UnimplementedBrokerServer) PushEVELiveImage(grpc.ClientStreamingServer[PushLiveImageChunk, PushLiveImageResponse]) error { + return status.Errorf(codes.Unimplemented, "method PushEVELiveImage not implemented") +} func (UnimplementedBrokerServer) SetupDevices(context.Context, *SetupDevicesRequest) (*SetupDevicesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SetupDevices not implemented") } @@ -461,6 +484,13 @@ func _Broker_PushEVEContainerImage_Handler(srv interface{}, stream grpc.ServerSt // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type Broker_PushEVEContainerImageServer = grpc.ClientStreamingServer[PushImageChunk, PushImageResponse] +func _Broker_PushEVELiveImage_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(BrokerServer).PushEVELiveImage(&grpc.GenericServerStream[PushLiveImageChunk, PushLiveImageResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Broker_PushEVELiveImageServer = grpc.ClientStreamingServer[PushLiveImageChunk, PushLiveImageResponse] + func _Broker_SetupDevices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SetupDevicesRequest) if err := dec(in); err != nil { @@ -644,6 +674,11 @@ var Broker_ServiceDesc = grpc.ServiceDesc{ Handler: _Broker_PushEVEContainerImage_Handler, ClientStreams: true, }, + { + StreamName: "PushEVELiveImage", + Handler: _Broker_PushEVELiveImage_Handler, + ClientStreams: true, + }, { StreamName: "ConnectConsoleToDevice", Handler: _Broker_ConnectConsoleToDevice_Handler, diff --git a/evetest/grpcapi/go/common.pb.go b/evetest/grpcapi/go/common.pb.go index 01e24ea4099..4ac578a8450 100644 --- a/evetest/grpcapi/go/common.pb.go +++ b/evetest/grpcapi/go/common.pb.go @@ -149,6 +149,11 @@ const ( Capability_CAPABILITY_FORWARD_LLDP Capability = 3 // Attachment of an emulated TPM (Trusted Platform Module) to the device. Capability_CAPABILITY_TPM Capability = 4 + // Deriving a device's disk from a locally built EVE live image uploaded by + // the client, instead of building it from an EVE container image. Requires a + // provider that attaches the qcow2 directly, so it tracks the provider's disk + // image strategy. + Capability_CAPABILITY_LOCAL_LIVE_IMAGE Capability = 5 ) // Enum value maps for Capability. @@ -159,13 +164,15 @@ var ( 2: "CAPABILITY_FORWARD_EAPOL", 3: "CAPABILITY_FORWARD_LLDP", 4: "CAPABILITY_TPM", + 5: "CAPABILITY_LOCAL_LIVE_IMAGE", } Capability_value = map[string]int32{ - "CAPABILITY_UNSPECIFIED": 0, - "CAPABILITY_FORWARD_LACP": 1, - "CAPABILITY_FORWARD_EAPOL": 2, - "CAPABILITY_FORWARD_LLDP": 3, - "CAPABILITY_TPM": 4, + "CAPABILITY_UNSPECIFIED": 0, + "CAPABILITY_FORWARD_LACP": 1, + "CAPABILITY_FORWARD_EAPOL": 2, + "CAPABILITY_FORWARD_LLDP": 3, + "CAPABILITY_TPM": 4, + "CAPABILITY_LOCAL_LIVE_IMAGE": 5, } ) @@ -418,6 +425,61 @@ func (x *ImageRef) GetArch() ArchType { return ArchType_ARCH_UNKNOWN } +// Reference to a locally built EVE live disk image, identified by content +// rather than by tag: the developer's build is rebuilt under an unchanged +// name, so only the hash distinguishes one from the next. +type LiveImageRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sha256 string `protobuf:"bytes,1,opt,name=sha256,proto3" json:"sha256,omitempty"` // hex sha256 of live.qcow2 + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` // EVE version, from the dist directory name; may be empty + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveImageRef) Reset() { + *x = LiveImageRef{} + mi := &file_common_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveImageRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveImageRef) ProtoMessage() {} + +func (x *LiveImageRef) ProtoReflect() protoreflect.Message { + mi := &file_common_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveImageRef.ProtoReflect.Descriptor instead. +func (*LiveImageRef) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{1} +} + +func (x *LiveImageRef) GetSha256() string { + if x != nil { + return x.Sha256 + } + return "" +} + +func (x *LiveImageRef) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + // A single log message, emitted by a device, edge app, or evetest component. type LogMessage struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -431,7 +493,7 @@ type LogMessage struct { func (x *LogMessage) Reset() { *x = LogMessage{} - mi := &file_common_proto_msgTypes[1] + mi := &file_common_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -443,7 +505,7 @@ func (x *LogMessage) String() string { func (*LogMessage) ProtoMessage() {} func (x *LogMessage) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[1] + mi := &file_common_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -456,7 +518,7 @@ func (x *LogMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use LogMessage.ProtoReflect.Descriptor instead. func (*LogMessage) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{1} + return file_common_proto_rawDescGZIP(), []int{2} } func (x *LogMessage) GetMessage() string { @@ -503,7 +565,7 @@ type EVEDevice struct { func (x *EVEDevice) Reset() { *x = EVEDevice{} - mi := &file_common_proto_msgTypes[2] + mi := &file_common_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -515,7 +577,7 @@ func (x *EVEDevice) String() string { func (*EVEDevice) ProtoMessage() {} func (x *EVEDevice) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[2] + mi := &file_common_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -528,7 +590,7 @@ func (x *EVEDevice) ProtoReflect() protoreflect.Message { // Deprecated: Use EVEDevice.ProtoReflect.Descriptor instead. func (*EVEDevice) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{2} + return file_common_proto_rawDescGZIP(), []int{3} } func (x *EVEDevice) GetDeviceName() string { @@ -592,7 +654,7 @@ type EVEDeviceStatus struct { func (x *EVEDeviceStatus) Reset() { *x = EVEDeviceStatus{} - mi := &file_common_proto_msgTypes[3] + mi := &file_common_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -604,7 +666,7 @@ func (x *EVEDeviceStatus) String() string { func (*EVEDeviceStatus) ProtoMessage() {} func (x *EVEDeviceStatus) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[3] + mi := &file_common_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -617,7 +679,7 @@ func (x *EVEDeviceStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use EVEDeviceStatus.ProtoReflect.Descriptor instead. func (*EVEDeviceStatus) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{3} + return file_common_proto_rawDescGZIP(), []int{4} } func (x *EVEDeviceStatus) GetSpec() *EVEDevice { @@ -654,7 +716,7 @@ type EVEInterfaceStatus struct { func (x *EVEInterfaceStatus) Reset() { *x = EVEInterfaceStatus{} - mi := &file_common_proto_msgTypes[4] + mi := &file_common_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -666,7 +728,7 @@ func (x *EVEInterfaceStatus) String() string { func (*EVEInterfaceStatus) ProtoMessage() {} func (x *EVEInterfaceStatus) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[4] + mi := &file_common_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -679,7 +741,7 @@ func (x *EVEInterfaceStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use EVEInterfaceStatus.ProtoReflect.Descriptor instead. func (*EVEInterfaceStatus) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{4} + return file_common_proto_rawDescGZIP(), []int{5} } func (x *EVEInterfaceStatus) GetLogicalLabel() string { @@ -722,7 +784,7 @@ type EVEInterface struct { func (x *EVEInterface) Reset() { *x = EVEInterface{} - mi := &file_common_proto_msgTypes[5] + mi := &file_common_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -734,7 +796,7 @@ func (x *EVEInterface) String() string { func (*EVEInterface) ProtoMessage() {} func (x *EVEInterface) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[5] + mi := &file_common_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -747,7 +809,7 @@ func (x *EVEInterface) ProtoReflect() protoreflect.Message { // Deprecated: Use EVEInterface.ProtoReflect.Descriptor instead. func (*EVEInterface) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{5} + return file_common_proto_rawDescGZIP(), []int{6} } func (x *EVEInterface) GetName() string { @@ -781,7 +843,7 @@ type ConsoleOutputResponse struct { func (x *ConsoleOutputResponse) Reset() { *x = ConsoleOutputResponse{} - mi := &file_common_proto_msgTypes[6] + mi := &file_common_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -793,7 +855,7 @@ func (x *ConsoleOutputResponse) String() string { func (*ConsoleOutputResponse) ProtoMessage() {} func (x *ConsoleOutputResponse) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[6] + mi := &file_common_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -806,7 +868,7 @@ func (x *ConsoleOutputResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConsoleOutputResponse.ProtoReflect.Descriptor instead. func (*ConsoleOutputResponse) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{6} + return file_common_proto_rawDescGZIP(), []int{7} } func (x *ConsoleOutputResponse) GetConsoleOutput() string { @@ -830,7 +892,7 @@ type ConsoleProperties struct { func (x *ConsoleProperties) Reset() { *x = ConsoleProperties{} - mi := &file_common_proto_msgTypes[7] + mi := &file_common_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -842,7 +904,7 @@ func (x *ConsoleProperties) String() string { func (*ConsoleProperties) ProtoMessage() {} func (x *ConsoleProperties) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[7] + mi := &file_common_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -855,7 +917,7 @@ func (x *ConsoleProperties) ProtoReflect() protoreflect.Message { // Deprecated: Use ConsoleProperties.ProtoReflect.Descriptor instead. func (*ConsoleProperties) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{7} + return file_common_proto_rawDescGZIP(), []int{8} } func (x *ConsoleProperties) GetEchoed() bool { @@ -886,7 +948,7 @@ type IPRoute struct { func (x *IPRoute) Reset() { *x = IPRoute{} - mi := &file_common_proto_msgTypes[8] + mi := &file_common_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -898,7 +960,7 @@ func (x *IPRoute) String() string { func (*IPRoute) ProtoMessage() {} func (x *IPRoute) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[8] + mi := &file_common_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -911,7 +973,7 @@ func (x *IPRoute) ProtoReflect() protoreflect.Message { // Deprecated: Use IPRoute.ProtoReflect.Descriptor instead. func (*IPRoute) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{8} + return file_common_proto_rawDescGZIP(), []int{9} } func (x *IPRoute) GetDstNetwork() string { @@ -939,7 +1001,10 @@ const file_common_proto_rawDesc = "" + "\n" + "hypervisor\x18\x03 \x01(\x0e2\".org.lfedge.evetest.HypervisorTypeR\n" + "hypervisor\x120\n" + - "\x04arch\x18\x04 \x01(\x0e2\x1c.org.lfedge.evetest.ArchTypeR\x04arch\"\xb5\x01\n" + + "\x04arch\x18\x04 \x01(\x0e2\x1c.org.lfedge.evetest.ArchTypeR\x04arch\"@\n" + + "\fLiveImageRef\x12\x16\n" + + "\x06sha256\x18\x01 \x01(\tR\x06sha256\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\"\xb5\x01\n" + "\n" + "LogMessage\x12\x18\n" + "\amessage\x18\x01 \x01(\tR\amessage\x12;\n" + @@ -996,14 +1061,15 @@ const file_common_proto_rawDesc = "" + "\x06HV_KVM\x10\x01\x12\n" + "\n" + "\x06HV_XEN\x10\x02\x12\x0f\n" + - "\vHV_KUBEVIRT\x10\x03*\x94\x01\n" + + "\vHV_KUBEVIRT\x10\x03*\xb5\x01\n" + "\n" + "Capability\x12\x1a\n" + "\x16CAPABILITY_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17CAPABILITY_FORWARD_LACP\x10\x01\x12\x1c\n" + "\x18CAPABILITY_FORWARD_EAPOL\x10\x02\x12\x1b\n" + "\x17CAPABILITY_FORWARD_LLDP\x10\x03\x12\x12\n" + - "\x0eCAPABILITY_TPM\x10\x04*g\n" + + "\x0eCAPABILITY_TPM\x10\x04\x12\x1f\n" + + "\x1bCAPABILITY_LOCAL_LIVE_IMAGE\x10\x05*g\n" + "\vLogSeverity\x12\x0f\n" + "\vLOG_UNKNOWN\x10\x00\x12\r\n" + "\tLOG_DEBUG\x10\x01\x12\f\n" + @@ -1038,7 +1104,7 @@ func file_common_proto_rawDescGZIP() []byte { } var file_common_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_common_proto_goTypes = []any{ (ArchType)(0), // 0: org.lfedge.evetest.ArchType (HypervisorType)(0), // 1: org.lfedge.evetest.HypervisorType @@ -1046,26 +1112,27 @@ var file_common_proto_goTypes = []any{ (LogSeverity)(0), // 3: org.lfedge.evetest.LogSeverity (EVEDeviceState)(0), // 4: org.lfedge.evetest.EVEDeviceState (*ImageRef)(nil), // 5: org.lfedge.evetest.ImageRef - (*LogMessage)(nil), // 6: org.lfedge.evetest.LogMessage - (*EVEDevice)(nil), // 7: org.lfedge.evetest.EVEDevice - (*EVEDeviceStatus)(nil), // 8: org.lfedge.evetest.EVEDeviceStatus - (*EVEInterfaceStatus)(nil), // 9: org.lfedge.evetest.EVEInterfaceStatus - (*EVEInterface)(nil), // 10: org.lfedge.evetest.EVEInterface - (*ConsoleOutputResponse)(nil), // 11: org.lfedge.evetest.ConsoleOutputResponse - (*ConsoleProperties)(nil), // 12: org.lfedge.evetest.ConsoleProperties - (*IPRoute)(nil), // 13: org.lfedge.evetest.IPRoute - (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*LiveImageRef)(nil), // 6: org.lfedge.evetest.LiveImageRef + (*LogMessage)(nil), // 7: org.lfedge.evetest.LogMessage + (*EVEDevice)(nil), // 8: org.lfedge.evetest.EVEDevice + (*EVEDeviceStatus)(nil), // 9: org.lfedge.evetest.EVEDeviceStatus + (*EVEInterfaceStatus)(nil), // 10: org.lfedge.evetest.EVEInterfaceStatus + (*EVEInterface)(nil), // 11: org.lfedge.evetest.EVEInterface + (*ConsoleOutputResponse)(nil), // 12: org.lfedge.evetest.ConsoleOutputResponse + (*ConsoleProperties)(nil), // 13: org.lfedge.evetest.ConsoleProperties + (*IPRoute)(nil), // 14: org.lfedge.evetest.IPRoute + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp } var file_common_proto_depIdxs = []int32{ 1, // 0: org.lfedge.evetest.ImageRef.hypervisor:type_name -> org.lfedge.evetest.HypervisorType 0, // 1: org.lfedge.evetest.ImageRef.arch:type_name -> org.lfedge.evetest.ArchType 3, // 2: org.lfedge.evetest.LogMessage.severity:type_name -> org.lfedge.evetest.LogSeverity - 14, // 3: org.lfedge.evetest.LogMessage.timestamp:type_name -> google.protobuf.Timestamp - 10, // 4: org.lfedge.evetest.EVEDevice.interfaces:type_name -> org.lfedge.evetest.EVEInterface + 15, // 3: org.lfedge.evetest.LogMessage.timestamp:type_name -> google.protobuf.Timestamp + 11, // 4: org.lfedge.evetest.EVEDevice.interfaces:type_name -> org.lfedge.evetest.EVEInterface 5, // 5: org.lfedge.evetest.EVEDevice.image:type_name -> org.lfedge.evetest.ImageRef - 7, // 6: org.lfedge.evetest.EVEDeviceStatus.spec:type_name -> org.lfedge.evetest.EVEDevice + 8, // 6: org.lfedge.evetest.EVEDeviceStatus.spec:type_name -> org.lfedge.evetest.EVEDevice 4, // 7: org.lfedge.evetest.EVEDeviceStatus.state:type_name -> org.lfedge.evetest.EVEDeviceState - 9, // 8: org.lfedge.evetest.EVEDeviceStatus.interfaces:type_name -> org.lfedge.evetest.EVEInterfaceStatus + 10, // 8: org.lfedge.evetest.EVEDeviceStatus.interfaces:type_name -> org.lfedge.evetest.EVEInterfaceStatus 9, // [9:9] is the sub-list for method output_type 9, // [9:9] is the sub-list for method input_type 9, // [9:9] is the sub-list for extension type_name @@ -1084,7 +1151,7 @@ func file_common_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), NumEnums: 5, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/evetest/grpcapi/proto/broker.proto b/evetest/grpcapi/proto/broker.proto index 097c01de213..7c99f42303a 100644 --- a/evetest/grpcapi/proto/broker.proto +++ b/evetest/grpcapi/proto/broker.proto @@ -30,6 +30,9 @@ service Broker { rpc BuildImage(BuildImageRequest) returns (BuildImageResponse); // PushEVEContainerImage streams a pre-built EVE container image to the broker. rpc PushEVEContainerImage(stream PushImageChunk) returns (PushImageResponse); + // Uploads a locally built EVE live image as a tar stream containing + // live.qcow2, config.img and firmware/*. + rpc PushEVELiveImage(stream PushLiveImageChunk) returns (PushLiveImageResponse); // Provision and start up EVE devices and SDN for the client. rpc SetupDevices(SetupDevicesRequest) returns (SetupDevicesResponse); @@ -142,11 +145,34 @@ message BuildImageRequest { bool make_installer = 4; // "live" image otherwise uint64 disk_bytes = 5; EveConfig config = 6; + // When set, the broker builds from this local live image and ignores `image`. + LiveImageRef live_image = 7; + // Where the client's live image files are, so a broker sharing the + // filesystem installs the template by reading them instead of having the + // client upload bytes it can already see. Advisory only -- see + // LocalLiveImageSource. + LocalLiveImageSource live_image_source = 8; +} + +// LocalLiveImageSource points at the files behind a LiveImageRef on the +// client's filesystem. It is purely an optimization hint: a broker that cannot +// use these paths -- they do not exist, the size disagrees, or the content does +// not hash to the declared sha256 -- reports missing_eve_live_image and takes +// the upload instead. The content check is what makes trusting the paths safe: +// a file is only ever installed as the template when it hashes to the value the +// client already declared, so a wrong or hostile path cannot substitute a +// different image, it can only fail. +message LocalLiveImageSource { + string disk_path = 1; // absolute path to live.qcow2 + uint64 disk_bytes = 2; // its size, checked before its content is read + string config_img_path = 3; // absolute path to installer/config.img + string firmware_dir = 4; // absolute path to the dir holding OVMF*.fd } // Response to a BuildImageRequest, indicating if broker is missing the EVE container image. message BuildImageResponse { bool missing_eve_container_image = 1; + bool missing_eve_live_image = 2; } // PushImageChunk represents a single message in the EVE docker image upload stream. @@ -173,6 +199,29 @@ message PushImageResponse { bool already_exists = 1; } +// PushLiveImageChunk is one message in the live-image upload stream. The first +// message MUST be a PushLiveImageRequest; the rest are tar bytes. +message PushLiveImageChunk { + oneof payload { + PushLiveImageRequest request = 1; + // Raw tar bytes -- deliberately not gzipped. The qcow2 inside is already + // zlib-compressed by `qemu-img convert -c`, so re-compressing costs CPU + // for almost nothing. + bytes data_chunk = 2; + } +} + +// PushLiveImageRequest contains metadata describing the EVE live image to be uploaded. +message PushLiveImageRequest { + string client_id = 1; + LiveImageRef live_image = 2; +} + +// PushLiveImageResponse is returned after the EVE live image upload completes. +message PushLiveImageResponse { + bool already_exists = 1; +} + // Request to setup a group of EVE devices and the SDN. message SetupDevicesRequest { string client_id = 1; diff --git a/evetest/grpcapi/proto/common.proto b/evetest/grpcapi/proto/common.proto index 5d3a8c47b7e..507973c87eb 100644 --- a/evetest/grpcapi/proto/common.proto +++ b/evetest/grpcapi/proto/common.proto @@ -43,6 +43,11 @@ enum Capability { CAPABILITY_FORWARD_LLDP = 3; // Attachment of an emulated TPM (Trusted Platform Module) to the device. CAPABILITY_TPM = 4; + // Deriving a device's disk from a locally built EVE live image uploaded by + // the client, instead of building it from an EVE container image. Requires a + // provider that attaches the qcow2 directly, so it tracks the provider's disk + // image strategy. + CAPABILITY_LOCAL_LIVE_IMAGE = 5; } // Reference to a specific EVE image used for provisioning or testing. @@ -53,6 +58,14 @@ message ImageRef { ArchType arch = 4; } +// Reference to a locally built EVE live disk image, identified by content +// rather than by tag: the developer's build is rebuilt under an unchanged +// name, so only the hash distinguishes one from the next. +message LiveImageRef { + string sha256 = 1; // hex sha256 of live.qcow2 + string version = 2; // EVE version, from the dist directory name; may be empty +} + // Severity levels for logs generated during test or device operation. enum LogSeverity { LOG_UNKNOWN = 0; diff --git a/evetest/harness.go b/evetest/harness.go index a4fc3c2b0c8..4c55f707937 100644 --- a/evetest/harness.go +++ b/evetest/harness.go @@ -54,8 +54,10 @@ const ( // whichever call/stream tries to use it next). Kept well under that. brokerKeepAlivePingInterval = 15 * time.Second - // Timeout for the broker to build an EVE VM image. - brokerBuildImageTimeout = 5 * time.Minute + // brokerBuildImageTimeout bounds a BuildImage call. It covers not only this + // client's own image build but also waiting for another client's in-flight + // build of a shared EVE image template, so it is deliberately generous. + brokerBuildImageTimeout = 20 * time.Minute // Timeout for uploading an EVE Docker image to the broker. brokerPushEVEImageTimeout = 10 * time.Minute @@ -309,18 +311,25 @@ type testSuiteState struct { } type deviceState struct { - name string - requirement RequireEdgeDevice - imageRef *api.ImageRef - imageName string - spec *api.EVEDevice - ID uuid.UUID - onboardCert *x509.Certificate - onboardKey *ecdsa.PrivateKey - ecdhCert *x509.Certificate - serial string - config *EdgeDeviceConfig - consoleInUse bool + name string + requirement RequireEdgeDevice + imageRef *api.ImageRef + imageName string + // liveImage is set only when EVETEST_EVE_LIVE_IMAGE selects a locally + // built live.qcow2 in place of an EVE container image. + liveImage *api.LiveImageRef + // liveImageSource accompanies liveImage with the paths behind it, so a + // broker sharing this filesystem installs the template by reading them + // instead of taking an upload of bytes it can already see. + liveImageSource *api.LocalLiveImageSource + spec *api.EVEDevice + ID uuid.UUID + onboardCert *x509.Certificate + onboardKey *ecdsa.PrivateKey + ecdhCert *x509.Certificate + serial string + config *EdgeDeviceConfig + consoleInUse bool unsubscribeInfo func() unsubscribeReq func() diff --git a/evetest/localimage.go b/evetest/localimage.go new file mode 100644 index 00000000000..5ea0df2cb6f --- /dev/null +++ b/evetest/localimage.go @@ -0,0 +1,317 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package evetest + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/lf-edge/eve/evetest/constants" + "github.com/spf13/viper" +) + +// configPartitionBytes is the fixed size of EVE's CONFIG partition. A +// config.img of any other size could not be written into it. +const configPartitionBytes = 5 << 20 + +// liveImageCurrent is the dist symlink pointing at the newest local build. Used +// when no EVE version is requested. +const liveImageCurrent = "current" + +// eveVersionDir matches a dist version directory, which is what `make live` +// names after the EVE version. Used only to decide whether a directory name is +// worth reporting as the version. +var eveVersionDir = regexp.MustCompile(`^\d+\.\d+\.\d+-`) + +// localLiveImage is a locally built EVE live image and the files that go with +// it. Every path field exists by the time this is returned. +type localLiveImage struct { + DiskPath string + DiskBytes int64 + ConfigImgPath string + FirmwareDir string + // Version is the build directory's name, i.e. the EVE version without the + // hypervisor/arch suffix a container tag carries. + Version string + // RootfsPath is installer/rootfs.img, the raw base OS image an upgrade + // installs. Empty when this build has none. + RootfsPath string + // ShortVersion is installer/eve_version: the version string EVE itself + // reports in ZInfoDevice.SwList, which is Version plus that suffix. Empty + // when the build does not record one. + ShortVersion string +} + +// resolveLocalLiveImage resolves the live artifacts of a local EVE build, or +// returns (nil, nil) when the live transport is off and the container transport +// should be used. +// +// EVETEST_EVE_LIVE_IMAGE selects the transport and nothing else: it is a plain +// boolean, deliberately carrying no filesystem detail, because which build to +// run is the version's business (EVETEST_EVE_VERSION), not the transport's. +// eveVersion is that version, empty when none was requested. +func resolveLocalLiveImage(zarch, eveVersion string) (*localLiveImage, error) { + setting := viper.GetString(constants.EVELiveImageEnv) + if setting == "" { + return nil, nil + } + live, err := strconv.ParseBool(setting) + if err != nil { + return nil, fmt.Errorf( + "%s must be a boolean (true/false), got %q: to run a specific EVE "+ + "version set %s instead", + constants.EnvPrefix+constants.EVELiveImageEnv, setting, + constants.EnvPrefix+constants.EVEVersionEnv) + } + if !live { + return nil, nil + } + distRoot := viper.GetString(constants.EVEDistDirEnv) + if distRoot == "" { + return nil, fmt.Errorf( + "%s is not set: it must point at the EVE dist directory to deliver a "+ + "locally built image (normally set for you by `make evetest`)", + constants.EnvPrefix+constants.EVEDistDirEnv) + } + return resolveLocalLiveImageIn(distRoot, zarch, eveVersion, + viper.GetString(constants.EVEFirmwareDirEnv)) +} + +// resolveLocalLiveImageIn is resolveLocalLiveImage past the transport decision, +// with the dist root, the requested version and the firmware override injected +// so it can be tested without touching the environment. eveVersion selects the +// dist subdirectory; empty means the `current` symlink. +func resolveLocalLiveImageIn(distRoot, zarch, eveVersion, firmwareOverride string) ( + *localLiveImage, error) { + + verDirName := eveVersion + if verDirName == "" { + verDirName = liveImageCurrent + } + diskPath := filepath.Join(distRoot, zarch, verDirName, "live.qcow2") + resolved, err := filepath.EvalSymlinks(diskPath) + if err != nil { + if eveVersion == "" { + return nil, fmt.Errorf( + "no local EVE build at %q: %w (run `make live`)", diskPath, err) + } + // Failing rather than quietly falling back to the container transport: + // the operator asked for this version *and* for the live transport, and + // silently delivering a different build -- or the same version from a + // registry -- is the kind of thing that costs an afternoon to notice. + return nil, fmt.Errorf( + "EVE version %q is not built locally: no live image at %q (run "+ + "`make live` for it, unset %s to run that version from a container "+ + "image, or unset %s to use whatever is in %s)", + eveVersion, diskPath, + constants.EnvPrefix+constants.EVEVersionEnv, + constants.EnvPrefix+constants.EVELiveImageEnv, liveImageCurrent) + } + + diskInfo, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("cannot stat the local EVE live image %q: %w", + resolved, err) + } + + verDir := filepath.Dir(resolved) + img := &localLiveImage{ + DiskPath: resolved, + DiskBytes: diskInfo.Size(), + ConfigImgPath: filepath.Join(verDir, "installer", "config.img"), + FirmwareDir: filepath.Join(verDir, "installer", "firmware"), + } + if firmwareOverride != "" { + img.FirmwareDir = firmwareOverride + } + if base := filepath.Base(verDir); eveVersionDir.MatchString(base) { + img.Version = base + } + // Both are only needed to deliver this build as an upgrade target, so a + // build without them is still perfectly usable for a fresh device; whoever + // needs them reports their absence. + rootfs := filepath.Join(verDir, "installer", "rootfs.img") + if info, err := os.Stat(rootfs); err == nil && info.Mode().IsRegular() { + img.RootfsPath = rootfs + } + if data, err := os.ReadFile(filepath.Join(verDir, "installer", "eve_version")); err == nil { + img.ShortVersion = strings.TrimSpace(string(data)) + } + + info, err := os.Stat(img.ConfigImgPath) + if err != nil { + return nil, fmt.Errorf("local EVE build is incomplete, no config.img at %q: %w", + img.ConfigImgPath, err) + } + if info.Size() != configPartitionBytes { + return nil, fmt.Errorf("config.img at %q is %d bytes, expected %d", + img.ConfigImgPath, info.Size(), configPartitionBytes) + } + for _, f := range []string{"OVMF.fd", "OVMF_CODE.fd", "OVMF_VARS.fd"} { + if _, err := os.Stat(filepath.Join(img.FirmwareDir, f)); err != nil { + return nil, fmt.Errorf("local EVE build is missing firmware %q: %w", f, err) + } + } + return img, nil +} + +// liveImageHypervisor reports which hypervisor flavor a local build was built +// for, read from the last two components of the version EVE reports for it +// ("…-kvm-amd64", "…-k-amd64"): that suffix is the only place the flavor is +// recorded, since the build directory's name does not carry it. +// +// Returns false when the suffix is not a flavor this framework knows, so the +// caller can proceed rather than reject a build over an unrecognised name. +func liveImageHypervisor(shortVersion string) (Hypervisor, bool) { + parts := strings.Split(shortVersion, "-") + if len(parts) < 2 { + return HypervisorUndefined, false + } + switch parts[len(parts)-2] { + case "kvm": + return HypervisorKVM, true + case "xen": + return HypervisorXen, true + case "k": + return HypervisorKubevirt, true + } + return HypervisorUndefined, false +} + +// liveImageSatisfies reports whether a build of flavor buildHV can serve a +// device that asked for requiredHV. +// +// Exact matches aside, the one flavor that substitutes for another is eve-k: it +// is KVM plus kubevirt orchestration, so it satisfies a plain KVM requirement +// (verified: the networking tests, which pin KVM, pass against an eve-k build). +// The reverse cannot work -- a KVM build has no k3s or kubevirt at all, so a +// test that needs them would not fail until its cluster assertions time out +// twenty minutes later, which is exactly the kind of thing worth refusing up +// front. +func liveImageSatisfies(requiredHV, buildHV Hypervisor) bool { + if requiredHV == HypervisorUndefined || requiredHV == buildHV { + return true + } + return requiredHV == HypervisorKVM && buildHV == HypervisorKubevirt +} + +// hvMakeFlavor is the HV= value that builds a given hypervisor flavor, which is +// not always the flavor's own name ("kubevirt" is built as HV=k). +func hvMakeFlavor(h Hypervisor) string { + if h == HypervisorKubevirt { + return "k" + } + if h == HypervisorUndefined { + return "kvm" + } + return h.String() +} + +// useLocalLiveImage reports whether a device should boot the configured local +// EVE live image rather than the container path. A device with an explicit +// EVE version requirement (RequireEdgeDevice.WithEVEVersion) must take the +// container path instead, since that is the only path that can produce an +// arbitrary requested version; the local live image always carries whatever +// version happens to be built. +func useLocalLiveImage(requirementVersion string, img *localLiveImage) bool { + return img != nil && requirementVersion == "" +} + +// LocalLiveImageRequested reports whether the operator selected the live +// transport (EVETEST_EVE_LIVE_IMAGE). An unparsable value counts as requested, +// so the caller surfaces the same error resolveLocalLiveImage would rather than +// silently treating a typo as "off". +func LocalLiveImageRequested() bool { + setting := viper.GetString(constants.EVELiveImageEnv) + if setting == "" { + return false + } + live, err := strconv.ParseBool(setting) + return err != nil || live +} + +// liveImageShaSidecar is the name of the hash cache file written next to the +// image. `make live` creates a new version directory per build, so a build +// this file doesn't already know about naturally has no sidecar yet -- that +// absence is the invalidation, no mtime bookkeeping needed. Format is one +// greppable line: " \n". +const liveImageShaSidecar = "image-sha" + +// liveImageSHA256 returns the hex sha256 of path, reusing the value recorded +// in the sidecar file when its recorded size still matches the file's +// current size. EVETEST_EVE_LIVE_IMAGE may point at an arbitrary path outside +// a dist version directory, where content can change without a new +// directory, so the size check guards against reusing a stale hash there. +// +// A cache read or write failure only costs time (falls back to recomputing); +// it never becomes an error. +func liveImageSHA256(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("failed to stat %q: %w", path, err) + } + cachePath := filepath.Join(filepath.Dir(path), liveImageShaSidecar) + + if data, err := os.ReadFile(cachePath); err == nil { + if sum, size, ok := parseLiveImageShaSidecar(data); ok && size == info.Size() { + return sum, nil + } + } + + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("failed to open %q: %w", path, err) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("failed to read %q: %w", path, err) + } + sum := hex.EncodeToString(h.Sum(nil)) + + line := fmt.Sprintf("%s %d\n", sum, info.Size()) + // World-readable: it's a content hash, nothing secret, and the harness + // may be writing it as root inside a container into the developer's own + // bind-mounted dist tree. + // A cache write failure only costs time on the next run. + _ = os.WriteFile(cachePath, []byte(line), 0o644) + chownToHostUser(cachePath) + return sum, nil +} + +// chownToHostUser hands path back to the developer when running inside the +// evetest container as root. EVETEST_HOST_UID/EVETEST_HOST_GID are set by the +// container runtime (see evetest/Makefile), not user-facing configuration, so +// they are read directly rather than via a constants.* env var. A chown +// failure -- including the common case of running outside the container, +// where the variables are unset -- only costs the developer a `sudo chown`; +// it is not an error. +func chownToHostUser(path string) { + uid, uidErr := strconv.Atoi(os.Getenv("EVETEST_HOST_UID")) + gid, gidErr := strconv.Atoi(os.Getenv("EVETEST_HOST_GID")) + if uidErr != nil || gidErr != nil { + return + } + _ = os.Chown(path, uid, gid) +} + +// parseLiveImageShaSidecar parses the " \n" sidecar format. +func parseLiveImageShaSidecar(data []byte) (sum string, size int64, ok bool) { + fields := strings.Fields(string(data)) + if len(fields) != 2 { + return "", 0, false + } + size, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return "", 0, false + } + return fields[0], size, true +} diff --git a/evetest/localimage_test.go b/evetest/localimage_test.go new file mode 100644 index 00000000000..f28735a71ea --- /dev/null +++ b/evetest/localimage_test.go @@ -0,0 +1,388 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package evetest + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lf-edge/eve/evetest/constants" + "github.com/spf13/viper" +) + +// writeFakeBuild lays out a dist tree like `make live` produces and returns +// the version directory it created. +func writeFakeBuild(t *testing.T, root, version string, cfgSize int) string { + t.Helper() + verDir := filepath.Join(root, "amd64", version) + fw := filepath.Join(verDir, "installer", "firmware") + if err := os.MkdirAll(fw, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for _, f := range []string{"OVMF.fd", "OVMF_CODE.fd", "OVMF_VARS.fd"} { + if err := os.WriteFile(filepath.Join(fw, f), []byte("x"), 0o600); err != nil { + t.Fatalf("write firmware: %v", err) + } + } + if err := os.WriteFile(filepath.Join(verDir, "live.qcow2"), []byte("qcow"), 0o600); err != nil { + t.Fatalf("write image: %v", err) + } + cfg := filepath.Join(verDir, "installer", "config.img") + if err := os.WriteFile(cfg, make([]byte, cfgSize), 0o600); err != nil { + t.Fatalf("write config.img: %v", err) + } + link := filepath.Join(root, "amd64", "current") + os.Remove(link) + if err := os.Symlink(verDir, link); err != nil { + t.Fatalf("symlink: %v", err) + } + return verDir +} + +// TestResolveLocalLiveImageTransportOff covers the transport switch: anything +// falsy (including unset) means the container transport, and no dist tree is +// consulted at all. +func TestResolveLocalLiveImageTransportOff(t *testing.T) { + defer viper.Set(constants.EVELiveImageEnv, "") + for _, setting := range []string{"", "false", "False", "FALSE", "0", "f", "F"} { + t.Run("setting="+setting, func(t *testing.T) { + viper.Set(constants.EVELiveImageEnv, setting) + img, err := resolveLocalLiveImage("amd64", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if img != nil { + t.Fatalf("expected nil for the container transport, got %+v", img) + } + }) + } +} + +// TestResolveLocalLiveImageRejectsNonBoolean covers the semantics this variable +// used to have: it carried a path, and a leftover path in someone's environment +// must say what to do instead of being silently treated as "on" or "off". +func TestResolveLocalLiveImageRejectsNonBoolean(t *testing.T) { + viper.Set(constants.EVELiveImageEnv, "/home/dev/eve/dist/amd64/current/live.qcow2") + defer viper.Set(constants.EVELiveImageEnv, "") + + _, err := resolveLocalLiveImage("amd64", "") + if err == nil { + t.Fatal("expected an error for a non-boolean value") + } + if !strings.Contains(err.Error(), constants.EVEVersionEnv) { + t.Errorf("the error should point at %s as the way to pick a build, got: %v", + constants.EVEVersionEnv, err) + } + if !LocalLiveImageRequested() { + t.Error("an unparsable value must still count as requested, so the " + + "caller surfaces the error instead of silently using containers") + } +} + +func TestResolveLocalLiveImageCurrent(t *testing.T) { + root := t.TempDir() + const version = "0.0.0-branch-abcd1234-k-amd64-v6.12.49-gcc" + verDir := writeFakeBuild(t, root, version, 5<<20) + + img, err := resolveLocalLiveImageIn(root, "amd64", "", "") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if img.DiskPath != filepath.Join(verDir, "live.qcow2") { + t.Errorf("DiskPath = %q", img.DiskPath) + } + if img.ConfigImgPath != filepath.Join(verDir, "installer", "config.img") { + t.Errorf("ConfigImgPath = %q", img.ConfigImgPath) + } + if img.FirmwareDir != filepath.Join(verDir, "installer", "firmware") { + t.Errorf("FirmwareDir = %q", img.FirmwareDir) + } + if img.Version != version { + t.Errorf("Version = %q, want %q", img.Version, version) + } +} + +// TestResolveLocalLiveImageRequestedVersion covers the version axis: a version +// the operator asked for selects that build's directory, not the newest one. +func TestResolveLocalLiveImageRequestedVersion(t *testing.T) { + root := t.TempDir() + const wanted = "0.0.0-x-1111-k-amd64-v1-gcc" + wantedDir := writeFakeBuild(t, root, wanted, 5<<20) + // A newer build exists and owns the `current` symlink, so resolving the + // requested version proves the symlink was not used. + writeFakeBuild(t, root, "0.0.0-x-2222-k-amd64-v1-gcc", 5<<20) + + img, err := resolveLocalLiveImageIn(root, "amd64", wanted, "") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if img.DiskPath != filepath.Join(wantedDir, "live.qcow2") { + t.Errorf("DiskPath = %q, want the requested version's image", img.DiskPath) + } + if img.Version != wanted { + t.Errorf("Version = %q, want %q", img.Version, wanted) + } +} + +// TestResolveLocalLiveImageUnbuiltVersionFails is the rule that keeps the two +// axes honest: the operator asked for a version *and* for the live transport, +// and that version is not built here. Falling back to the container transport +// would run a different set of bits than the request describes, so this fails +// instead -- and the error has to name the version and the path it looked in. +func TestResolveLocalLiveImageUnbuiltVersionFails(t *testing.T) { + root := t.TempDir() + writeFakeBuild(t, root, "0.0.0-x-2222-k-amd64-v1-gcc", 5<<20) + + _, err := resolveLocalLiveImageIn(root, "amd64", "16.0.0-lts", "") + if err == nil { + t.Fatal("expected an error for a version that is not built locally") + } + for _, want := range []string{"16.0.0-lts", constants.EVEVersionEnv} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %q, got: %v", want, err) + } + } +} + +func TestResolveLocalLiveImageFirmwareOverride(t *testing.T) { + root := t.TempDir() + writeFakeBuild(t, root, "0.0.0-x-2222-k-amd64-v1-gcc", 5<<20) + other := t.TempDir() + for _, f := range []string{"OVMF.fd", "OVMF_CODE.fd", "OVMF_VARS.fd"} { + if err := os.WriteFile(filepath.Join(other, f), []byte("y"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + } + img, err := resolveLocalLiveImageIn(root, "amd64", "", other) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if img.FirmwareDir != other { + t.Errorf("FirmwareDir = %q, want the override %q", img.FirmwareDir, other) + } +} + +func TestResolveLocalLiveImageRequiresDistDir(t *testing.T) { + viper.Set(constants.EVELiveImageEnv, "true") + viper.Set(constants.EVEDistDirEnv, "") + defer func() { + viper.Set(constants.EVELiveImageEnv, "") + viper.Set(constants.EVEDistDirEnv, "") + }() + + _, err := resolveLocalLiveImage("amd64", "") + if err == nil { + t.Fatal("expected an error when the live transport is on and EVE_DIST_DIR is unset") + } + if !strings.Contains(err.Error(), constants.EVEDistDirEnv) { + t.Fatalf("expected the error to name %s, got: %v", constants.EVEDistDirEnv, err) + } +} + +func TestResolveLocalLiveImageMissingImage(t *testing.T) { + _, err := resolveLocalLiveImageIn(t.TempDir(), "amd64", "", "") + if err == nil { + t.Fatal("expected an error when no local build exists") + } +} + +func TestResolveLocalLiveImageWrongConfigSize(t *testing.T) { + root := t.TempDir() + writeFakeBuild(t, root, "0.0.0-x-3333-k-amd64-v1-gcc", 1024) + _, err := resolveLocalLiveImageIn(root, "amd64", "", "") + if err == nil { + t.Fatal("expected an error for a config.img that is not 5 MiB") + } +} + +// TestResolveLocalLiveImageUnversionedDir covers a `current` symlink pointing at +// a directory whose name is not version-shaped: the image is still usable, but +// nothing can be reported as its version. +func TestResolveLocalLiveImageUnversionedDir(t *testing.T) { + root := t.TempDir() + verDir := writeFakeBuild(t, root, "some-scratch-build", 5<<20) + + img, err := resolveLocalLiveImageIn(root, "amd64", "", "") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if img.DiskPath != filepath.Join(verDir, "live.qcow2") { + t.Errorf("DiskPath = %q", img.DiskPath) + } + if img.Version != "" { + t.Errorf("Version = %q, want empty for a non-version-shaped dir", img.Version) + } +} + +// TestUseLocalLiveImage covers the precedence between an explicitly requested +// EVE version (RequireEdgeDevice.WithEVEVersion) and a configured local live +// image: the explicit version must always win, since it is the strongest +// signal a test can give about which build a device should boot. +func TestUseLocalLiveImage(t *testing.T) { + img := &localLiveImage{DiskPath: "/dist/amd64/current/live.qcow2"} + + cases := []struct { + name string + requirementVersion string + img *localLiveImage + want bool + }{ + { + name: "explicit version never uses the live image", + requirementVersion: "16.0.0-lts", + img: img, + want: false, + }, + { + name: "no explicit version uses the configured live image", + requirementVersion: "", + img: img, + want: true, + }, + { + name: "no image configured, nothing to use regardless of version", + requirementVersion: "", + img: nil, + want: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := useLocalLiveImage(c.requirementVersion, c.img); got != c.want { + t.Errorf("useLocalLiveImage(%q, %v) = %v, want %v", + c.requirementVersion, c.img != nil, got, c.want) + } + }) + } +} + +func TestLiveImageSHA256IsStable(t *testing.T) { + f := filepath.Join(t.TempDir(), "live.qcow2") + if err := os.WriteFile(f, []byte("hello"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + // sha256("hello") + const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + got, err := liveImageSHA256(f) + if err != nil { + t.Fatalf("hash: %v", err) + } + if got != want { + t.Fatalf("sha256 = %q, want %q", got, want) + } + sidecar := filepath.Join(filepath.Dir(f), "image-sha") + if _, err := os.Stat(sidecar); err != nil { + t.Fatalf("expected sidecar %q to be written: %v", sidecar, err) + } + again, err := liveImageSHA256(f) + if err != nil || again != want { + t.Fatalf("cached read = %q, %v", again, err) + } +} + +// TestLiveImageSHA256SidecarIsWorldReadable guards against the sidecar +// landing 0600 root:root when the harness runs as root inside the evetest +// container against the developer's bind-mounted dist tree -- a plain hash +// file the developer cannot read is strictly worse than the opaque cache it +// replaced. +func TestLiveImageSHA256SidecarIsWorldReadable(t *testing.T) { + f := filepath.Join(t.TempDir(), "live.qcow2") + if err := os.WriteFile(f, []byte("hello"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := liveImageSHA256(f); err != nil { + t.Fatalf("hash: %v", err) + } + sidecar := filepath.Join(filepath.Dir(f), "image-sha") + info, err := os.Stat(sidecar) + if err != nil { + t.Fatalf("expected sidecar %q to be written: %v", sidecar, err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o644); got != want { + t.Fatalf("sidecar mode = %o, want %o", got, want) + } +} + +// TestLiveImageSHA256InvalidatesOnChange covers a rebuilt image: the cache is +// keyed on the recorded size, so content of a different length must not +// return the old hash. +func TestLiveImageSHA256InvalidatesOnChange(t *testing.T) { + f := filepath.Join(t.TempDir(), "live.qcow2") + if err := os.WriteFile(f, []byte("hello"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + first, err := liveImageSHA256(f) + if err != nil { + t.Fatalf("hash: %v", err) + } + if err := os.WriteFile(f, []byte("goodbye"), 0o600); err != nil { + t.Fatalf("rewrite: %v", err) + } + second, err := liveImageSHA256(f) + if err != nil { + t.Fatalf("rehash: %v", err) + } + if first == second { + t.Fatal("hash did not change after the file changed; the cache is stale") + } +} + +// TestLiveImageHypervisor covers reading the flavor out of the version EVE +// reports, which is the only place a build records it -- the build directory's +// name does not. +func TestLiveImageHypervisor(t *testing.T) { + cases := []struct { + shortVersion string + want Hypervisor + known bool + }{ + {"0.0.0-branch-abc1234-kvm-amd64", HypervisorKVM, true}, + {"0.0.0-branch-abc1234-k-amd64", HypervisorKubevirt, true}, + {"0.0.0-branch-abc1234-xen-amd64", HypervisorXen, true}, + // eve-k builds carry "-k-amd64" mid-string too; only the suffix counts. + {"0.0.0-b-abc-k-amd64-v6.12.49-generic-core-deadbeef-user-gcc-k-amd64", + HypervisorKubevirt, true}, + {"16.0.0-lts-kvm-arm64", HypervisorKVM, true}, + {"", HypervisorUndefined, false}, + {"0.0.0-no-flavor-here", HypervisorUndefined, false}, + } + for _, c := range cases { + t.Run(c.shortVersion, func(t *testing.T) { + got, known := liveImageHypervisor(c.shortVersion) + if known != c.known || got != c.want { + t.Errorf("liveImageHypervisor(%q) = (%v, %v), want (%v, %v)", + c.shortVersion, got, known, c.want, c.known) + } + }) + } +} + +// TestLiveImageSatisfies pins the one substitution that is allowed: eve-k is KVM +// plus kubevirt orchestration, so it serves a KVM requirement, while a KVM build +// can never serve a test that needs kubevirt. +func TestLiveImageSatisfies(t *testing.T) { + cases := []struct { + required, build Hypervisor + want bool + }{ + {HypervisorKVM, HypervisorKVM, true}, + {HypervisorKubevirt, HypervisorKubevirt, true}, + {HypervisorUndefined, HypervisorKubevirt, true}, + {HypervisorKVM, HypervisorKubevirt, true}, + {HypervisorKubevirt, HypervisorKVM, false}, + {HypervisorXen, HypervisorKVM, false}, + {HypervisorKVM, HypervisorXen, false}, + } + for _, c := range cases { + name := c.required.String() + "-on-" + c.build.String() + t.Run(name, func(t *testing.T) { + if got := liveImageSatisfies(c.required, c.build); got != c.want { + t.Errorf("liveImageSatisfies(%v, %v) = %v, want %v", + c.required, c.build, got, c.want) + } + }) + } +} diff --git a/evetest/requirements.go b/evetest/requirements.go index 8c89c0f4b86..77f35e0b2bf 100644 --- a/evetest/requirements.go +++ b/evetest/requirements.go @@ -77,6 +77,19 @@ func (h Hypervisor) toAPIType() api.HypervisorType { } } +// hypervisorFromAPIType is the inverse of toAPIType, for deciding what a device +// that is already running reports itself as. +func hypervisorFromAPIType(t api.HypervisorType) Hypervisor { + switch t { + case api.HypervisorType_HV_XEN: + return HypervisorXen + case api.HypervisorType_HV_KUBEVIRT: + return HypervisorKubevirt + default: + return HypervisorKVM + } +} + // Filesystem identifies the filesystem type required or detected on an EVE device. type Filesystem int diff --git a/evetest/setup.go b/evetest/setup.go index 394e6770e83..881990e393c 100644 --- a/evetest/setup.go +++ b/evetest/setup.go @@ -4,6 +4,7 @@ package evetest import ( + "archive/tar" "bytes" "context" "crypto/x509" @@ -14,6 +15,7 @@ import ( "math/big" "net" "os" + "path/filepath" "strconv" "strings" "sync" @@ -30,10 +32,17 @@ import ( "github.com/spf13/viper" "github.com/vishvananda/netlink" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) +// liveImageChunkBytes is the gRPC chunk size used for both the container +// image push (pushEVEImageToBroker) and the live image push +// (pushLiveImageToBroker), so the two paths behave the same way on the wire. +const liveImageChunkBytes = 1024 * 1024 + // prepareEVEDeviceForOnboarding generates serial number and onboarding certificates // for the device. func (th *TestHarness) prepareEVEDeviceForOnboarding(dev *deviceState) { @@ -81,6 +90,19 @@ func (th *TestHarness) selectArch() api.ArchType { return fallback } +// zarchDirName converts arch into the ZARCH-named dist directory (e.g. +// dist/amd64/current) that `make live` builds into. +func zarchDirName(arch api.ArchType) (string, error) { + switch arch { + case api.ArchType_ARCH_AMD64: + return "amd64", nil + case api.ArchType_ARCH_ARM64: + return "arm64", nil + default: + return "", fmt.Errorf("unsupported architecture: %v", arch) + } +} + // prepareImageForEVEDevice prepares an EVE image reference for the given device // and ensures that the corresponding EVE (live or installer) VM image is built // on the broker. @@ -97,9 +119,6 @@ func (th *TestHarness) prepareImageForEVEDevice(dev *deviceState) { if eveVersion == "" { eveVersion = viper.GetString(constants.EVEVersionEnv) } - if eveVersion == "" { - th.t.Fatalf("EVE version is not defined") - } var err error var hypervisor api.HypervisorType switch dev.requirement.WithHypervisor { @@ -114,6 +133,85 @@ func (th *TestHarness) prepareImageForEVEDevice(dev *deviceState) { hypervisor = api.HypervisorType_HV_KUBEVIRT } arch := th.selectArch() + + zarch, err := zarchDirName(arch) + if err != nil { + th.t.Fatalf("%v", err) + } + // A version pinned by the test itself is never looked for among the local + // builds: it names a particular release (TestEVEUpgrade's pre-upgrade + // version, say), which is the container transport's job. Only the operator's + // EVETEST_EVE_VERSION selects which local build the live transport delivers. + localImg, err := resolveLocalLiveImage(zarch, viper.GetString(constants.EVEVersionEnv)) + if err != nil { + // The operator explicitly selected the live transport; silently falling + // back to the container transport would run the test against a different + // EVE build than they asked for. + th.t.Fatalf("Failed to resolve the local EVE build: %v", err) + } + if useLocalLiveImage(dev.requirement.WithEVEVersion, localImg) { + if !generics.ContainsItem( + th.brokerCapabilities, api.Capability_CAPABILITY_LOCAL_LIVE_IMAGE) { + th.t.Fatalf("the broker does not support the live image transport " + + "(EVETEST_EVE_LIVE_IMAGE=true): either it predates this feature " + + "and must be updated, or its device provider builds images per " + + "device and cannot consume one") + } + // A local build that cannot provide the hypervisor this test declares is an + // unsatisfiable requirement, so the test is skipped -- the same treatment + // RequireInternetConnectivity gets, and what Setup documents. Decided here, + // before anything is hashed, transferred or booted: otherwise the mismatch + // surfaces only as the test's own assertions timing out much later, with + // nothing pointing at the flavor of the build that was delivered. + // + // This is about satisfying a declared requirement, not about judging + // whether an EVE image is compatible with a device -- that is EVE's call. + if buildHV, known := liveImageHypervisor(localImg.ShortVersion); known { + required := dev.requirement.WithHypervisor + if !liveImageSatisfies(required, buildHV) { + th.t.Skipf("Test requires the %s hypervisor for device %q, but the "+ + "local EVE build being delivered (%s) is %s: rebuild with "+ + "`make HV=%s live`, or unset %s%s to run a %s container image", + required, dev.name, localImg.Version, buildHV, + hvMakeFlavor(required), + constants.EnvPrefix, constants.EVELiveImageEnv, required) + } + if buildHV != required && required != HypervisorUndefined { + th.log.Infof("Device %q requires %s and the local build is %s, "+ + "which provides it", dev.name, required, buildHV) + } + } + sum, err := liveImageSHA256(localImg.DiskPath) + if err != nil { + th.t.Fatalf("Failed to hash local EVE live image %q: %v", + localImg.DiskPath, err) + } + dev.liveImage = &api.LiveImageRef{Sha256: sum, Version: localImg.Version} + // Sent unconditionally: whether the broker can actually read these paths + // is for the broker to determine, not for the harness to guess from its + // deployment mode. One that cannot asks for the upload as before. + dev.liveImageSource = &api.LocalLiveImageSource{ + DiskPath: localImg.DiskPath, + DiskBytes: uint64(localImg.DiskBytes), + ConfigImgPath: localImg.ConfigImgPath, + FirmwareDir: localImg.FirmwareDir, + } + // The resolved directory is the authority on what is actually being + // delivered: when a version was requested it is the one that was found, + // and when none was, `current` decides it. Either way it is reported + // rather than the wrapper's guess at the checkout's version. + if localImg.Version != "" { + eveVersion = localImg.Version + } + } else if localImg != nil { + th.log.Infof("Device %q requested EVE version %q explicitly; "+ + "skipping the local EVE live image for this device", + dev.name, dev.requirement.WithEVEVersion) + } + if eveVersion == "" { + th.t.Fatalf("EVE version is not defined") + } + dev.imageRef = &api.ImageRef{ Repo: viper.GetString(constants.EVERepoEnv), Version: eveVersion, @@ -220,11 +318,13 @@ func (th *TestHarness) prepareImageForEVEDevice(dev *deviceState) { globalPropertiesJSON := string(globalPropertiesBytes) buildReq := &api.BuildImageRequest{ - ClientId: th.brokerClientID, - DeviceName: dev.name, - Image: dev.imageRef, - MakeInstaller: dev.requirement.DeviceReusePolicy == CreateFromScratchWithInstaller, - DiskBytes: uint64(diskSizeInMiB) << 20, + ClientId: th.brokerClientID, + DeviceName: dev.name, + Image: dev.imageRef, + LiveImage: dev.liveImage, + LiveImageSource: dev.liveImageSource, + MakeInstaller: dev.requirement.DeviceReusePolicy == CreateFromScratchWithInstaller, + DiskBytes: uint64(diskSizeInMiB) << 20, Config: &api.EveConfig{ ServerName: fmt.Sprintf("%s:%d", GetControllerHostname(), GetControllerPort()), SoftSerial: dev.requirement.WithSoftSerial, @@ -261,6 +361,29 @@ func (th *TestHarness) prepareImageForEVEDevice(dev *deviceState) { } th.log.Infof("BuildImage %q succeeded after pushing image.", dev.imageName) + } else if buildResp.MissingEveLiveImage { + th.log.Warn("Broker is missing the local EVE live image — pushing it now...") + th.pushLiveImageToBroker(localImg, dev.liveImage.Sha256) + + // Retry build + ctx, cancel = context.WithTimeout(th.ctx, brokerBuildImageTimeout) + buildResp, err = th.brokerClient.BuildImage(ctx, buildReq) + cancel() + if err != nil { + th.t.Fatalf("BuildImage %q (retry) failed: %v", dev.imageName, err) + } + if buildResp.MissingEveLiveImage { + th.t.Fatalf("Broker is missing the local EVE live image even after push.") + } + th.log.Infof("BuildImage %q succeeded after pushing live image.", + dev.imageName) + } else if dev.liveImage != nil { + // Nothing was uploaded and nothing was missing: either the broker still + // had the template, or it read the live image out of the dist directory + // itself. Reporting a docker image here would be a lie -- the live path + // may run on a broker that has no EVE container image at all. + th.log.Infof("BuildImage succeeded using the local EVE live image %q.", + dev.liveImage.GetSha256()) } else { th.log.Infof("BuildImage %q succeeded (docker image was already present).", dev.imageName) @@ -324,7 +447,7 @@ func (th *TestHarness) pushEVEImageToBroker(imageRef *api.ImageRef) { var sentBytes int64 nextLogPercent := int64(10) - buf := make([]byte, 1024*1024) // 1MB chunks + buf := make([]byte, liveImageChunkBytes) earlyClose := false for { @@ -374,6 +497,158 @@ func (th *TestHarness) pushEVEImageToBroker(imageRef *api.ImageRef) { } } +// pushLiveImageToBroker streams a locally built EVE live image to the broker +// as a tar (disk.qcow2, config.img, firmware/), built in-process and +// written straight into the client-streaming gRPC call -- the tar is never +// assembled on disk or fully buffered in memory. +// +// The qcow2's clusters are already zlib-compressed by `qemu-img convert -c`, +// so unlike pushEVEImageToBroker this stream is not gzipped: recompressing +// already-compressed data would only burn CPU. +// +// A broker that predates local live image support rejects the RPC with +// codes.Unimplemented; that case fails with a message telling the operator to +// update the broker rather than silently falling back to the container path. +// +// A broker that already has this hash staged calls SendAndClose without +// draining the stream, exactly as PushEVEContainerImage does: every Send after +// that fails (typically with EOF), which is not this client's failure to +// report -- it means the concurrent upload that is about to be reported via +// AlreadyExists got there first. earlyClose records that so it can be treated +// as benign instead of fatal, mirroring pushEVEImageToBroker's structure. +func (th *TestHarness) pushLiveImageToBroker(img *localLiveImage, sum string) { + ctx, cancel := context.WithTimeout(th.ctx, brokerPushEVEImageTimeout) + defer cancel() + + stream, err := th.brokerClient.PushEVELiveImage(ctx) + if err != nil { + th.t.Fatalf("PushEVELiveImage failed: %v", err) + } + + earlyClose := false + send := func(chunk *api.PushLiveImageChunk, action string) error { + if err := stream.Send(chunk); err != nil { + if status.Code(err) == codes.Unimplemented { + th.t.Fatalf("this broker predates local live image support; " + + "update the broker") + } + earlyClose = true + return err + } + return nil + } + + // The metadata Send itself may already be the one the broker abandons, so + // its error is not fatal either; the loop below and tw.Close() all check + // earlyClose rather than treating any of this as an unexpected failure. + _ = send(&api.PushLiveImageChunk{ + Payload: &api.PushLiveImageChunk_Request{ + Request: &api.PushLiveImageRequest{ + ClientId: th.brokerClientID, + LiveImage: &api.LiveImageRef{Sha256: sum, Version: img.Version}, + }, + }, + }, "failed to send live image metadata") + + tw := tar.NewWriter(&liveImageChunkWriter{send: func(data []byte) error { + return send(&api.PushLiveImageChunk{ + Payload: &api.PushLiveImageChunk_DataChunk{DataChunk: data}, + }, "failed to send live image data") + }}) + + buf := make([]byte, liveImageChunkBytes) + th.writeTarFile(tw, buf, "disk.qcow2", img.DiskPath, &earlyClose) + th.writeTarFile(tw, buf, "config.img", img.ConfigImgPath, &earlyClose) + + firmwareFiles, err := os.ReadDir(img.FirmwareDir) + if err != nil { + th.t.Fatalf("failed to read firmware dir %q: %v", img.FirmwareDir, err) + } + for _, f := range firmwareFiles { + if f.IsDir() { + continue + } + fp := filepath.Join(img.FirmwareDir, f.Name()) + th.writeTarFile(tw, buf, "firmware/"+f.Name(), fp, &earlyClose) + } + if err := tw.Close(); err != nil && !earlyClose { + th.t.Fatalf("failed to finalize live image tar stream: %v", err) + } + + pushResp, err := stream.CloseAndRecv() + if err != nil { + if status.Code(err) == codes.Unimplemented { + th.t.Fatalf("this broker predates local live image support; " + + "update the broker") + } + th.t.Fatalf("PushEVELiveImage failed: %v", err) + } + + if earlyClose && !pushResp.AlreadyExists { + th.t.Fatalf("Server closed live image upload stream early " + + "but did not report the image as already existing") + } + if pushResp.AlreadyExists { + th.log.Info("EVE live image already exists on broker.") + } else { + th.log.Info("EVE live image pushed successfully.") + } +} + +// liveImageChunkWriter adapts the PushEVELiveImage client stream to an +// io.Writer, so archive/tar can write tar entries directly into the gRPC +// stream one chunk at a time. +type liveImageChunkWriter struct { + send func(data []byte) error +} + +func (w *liveImageChunkWriter) Write(p []byte) (int, error) { + // io.Writer requires a zero-length Write to be a harmless no-op; both + // archive/tar and io.CopyBuffer can legitimately call Write(nil/[]byte{}). + // Sending it anyway would put an empty chunk on the wire for the broker + // to reject. + if len(p) == 0 { + return 0, nil + } + if err := w.send(p); err != nil { + return 0, err + } + return len(p), nil +} + +// writeTarFile writes one tar header plus its contents, copied via buf so +// that streaming a multi-gigabyte disk image never holds more than +// len(buf) bytes in memory at once. +// +// earlyClose is checked before treating a tar-writing error as fatal: once it +// is true, tw has already recorded that error internally and every further +// call on it returns the same cached error without touching the underlying +// gRPC stream again, so it is safe to keep calling this for the remaining +// files. +func (th *TestHarness) writeTarFile(tw *tar.Writer, buf []byte, name, path string, earlyClose *bool) { + f, err := os.Open(path) + if err != nil { + th.t.Fatalf("failed to open %q: %v", path, err) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + th.t.Fatalf("failed to stat %q: %v", path, err) + } + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: info.Size()}); err != nil { + if *earlyClose { + return + } + th.t.Fatalf("failed to write tar header for %q: %v", name, err) + } + if _, err := io.CopyBuffer(tw, f, buf); err != nil { + if *earlyClose { + return + } + th.t.Fatalf("failed to stream %q into tar: %v", path, err) + } +} + // setupEVEDevices requests the broker to provision and configure EVE devices // according to the provided device requirements and network model. // diff --git a/evetest/setup_test.go b/evetest/setup_test.go new file mode 100644 index 00000000000..ae2f7297945 --- /dev/null +++ b/evetest/setup_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Zededa, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package evetest + +import ( + "errors" + "testing" +) + +// TestLiveImageChunkWriterReturnsSendError covers the broker's early close on +// an already-staged upload: the underlying send failing must surface as a +// Write error rather than being silently discarded, or the caller (a +// tar.Writer) cannot tell a benign early close from a successful write. +func TestLiveImageChunkWriterReturnsSendError(t *testing.T) { + wantErr := errors.New("boom") + w := &liveImageChunkWriter{send: func(data []byte) error { + return wantErr + }} + n, err := w.Write([]byte("data")) + if n != 0 || err != wantErr { + t.Fatalf("Write = (%d, %v), want (0, %v)", n, err, wantErr) + } +} + +// TestLiveImageChunkWriterSkipsEmptyWrite verifies that liveImageChunkWriter +// honors the io.Writer contract for a zero-length Write: it must return +// (0, nil) without invoking send, since archive/tar and io.CopyBuffer can +// legitimately produce such a call and the broker should never see an empty +// chunk on the wire. +func TestLiveImageChunkWriterSkipsEmptyWrite(t *testing.T) { + var sent [][]byte + w := &liveImageChunkWriter{send: func(data []byte) error { + sent = append(sent, data) + return nil + }} + + for _, p := range [][]byte{nil, {}} { + n, err := w.Write(p) + if n != 0 || err != nil { + t.Fatalf("Write(%#v) = (%d, %v), want (0, nil)", p, n, err) + } + } + if len(sent) != 0 { + t.Fatalf("send called %d times for empty writes, want 0", len(sent)) + } + + payload := []byte("data") + n, err := w.Write(payload) + if n != len(payload) || err != nil { + t.Fatalf("Write(%q) = (%d, %v), want (%d, nil)", payload, n, err, len(payload)) + } + if len(sent) != 1 || string(sent[0]) != string(payload) { + t.Fatalf("send got %v, want one call with %q", sent, payload) + } +} diff --git a/evetest/tests/upgrade/upgrade_test.go b/evetest/tests/upgrade/upgrade_test.go index 2b8f6f4f23d..d550abe7ebf 100644 --- a/evetest/tests/upgrade/upgrade_test.go +++ b/evetest/tests/upgrade/upgrade_test.go @@ -105,6 +105,18 @@ func TestEVEUpgrade(test *testing.T) { targetHypervisor := evetest.GetHypervisorParameterValue() expectRevert := evetest.GetTestParameter[bool](expectRevertParamKey) + // The pre-upgrade device pins INITIAL_EVE_VERSION, so it always boots that + // released version from a container image; the live transport applies to the + // upgrade target only, which is the useful direction -- "does my working tree + // survive an upgrade from the last release?". Naming a target version and + // asking for the live transport is only contradictory when that version is + // not one of the local builds, which UpgradeEVE reports. + if evetest.LocalLiveImageRequested() { + evetestT.Logf("Upgrading to the local EVE build (%s%s is set); the "+ + "pre-upgrade version %q still comes from a container image", + constants.EnvPrefix, constants.EVELiveImageEnv, initialVersion) + } + const devName = "edge-dev" evetest.Setup( evetest.RequireEdgeDevice{ diff --git a/evetest/utils/docker.go b/evetest/utils/docker.go index f7223dd598a..c4e67fe8300 100644 --- a/evetest/utils/docker.go +++ b/evetest/utils/docker.go @@ -95,6 +95,25 @@ func HaveDockerImage(ctx context.Context, log *logrus.Entry, image string) (bool return err == nil, nil } +// DockerImageID returns the content-addressable ID of a local Docker image. +// Callers that cache artifacts derived from an image must key on this rather +// than on the image name: the normal development loop rebuilds EVE and pushes +// it under an unchanged tag, so a name-keyed cache would serve stale results. +func DockerImageID(ctx context.Context, imageName string) (string, error) { + cli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return "", fmt.Errorf("failed to create docker client: %w", err) + } + resp, err := cli.ImageInspect(ctx, imageName) + if err != nil { + return "", fmt.Errorf("failed to inspect docker image %q: %w", imageName, err) + } + if resp.ID == "" { + return "", fmt.Errorf("docker reported no ID for image %q", imageName) + } + return resp.ID, nil +} + // IsErrDockerImageNotFound returns true if err indicates that a Docker image // does not exist in the local Docker daemon (i.e. "No such image"). func IsErrDockerImageNotFound(err error) bool {