Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,19 @@ linked, mode-`0600` file owned by you):
stops the client's whole process group; so does the time budget (default 30 minutes,
at most 2 hours). Both also apply while base images resolve, before the client
starts: the budget covers the whole operation.
- A cold build needs at least 6 GiB free on the runtime's storage disk, or twice the
last image of the same build if that is larger; otherwise it refuses with
`native_build_storage` and deletes nothing. After each successful build, BuildKit's
cache is pruned to 8 GiB. The cache holds only build steps; pruning never removes
images, containers or volumes.

`runtime reclaim-build-images --json` removes build images that a newer build of the
same key replaced. It keeps every image a build receipt names as current, every image
that any retained run's saved state mentions, every image a container still uses (the
engine refuses those), and every image whose ownership labels are not exactly this
runtime's. Each removal deletes that one image only, never its untagged parent
images. It refuses while a build runs, and removes nothing if it cannot read all
saved run state. The report lists removed images and why each other one was kept.

Before startup, network-tool preparation additionally requires all four pinned
Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/v5-model-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ must be attached to the eventual exact integration revision.
| Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. |
| Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. |
| Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. |
| Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; a build client orphaned by a killed `hack-native` is not modeled (the client inherits the build fence and per-key lock, so both stay held until it exits: `image_build::tests::a_killed_coordinator_leaves_its_client_fenced`); real BuildKit, engine removal semantics and guest storage are host evidence. |
| MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). |
| Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. |
| Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. |
Expand Down Expand Up @@ -86,6 +87,7 @@ liveness/reachability controls have their own documented assumptions.
| [fence-pending](../../tests/models/tla/fence-pending/Fence.tla) | 7 | 2 |
| [relay-staging](../../tests/models/tla/relay-staging/Stage.tla) | 9 | 2 |
| [relay-fence](../../tests/models/tla/relay-fence/RelayFence.tla) | 16 | 2 |
| [native-build-retention](../../tests/models/tla/native-build-retention/BuildRetention.tla) | 6,181 | 4 |
| [shared-helper-instance](../../tests/models/tla/shared-helper-instance/Instance.tla) | 7,649 | 11 |
| [restore-history](../../tests/models/tla/restore-history/RestoreHistory.tla) | See named result contract | 2 |
| [graph-admission](../../tests/models/tla/graph-admission/Admission.tla) | See named result contract | 2 |
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,11 @@ build fence, cancellation and timeout rules are documented in that module. Egres
pools give dockerd a fixed default bridge (`10.213.255.0/24`, `icc=false`) that only
BuildKit `RUN` steps use; `guest-build-network.sh` isolates it from the guest, the host
gateway and link-local metadata, and the engine client refuses any container create
that does not name an owned network or `none`. Native `up` does not call the build
that does not name an owned network or `none`. Cold builds need free guest storage
(at least 6 GiB, or twice the key's last image), successful builds prune BuildKit
cache to 8 GiB, and `runtime reclaim-build-images` removes only owned, superseded,
unreferenced images (`provider/image_build/reclaim.rs`; model
`tests/models/tla/native-build-retention`). Native `up` does not call the build
operation yet.

Source binaries embed their build checkout. If a copied Cargo target directory
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Usage:
hack-local runtime prepared-base remove --base-id <id> [--store <absolute-private-store>] [--json]
hack-local runtime admit-image --reference <[namespace/]repository[:tag][@sha256:digest]|sha256:image-id> --json
hack-local runtime build-image --request <private-0600-request.json> --json
hack-local runtime reclaim-build-images --json
hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> --json
hack-local runtime resolve-image --reference <[namespace/]repository[:tag]> --json
hack-local runtime fetch-image --reference <[namespace/]repository@sha256:digest> --archive <new-flat-image.tar> [--json]
Expand Down Expand Up @@ -956,6 +957,13 @@ fn run() -> Result<(), CandidateError> {
Path::new(request),
)?)?;
}
["runtime", "reclaim-build-images", "--json"] => {
print_json(
&hack_runtime_core::provider::image_build::reclaim::reclaim_build_images(
&discover_candidate(&requested)?,
)?,
)?;
}
["runtime", "admit-image", "--reference", reference, "--json"] => {
// Pure: no candidate discovery, network, credentials or runtime effects.
print_json(&hack_runtime_core::provider::registry_image::admit(
Expand Down
95 changes: 92 additions & 3 deletions packages/runtime-core/src/provider/image_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ use std::{
};

mod output;
pub mod reclaim;
#[cfg(test)]
mod tests;

Expand All @@ -91,6 +92,13 @@ const MAX_METADATA_BYTES: u64 = 1024 * 1024;
const MAX_IGNORE_BYTES: u64 = 1024 * 1024;
const MAX_ARGS: usize = 64;
const MAX_ARG_BYTES: usize = 64 * 1024;
const GIB: u64 = 1024 * 1024 * 1024;
/// A cold build needs this much free guest storage, or twice the key's last verified
/// image size when that is larger. Admission refuses; it never deletes to make room.
const STORAGE_FLOOR: u64 = 6 * GIB;
/// BuildKit cache kept after each successful build. The cache in this engine is created
/// only by Hack builds and is regenerable; pruning never touches images or volumes.
pub(super) const CACHE_BUDGET: u64 = 8 * GIB;
/// Labels a Dockerfile (or a base image) must not set on an image Hack runs: the same
/// reserved ownership namespaces the frontend refuses on authored services.
const RESERVED_LABEL_PREFIXES: [&str; 5] = [
Expand Down Expand Up @@ -169,6 +177,10 @@ pub struct BuildOutcome {
pub reused: bool,
/// Named build context to the pinned reference it resolved to, for this image.
pub base_pins: BTreeMap<String, String>,
/// Build cache bytes the post-build prune reclaimed; `None` when no prune ran or
/// it failed (the build result stands either way).
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_reclaimed_bytes: Option<u64>,
}

/// Receipt phases. `building` with no live holder of the key lock is a dead attempt.
Expand All @@ -195,6 +207,10 @@ pub(super) struct Receipt {
phase: Phase,
#[serde(default, skip_serializing_if = "Option::is_none")]
image_id: Option<String>,
/// Engine-reported size of the last verified image of this key, for the next cold
/// build's admission. Attempts that verify no image carry it forward unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
image_bytes: Option<u64>,
base_pins: BTreeMap<String, String>,
}

Expand Down Expand Up @@ -228,6 +244,17 @@ pub(super) trait BuildPool {
fn builder(&self) -> Result<PathBuf, CandidateError>;
/// Where the client's filtered output goes.
fn output(&self) -> Box<dyn Write + Send>;
/// Free bytes on the guest storage disk that holds images and build cache.
fn storage_available(&self, lease: &Self::Lease) -> Result<u64, CandidateError>;
/// Prune BuildKit cache down to `max_used` bytes; returns the bytes reclaimed.
fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result<u64, CandidateError>;
}

/// Free storage a cold build of a key requires.
pub(super) fn storage_required(previous_image_bytes: Option<u64>) -> u64 {
previous_image_bytes
.map_or(0, |bytes| bytes.saturating_mul(2))
.max(STORAGE_FLOOR)
}

/// Validated local inputs and the build key derived from them.
Expand Down Expand Up @@ -757,6 +784,7 @@ pub(super) fn run<P: BuildPool>(
tag: inputs.tag(),
reused: true,
base_pins: previous.base_pins.clone(),
cache_reclaimed_bytes: None,
});
}
}
Expand All @@ -769,6 +797,7 @@ pub(super) fn run<P: BuildPool>(
state::write(&receipt_path, &dead)?;
}
}
let previous_bytes = previous.as_ref().and_then(|receipt| receipt.image_bytes);
drop(lease);

// Step 2, unleased: resolve every base image to a pinned manifest.
Expand Down Expand Up @@ -809,6 +838,12 @@ pub(super) fn run<P: BuildPool>(
"The runtime changed before the build started; no build was run.",
));
}
if pool.storage_available(&lease)? < storage_required(previous_bytes) {
return Err(refusal(
"native_build_storage",
"The native runtime's storage disk has too little free space for this build (at least 6 GiB, or twice the last image of this build). Inspect it with `runtime guest-disk-usage` and remove unused images or data; nothing was deleted.",
));
}
let mut receipt = Receipt {
version: 1,
checkout: candidate.checkout.clone(),
Expand All @@ -819,6 +854,9 @@ pub(super) fn run<P: BuildPool>(
engine_id: identity.engine_id.clone(),
phase: Phase::Building,
image_id: None,
// Failed, canceled or interrupted attempts keep the last verified size, so a
// retry is admitted against it rather than the floor.
image_bytes: previous_bytes,
base_pins: pins.clone(),
};
state::write(&receipt_path, &receipt)?;
Expand Down Expand Up @@ -902,10 +940,10 @@ pub(super) fn run<P: BuildPool>(
)
})?;
owned_image(&inspected, &id, &expected)?;
Ok(id)
Ok((id, inspected["Size"].as_u64()))
});
let id = match adopted {
Ok(id) => id,
let (id, image_bytes) = match adopted {
Ok(adopted) => adopted,
Err(error) => {
// A refused or missing result is a failed build; an engine or transport
// error leaves the outcome unknown.
Expand All @@ -920,14 +958,18 @@ pub(super) fn run<P: BuildPool>(
};
receipt.phase = Phase::ContentIdVerified;
receipt.image_id = Some(id.clone());
receipt.image_bytes = image_bytes.or(receipt.image_bytes);
state::write(&receipt_path, &receipt)?;
// The verified image is recorded first; a failed prune leaves only extra cache.
let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok();
drop(lease);
Ok(BuildOutcome {
image_id: id,
build_key: inputs.key.clone(),
tag: inputs.tag(),
reused: false,
base_pins: pins,
cache_reclaimed_bytes,
})
}

Expand Down Expand Up @@ -1369,6 +1411,53 @@ impl<'a> BuildPool for LivePool<'a> {
fn output(&self) -> Box<dyn Write + Send> {
Box::new(std::io::stderr())
}
fn storage_available(&self, lease: &Self::Lease) -> Result<u64, CandidateError> {
guest_storage_available(lease)
}
fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result<u64, CandidateError> {
prune_build_cache(lease, max_used)
}
}

/// Free bytes on the guest `/storage` ext4 disk, which holds `/var/lib/docker`.
pub(super) fn guest_storage_available(
engine: &super::engine::Engine<'_>,
) -> Result<u64, CandidateError> {
let output =
engine
.guest()
.execute("df -Pk /storage | awk 'NR == 2 { print $4 }'", &[], None)?;
output
.trim()
.parse::<u64>()
.ok()
.and_then(|kib| kib.checked_mul(1024))
.ok_or_else(|| {
refusal(
"guest_protocol",
"The guest reported no usable free-space value.",
)
})
}

/// Prune the engine's BuildKit cache to at most `max_used` bytes. Cache records only:
/// the build-prune endpoint never removes images, containers or volumes, and BuildKit
/// keeps records an active build uses.
pub(super) fn prune_build_cache(
engine: &super::engine::Engine<'_>,
max_used: u64,
) -> Result<u64, CandidateError> {
let value = engine.request(
reqwest::Method::POST,
&format!("/v1.53/build/prune?max-used-space={max_used}"),
None,
)?;
value["SpaceReclaimed"].as_u64().ok_or_else(|| {
refusal(
"engine_protocol",
"The engine reported no reclaimed build-cache size.",
)
})
}

/// Run a blocking, effect-free network step on its own thread and wait for it until
Expand Down
Loading
Loading