Skip to content
Merged
39 changes: 33 additions & 6 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ hack doctor --native-preflight --profile dev,ops
hack doctor --native-preflight --json
```

It lists the blockers and notes for the project's `.hack/docker-compose.yml`,
It lists blockers, consent needed and notes for the project's `.hack/docker-compose.yml`,
each with a plain sentence and a fix. It judges what `hack up` judges: the
Compose document after the selected native adaptation and the project-wide
extra hosts are applied, reviewed with the profiles you pass.
Expand All @@ -192,6 +192,21 @@ foreground `up`; native publishes TCP only. Graph budgets, readiness, probes
and restart restrictions use the same admission checks. Inactive profile
services do not contribute publication blockers.

Graph subset and route refusals identify each affected service and field, with
a separate reason and fix. Incompatible plans show their underlying field
diagnostics instead of a second generic refusal. Invalid dependency-cache
declarations identify the offending labels across services; foreign-platform
findings name each service. Only names and field paths are shown, never authored
values. Structural parse errors can still stop review early; fix them and rerun.

Missing shared-source consent appears under **Consent needed**, separately from
configuration blockers. Review still runs without consent, including in a
non-interactive terminal, and `--json` retains the full discovered blocker list.
Reporting consent does not grant it or start the VM. The file-only native check in
`hack upgrade check` also reports outstanding consent separately and does not call
the project native-ready until it is granted; native readiness remains informational
for a Compose upgrade.

The Compose input must be a nonempty regular file of at most 256 KiB, not a
symbolic link. Unreadable, changing or oversized input is reported as
`compose_file_unreadable`; the check never waits for a FIFO writer.
Expand Down Expand Up @@ -230,9 +245,19 @@ service receiving only those adapter credentials can still be refused at serve.

With `--json` the report is the `data` of the standard result envelope:

Report version 2 moves `shared_source_grant_required` from `blockers` into
`consent_needed`. Consumers should use `ready` for the overall verdict and
inspect both lists for what remains to do.

Configuration finding `field` paths are relative to the named service (for
example, `labels.hack.dependencies.lockfiles`); plan diagnostics use absolute
paths such as `services.api.privileged`. Large configuration refusal batches
retain their code and service locations during authored startup as well as
preflight. Raw runtime stderr and startup output are not configuration findings.

```json
{
"report_version": 1,
"report_version": 2,
"backend": "native",
"compose_file": ".hack/docker-compose.yml",
"ready": false,
Expand All @@ -243,6 +268,7 @@ With `--json` the report is the `data` of the standard result envelope:
"fix": "Native runtime is set up."
},
"runtime_review": { "status": "completed" },
"consent_needed": [],
"blockers": [
{
"severity": "blocker",
Expand All @@ -258,12 +284,13 @@ With `--json` the report is the `data` of the standard result envelope:
}
```

- `ready` is true only when there is no blocker, setup is ready and the runtime
review completed.
- The exit status follows the blockers: `0` with none, `1` otherwise. A skipped
- `ready` is true only when there is no blocker or missing consent, setup is ready
and the runtime review completed.
- The exit status is `1` if blockers or missing consent remain, `0` otherwise. A skipped
or unavailable review is not a blocker, so that run exits `0` with
`ready: false`. With `--json` the envelope is `ok: true` in both cases; read
`ready` and `blockers` for the verdict.
`ready`, `blockers` and `consent_needed` for the verdict. `consent_needed`
contains findings with severity `consent`, including `shared_source_grant_required`.
- `gate` is `cli` or `runtime`: which of the two checks reported the finding.
- `runtime_review.status` is one of:
- `completed`: the review ran.
Expand Down
44 changes: 44 additions & 0 deletions packages/runtime-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ pub struct RouteProbeDiagnostic {
/// `process_refusals`, `plan_change` and `route_probe`.
#[derive(Debug, Default, Serialize)]
pub struct RefusalDiagnostics {
/// File-only explanations. Every sentence and field is a compile-time literal;
/// only validated service names come from the project. Never contains values.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub configuration_refusals: Vec<ConfigurationRefusal>,
/// Value-free durable interactive-session UUID, never argv or environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
Expand All @@ -158,6 +162,34 @@ pub struct RefusalDiagnostics {
pub route_probe: Option<RouteProbeDiagnostic>,
}

#[derive(Clone, Debug, Serialize)]
pub struct ConfigurationRefusal {
pub text_fixed: bool,
pub service: Option<Box<str>>,
pub field: &'static str,
pub message: &'static str,
pub fix: &'static str,
}

impl ConfigurationRefusal {
pub fn new(field: &'static str, message: &'static str, fix: &'static str) -> Self {
Self {
text_fixed: true,
service: None,
field,
message,
fix,
}
}

pub fn with_service(mut self, service: &str) -> Self {
self.service = CandidateError::new("configuration", "Configuration refusal.")
.with_service(service)
.service;
self
}
}

/// Closed diagnostic vocabulary for the read-only log follower. No stream bytes.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
Expand Down Expand Up @@ -204,6 +236,18 @@ pub struct CandidateError {
}

impl CandidateError {
pub fn with_configuration_refusals(mut self, mut findings: Vec<ConfigurationRefusal>) -> Self {
// Malformed lists can repeat the same field thousands of times. Keep
// every distinct explanation, not one copy per authored value. This
// also bounds current producers below the CLI's shared 1 MiB error
// envelope, including planning and startup consumers.
let mut seen = std::collections::BTreeSet::new();
findings.retain(|item| seen.insert((item.service.clone(), item.field, item.message)));
self.refusals
.get_or_insert_with(Default::default)
.configuration_refusals = findings;
self
}
pub fn with_session(mut self, session: &str) -> Self {
// This file is also compiled by the guest, which has no host provider.
if session.len() == 36
Expand Down
134 changes: 109 additions & 25 deletions packages/runtime-core/src/project/compose/dependency_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,32 @@ const DEFAULT_RUNTIME: &[&str] = &[
pub(super) fn recognized(key: &str) -> bool {
matches!(key, VOLUME | LOCKFILES | RUNTIME | BOOTSTRAP)
}
fn refused() -> CandidateError {
fn detail(
key: &str,
message: &'static str,
fix: &'static str,
) -> crate::error::ConfigurationRefusal {
let field = match key {
VOLUME => "labels.hack.dependencies.cache-volume",
LOCKFILES => "labels.hack.dependencies.lockfiles",
RUNTIME => "labels.hack.dependencies.runtime-files",
BOOTSTRAP => "labels.hack.dependencies.bootstrap",
_ => "labels",
};
crate::error::ConfigurationRefusal::new(field, message, fix)
}
fn refused(findings: Vec<crate::error::ConfigurationRefusal>) -> CandidateError {
problem(
"invalid_dependency_cache",
"Dependency cache requires a literal named volume, bounded relative file paths and a boolean bootstrap declaration; values omitted.",
"Dependency cache declarations are invalid; values omitted.",
)
.with_configuration_refusals(findings)
}
fn paths(value: Option<&String>, defaults: &[&str]) -> Result<(Vec<String>, bool), CandidateError> {
fn paths(value: Option<&String>, defaults: &[&str]) -> Option<(Vec<String>, bool)> {
let mut paths = std::collections::BTreeSet::new();
if let Some(value) = value {
if value.len() > 8192 {
return Err(refused());
return None;
}
for path in value.split(',').map(str::trim).filter(|p| !p.is_empty()) {
if path.len() > 512
Expand All @@ -48,67 +63,121 @@ fn paths(value: Option<&String>, defaults: &[&str]) -> Result<(Vec<String>, bool
.split('/')
.any(|p| p.is_empty() || p == "." || p == "..")
{
return Err(refused());
return None;
}
paths.insert(path.to_owned());
if paths.len() > 32 {
return Err(refused());
return None;
}
}
}
let explicit = !paths.is_empty();
if !explicit {
paths.extend(defaults.iter().map(|p| (*p).to_owned()));
}
Ok((paths.into_iter().collect(), explicit))
Some((paths.into_iter().collect(), explicit))
}
/// Collect independent label errors without ever storing a label value in a diagnostic.
/// The accepted cache and the standalone-bootstrap exception are unchanged.
pub(super) fn parse(value: Option<&Value>) -> Result<Option<DependencyCachePlan>, CandidateError> {
let mut labels = BTreeMap::new();
let mut insert = |key: &str, value: &Value| -> Result<(), CandidateError> {
let mut findings = Vec::new();
let mut insert = |key: &str, value: &Value| {
if !recognized(key) {
return Ok(());
return;
}
let value = match value {
Value::String(v) => v.clone(),
Value::Bool(v) if key == BOOTSTRAP => v.to_string(),
_ => return Err(refused()),
_ => {
findings.push(detail(key, "A dependency-cache label has the wrong type.", "Use a string for cache-volume and file lists; bootstrap accepts a boolean or its literal string form."));
return;
}
};
if labels.insert(key.to_owned(), value).is_some() {
return Err(refused());
findings.push(detail(
key,
"A dependency-cache label is declared more than once.",
"Keep exactly one declaration of this label.",
));
}
Ok(())
};
match value {
None | Some(Value::Null) => return Ok(None),
Some(Value::Object(values)) => {
for (key, value) in values {
insert(key, value)?;
insert(key, value);
}
}
Some(Value::Array(values)) => {
for value in values {
let value = value.as_str().ok_or_else(refused)?;
let (key, value) = value.split_once('=').unwrap_or((value, ""));
insert(key, &Value::String(value.into()))?;
if let Some(value) = value.as_str() {
let (key, value) = value.split_once('=').unwrap_or((value, ""));
insert(key, &Value::String(value.into()));
} else {
findings.push(detail(
"",
"The label list contains a non-string entry.",
"Use a mapping or a list of key=value strings for labels.",
));
return Err(refused(findings));
}
}
}
_ => return Err(refused()),
_ => {
return Err(refused(vec![detail(
"",
"Labels are neither a mapping nor a list.",
"Use a mapping or a list of key=value strings for labels.",
)]));
}
}
if labels.is_empty() || (labels.len() == 1 && labels.contains_key(BOOTSTRAP)) {
return Ok(None);
return if findings.is_empty() {
Ok(None)
} else {
Err(refused(findings))
};
}
let volume = labels.get(VOLUME).ok_or_else(refused)?.trim().to_owned();
super::fields::identifier(&volume).map_err(|_| refused())?;
// A generated anonymous volume belongs to one service and is never a cache.
if volume.starts_with(super::service::ANONYMOUS_PREFIX) {
let volume = labels
.get(VOLUME)
.map(|v| v.trim().to_owned())
.unwrap_or_default();
if super::fields::identifier(&volume).is_err() {
findings.push(detail(VOLUME, "The dependency cache has no literal named-volume identifier.", "Set hack.dependencies.cache-volume to one declared named volume, without interpolation."));
} else if findings.is_empty() && volume.starts_with(super::service::ANONYMOUS_PREFIX) {
// A generated anonymous volume belongs to one service and is never a
// cache. Same precedence as before: only when nothing earlier refused.
return Err(super::service::reserved_volume_name());
}
let (lockfiles, lockfiles_explicit) = paths(labels.get(LOCKFILES), DEFAULT_LOCKFILES)?;
let (runtime_files, _) = paths(labels.get(RUNTIME), DEFAULT_RUNTIME)?;
let lockfiles = paths(labels.get(LOCKFILES), DEFAULT_LOCKFILES);
let runtime = paths(labels.get(RUNTIME), DEFAULT_RUNTIME);
for (key, valid) in [
(LOCKFILES, lockfiles.is_some()),
(RUNTIME, runtime.is_some()),
] {
if !valid {
findings.push(detail(key, "The dependency-cache file list is not a bounded list of relative paths.", "Use at most 32 comma-separated relative paths (512 bytes each, 8192 bytes total), without variables, backslashes, control characters, empty, dot or parent components."));
}
}
let bootstrap = match labels.get(BOOTSTRAP).map(String::as_str) {
None | Some("false") => false,
Some("true") => true,
_ => return Err(refused()),
_ => {
findings.push(detail(
BOOTSTRAP,
"The dependency-cache bootstrap declaration is not boolean.",
"Use true or false for hack.dependencies.bootstrap.",
));
false
}
};
if !findings.is_empty() {
return Err(refused(findings));
}
let (Some((lockfiles, lockfiles_explicit)), Some((runtime_files, _))) = (lockfiles, runtime)
else {
return Err(refused(vec![]));
};
Ok(Some(DependencyCachePlan {
volume,
Expand All @@ -123,6 +192,21 @@ pub(super) fn parse(value: Option<&Value>) -> Result<Option<DependencyCachePlan>
mod tests {
use super::*;
use serde_json::json;
#[test]
fn duplicate_label_flood_keeps_a_small_error_with_prior_findings() {
let mut values = vec![json!("hack.dependencies.lockfiles=private-sentinel"); 4096];
values.push(json!(42));
let error = parse(Some(&Value::Array(values))).unwrap_err();
assert_eq!(error.code, "invalid_dependency_cache");
let wire = serde_json::to_value(error).unwrap();
let details = wire["configuration_refusals"].as_array().unwrap();
assert_eq!(details.len(), 2);
assert_eq!(details[0]["field"], "labels.hack.dependencies.lockfiles");
assert_eq!(details[1]["field"], "labels");
assert!(wire.to_string().len() < 8192);
assert!(!wire.to_string().contains("private-sentinel"));
}

#[test]
fn actual_declaration_and_list_form_are_equivalent() {
let map = json!({"hack.dependencies.cache-volume":"node_modules", "hack.dependencies.lockfiles":"bun.lock", "hack.dependencies.runtime-files":"package.json,bunfig.toml,mise.toml,.tool-versions,packages/db/prisma/schema.prisma", "hack.dependencies.bootstrap":"true"});
Expand Down
Loading
Loading