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
47 changes: 47 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,53 @@ state and checks its code signature. If a prepared client exists, the command ch
it again and does not replace it. If the prepared tree changes, `runtime probe`
refuses; it reports `builder_prepared` when the client is present and unchanged.

`runtime build-image --request <file> --json` builds one image in the running
runtime's engine with that client. It is a building block: native `up` still refuses
`build:` services and does not call it. The request is a private JSON file (a singly
linked, mode-`0600` file owned by you):

```json
{"version": 1, "project_root": "/absolute/project", "context": "app",
"dockerfile": "Dockerfile", "target": null, "args": {"NAME": "literal"},
"policy": "missing", "timeout_seconds": 1800}
```

- The context and Dockerfile must be inside the project and reached without symlinks.
The Dockerfile is read, never run, to find every image it can pull. Each image must
be a public Docker Hub reference; it is pinned to a digest before the build starts.
Base images with stored `ONBUILD` triggers refuse. The client builds from a private
copy of the Dockerfile and ignore rules that were scanned, so editing either file
during the build has no effect on it.
- Build arguments are not secrets, exactly as with Docker: the built image records
their values in its history, and an argument that selects a base image appears in
the resolved base references that the receipt and the result report. Never pass a
secret as a build argument. BuildKit build secrets are the right mechanism for
secrets; native builds do not support them yet.
- Every build runs under a BuildKit source policy: only the pinned base images
resolve as images, and network sources (`ADD` of an `http`, `https` or git URL, and
image blob sources) are denied. The engine would otherwise fetch those from outside
the isolated build network. The policy also covers sources that a base image's
stored `ONBUILD` triggers add; such bases refuse before the build anyway.
- Argument values reach the client only through a private definition file (mode
`0600`, removed after the build), never its command line or its environment; the
client's environment is a fixed list of its own settings. Names must be plain
identifiers. Names BuildKit or buildx interpret themselves (`BUILDKIT_*`,
`BUILDX_*`, `SOURCE_DATE_EPOCH`) and the platform arguments refuse.
- Build output is shown on stderr after filtering, as defense in depth: every
argument value is replaced with `[redacted]`, also when quoted or escaped the way
the client prints values, terminal control characters are removed, lines are cut at
16 KiB and output stops after 4 MiB.
- `missing` reuses a verified image for the same build key without running the client;
`rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes,
target, arguments and versions, not the context's other files (as with Compose).
- The runtime must have network egress and the isolated build network that a runtime
started by this version sets up. An older runtime refuses with
`native_build_runtime_restart`: stop it and start it again.
- `runtime down` refuses while a build runs. Ctrl-C or SIGTERM cancels the build and
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.

Before startup, network-tool preparation additionally requires all four pinned
Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with
regular, singly linked mode-`0600` files. Acquire the matching public package bytes;
Expand Down
11 changes: 9 additions & 2 deletions packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,15 @@ The default private build includes native HTTP graph probes (local `_docs/docs/p
and requires Zig 0.15.2 plus a host C compiler.
[Provider pins](provider-pins.json) record the exact package inputs; the engine runs inside the VM.
The pinned host BuildKit client (`runtime prepare-builder`) and the pure Dockerfile
image-reference scanner (`provider/build_scan.rs`) prepare for native image builds;
no command builds images yet.
image-reference scanner (`provider/build_scan.rs`) back `runtime build-image`
(`provider/image_build.rs`), which builds one Compose-style image in the guest engine
from a private request file. Its build key, receipts, leases, the `runtime down`
build fence, cancellation and timeout rules are documented in that module. Egress
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
operation yet.

Source binaries embed their build checkout. If a copied Cargo target directory
retains a different checkout identity, use a fresh `--target-dir` for checks and
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 @@ -102,6 +102,7 @@ Usage:
hack-local runtime prepared-base status --profile research|development [--store <absolute-private-store>] [--json]
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 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 @@ -948,6 +949,13 @@ fn run() -> Result<(), CandidateError> {
reference,
)?)?;
}
["runtime", "build-image", "--request", request, "--json"] => {
// Build output streams to stderr; only the result is printed on stdout.
print_json(&hack_runtime_core::provider::image_build::build_image(
&discover_candidate(&requested)?,
Path::new(request),
)?)?;
}
["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
1 change: 0 additions & 1 deletion packages/runtime-core/src/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

pub use enrollment::{EnrollmentReceipt, enroll, status, status_with_branch};
#[cfg(feature = "native-config-plan")]
pub(crate) use source::resolve as resolve_source;
pub use source::{SourceEntry, SourceSelection};

Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/provider/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ pub fn prepare_builder(candidate: &Candidate, binary: &Path) -> Result<Artifact,
prepare_binary(candidate, binary, &BUILDER)
}

/// The prepared BuildKit client executable. Callers run it only after `verify_builder`.
pub fn builder_executable(candidate: &Candidate) -> PathBuf {
binary_root(candidate, &BUILDER).join(BUILDER.executable)
}

/// Verify the prepared BuildKit client before any use.
pub fn verify_builder(candidate: &Candidate) -> Result<Artifact, CandidateError> {
verify_binary(candidate, &BUILDER)
Expand Down
67 changes: 67 additions & 0 deletions packages/runtime-core/src/provider/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ impl Transport {
{
return Err(failure("Invalid private engine request path."));
}
require_owned_network(&method, path, body)?;
let requires_admission = ![Method::GET, Method::HEAD, Method::DELETE].contains(&method);
let mut request = self
.client
Expand Down Expand Up @@ -127,6 +128,40 @@ impl Transport {
}
}

/// Every container Hack creates names its network explicitly: an owned network, `none`
/// or another container's namespace. Egress pools give the engine a default bridge that
/// only BuildKit `RUN` steps may use (guest-daemon.sh); an unset, `default`, `bridge`
/// or `host` mode, or a connect to the default bridge, would put a container on it or
/// in the guest root namespace, so both refuse before dispatch.
fn require_owned_network(
method: &Method,
path: &str,
body: Option<&Value>,
) -> Result<(), CandidateError> {
if *method != Method::POST {
return Ok(());
}
let route = path.split('?').next().unwrap_or(path);
let refused = || {
CandidateError::new(
"engine_network_refused",
"Containers must name an owned network or none; the engine default bridge is reserved for image builds.",
)
};
if route.ends_with("/containers/create") {
let mode = body
.and_then(|body| body["HostConfig"]["NetworkMode"].as_str())
.unwrap_or("");
if mode.is_empty() || ["default", "bridge", "host"].contains(&mode) {
return Err(refused());
}
}
if route.ends_with("/networks/bridge/connect") {
return Err(refused());
}
Ok(())
}

// One minimum budget reaches dispatch. A later explicit deadline cannot replace
// an earlier mutation-admission deadline; cleanup admission exemptions stay above.
fn request_timeout(
Expand Down Expand Up @@ -691,6 +726,38 @@ mod tests {
use std::io::Write;
use std::os::unix::net::UnixListener;

#[test]
fn containers_must_name_their_network_and_never_join_the_build_bridge() {
let create = |mode: Value| {
require_owned_network(
&Method::POST,
"/v1.53/containers/create?name=x",
Some(&serde_json::json!({"HostConfig": {"NetworkMode": mode}})),
)
};
for refused in [
Value::Null,
serde_json::json!(""),
serde_json::json!("default"),
serde_json::json!("bridge"),
serde_json::json!("host"),
serde_json::json!(7),
] {
assert_eq!(create(refused).unwrap_err().code, "engine_network_refused");
}
for allowed in ["none", "hack-local-owned", "container:abc"] {
assert!(create(serde_json::json!(allowed)).is_ok());
}
assert!(require_owned_network(&Method::POST, "/v1.53/containers/create", None).is_err());
assert!(
require_owned_network(&Method::POST, "/v1.53/networks/bridge/connect", None).is_err()
);
assert!(
require_owned_network(&Method::POST, "/v1.53/networks/owned/connect", None).is_ok()
);
assert!(require_owned_network(&Method::GET, "/v1.53/networks/bridge", None).is_ok());
}

#[test]
fn explicit_request_budget_never_extends_mutation_admission() {
let now = Instant::now();
Expand Down
24 changes: 21 additions & 3 deletions packages/runtime-core/src/provider/engine/admission_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ fn empty_admission() -> super::super::managed_environment::Managed {
managed
}

/// Every create Hack dispatches names its network; the engine guard refuses one that
/// does not before admission is consulted, so these controls name `none`.
fn owned_create() -> Value {
serde_json::json!({"HostConfig": {"NetworkMode": "none"}})
}

fn expire(deadline: Instant) {
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
std::thread::sleep(remaining + Duration::from_millis(1));
Expand Down Expand Up @@ -129,7 +135,11 @@ fn empty_admission_expiring_before_create_sends_no_request() {
let transport = fixture.transport(managed.deadline(), Duration::from_millis(250));
expire(managed.deadline());
let failure = transport
.request(Method::POST, "/v1.53/containers/create?name=owned", None)
.request(
Method::POST,
"/v1.53/containers/create?name=owned",
Some(&owned_create()),
)
.unwrap_err();
assert_eq!(failure.code, "graph_environment_input");
fixture.assert_no_request();
Expand Down Expand Up @@ -183,7 +193,11 @@ fn empty_admission_expiring_after_create_blocks_start_but_allows_owned_delete()
None,
);
transport
.request(Method::POST, "/v1.53/containers/create?name=owned", None)
.request(
Method::POST,
"/v1.53/containers/create?name=owned",
Some(&owned_create()),
)
.unwrap();
create.join().unwrap();
let id = "1".repeat(64);
Expand Down Expand Up @@ -243,7 +257,11 @@ fn in_flight_mutation_cannot_renew_empty_admission_transport_budget() {
// The request was dispatched while valid, but its response arrives after expiry.
// Without the remaining-lifetime timeout it would succeed under the 3s default.
let failure = transport
.request(Method::POST, "/v1.53/containers/create?name=owned", None)
.request(
Method::POST,
"/v1.53/containers/create?name=owned",
Some(&owned_create()),
)
.unwrap_err();
assert_eq!(failure.code, "engine_protocol");
assert!(failure.message.contains("effect may be uncertain"));
Expand Down
33 changes: 33 additions & 0 deletions packages/runtime-core/src/provider/guest-build-network.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
set -eu
# Isolate the build bridge (docker0) that only BuildKit `RUN` steps use. Docker's
# own isolation chains already drop docker0 <-> owned project bridges; these rules
# also drop build traffic to the guest itself (relays, sockets, the engine), to
# the host gateway and to link-local metadata, while allowing DNS to the guest's
# configured resolvers. External reach is then only what the VM boundary allows.
# Arguments: network mode, guest boot ID. The marker binds the rules to this boot.
case "${1-}" in
isolated) exit 0 ;;
approved-hosts|internet|host-gateway) ;;
*) exit 64 ;;
esac
test -d /sys/class/net/docker0
marker=/run/hack-local/build-network
for chain in HACK-BUILD-FORWARD HACK-BUILD-INPUT; do
iptables -N "$chain" 2>/dev/null || iptables -F "$chain"
done
for resolver in $(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf); do
case "$resolver" in *:*|127.*) continue ;; esac
for protocol in udp tcp; do
iptables -A HACK-BUILD-FORWARD -d "$resolver" -p "$protocol" --dport 53 -j RETURN
iptables -A HACK-BUILD-INPUT -d "$resolver" -p "$protocol" --dport 53 -j ACCEPT
done
done
iptables -A HACK-BUILD-FORWARD -d 100.96.0.1 -j DROP
iptables -A HACK-BUILD-FORWARD -d 169.254.0.0/16 -j DROP
iptables -A HACK-BUILD-INPUT -j DROP
iptables -C DOCKER-USER -i docker0 -j HACK-BUILD-FORWARD 2>/dev/null ||
iptables -I DOCKER-USER 1 -i docker0 -j HACK-BUILD-FORWARD
iptables -C INPUT -i docker0 -j HACK-BUILD-INPUT 2>/dev/null ||
iptables -I INPUT 1 -i docker0 -j HACK-BUILD-INPUT
printf 'build-network-v1 %s\n' "$2" > "$marker.pending"
mv "$marker.pending" "$marker"
13 changes: 9 additions & 4 deletions packages/runtime-core/src/provider/guest-daemon.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
set -eu
# Egress pools give dockerd a default bridge with a fixed range outside Docker's
# default address pools. Only BuildKit `RUN` steps use it: graph containers always
# name an owned network or `none`, and the engine client refuses any other create.
# guest-build-network.sh isolates it after the engine starts. Isolated pools keep
# no default bridge, so builds refuse there.
case "${1-}" in
isolated) gateway_rules=false; set -- ;;
approved-hosts|internet) gateway_rules=true; set -- ;;
host-gateway) gateway_rules=true; set -- --host-gateway-ip=100.96.0.1 ;;
isolated) gateway_rules=false; set -- --bridge=none ;;
approved-hosts|internet) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false ;;
host-gateway) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false --host-gateway-ip=100.96.0.1 ;;
*) exit 64 ;;
esac
dockerd --storage-driver=overlay2 --data-root=/var/lib/docker \
--bridge=none --iptables="$gateway_rules" --ip6tables=false \
--iptables="$gateway_rules" --ip6tables=false \
--ip-forward="$gateway_rules" --ip-masq="$gateway_rules" "$@" \
--exec-root=/run/hack-local/exec --pidfile=/run/hack-local/docker.pid \
--host=unix:///run/hack-local/docker.sock --log-level=warn \
Expand Down
Loading
Loading