From a7995933a673e3097b10a6567dd70cc7beff7faa Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Wed, 7 Oct 2026 16:33:17 -0400 Subject: [PATCH] feat: preserve native workload process policy --- .github/workflows/ci.yml | 3 + docs/reference/native-config-compiler.md | 31 +- packages/config-compiler/README.md | 36 + .../generated/hack.project.schema.json | 186 +- .../generated/native-config.ts | 6 +- packages/config-compiler/src/lib.rs | 29 +- packages/config-compiler/src/model.rs | 32 + packages/config-compiler/src/process.rs | 178 ++ packages/config-compiler/src/shape.rs | 9 +- packages/config-compiler/src/validate.rs | 3 +- .../tests/fixtures/schema-corpus.json | 1540 +++++++++++++++++ packages/config-compiler/tests/process.rs | 389 +++++ packages/config-compiler/tests/protocol.rs | 2 +- scripts/check-native-process-plan-cli.ts | 397 +++++ src/lib/native-config-compiler.ts | 93 +- src/lib/native-process-plan-protocol.ts | 384 ++++ tests/native-process-plan-transport.test.ts | 525 ++++++ tests/native-process-project.test.ts | 320 ++++ 18 files changed, 4143 insertions(+), 20 deletions(-) create mode 100644 packages/config-compiler/src/process.rs create mode 100644 packages/config-compiler/tests/process.rs create mode 100644 scripts/check-native-process-plan-cli.ts create mode 100644 src/lib/native-process-plan-protocol.ts create mode 100644 tests/native-process-plan-transport.test.ts create mode 100644 tests/native-process-project.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afde00463..a5406da39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: bunx ultracite check scripts/check-native-endpoint-plan-cli.ts src/lib/native-endpoint-plan-protocol.ts tests/native-endpoint-plan-transport.test.ts tests/native-endpoint-project.test.ts bun test tests/native-endpoint-plan-transport.test.ts tests/native-endpoint-project.test.ts bun scripts/check-native-endpoint-plan-cli.ts + bunx ultracite check scripts/check-native-process-plan-cli.ts src/lib/native-process-plan-protocol.ts tests/native-process-plan-transport.test.ts tests/native-process-project.test.ts + bun test tests/native-process-plan-transport.test.ts tests/native-process-project.test.ts + bun scripts/check-native-process-plan-cli.ts state-models: name: Runtime state models diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 816460582..20519f621 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -46,6 +46,10 @@ versions refuse; omitted fields retain their documented defaults. "web": { "image": "example/web:1", "command": { "exec": ["web", "--port", "3000"] }, + "entrypoint": { "exec": [] }, + "init": true, + "shutdown": { "signal": "SIGTERM", "grace": "45s" }, + "restart": { "kind": "on-failure", "max_retries": 3 }, "working_directory": "/app", "mounts": [{ "source": ".", "target": "/app", "access": "read-only" }], "environment": { "TOKEN": { "env_ref": "TOKEN" } } @@ -60,6 +64,30 @@ versions refuse; omitted fields retain their documented defaults. - Omitted command preserves image defaults. `{ "exec": ["program", "argument"] }` and `{ "shell": "explicit shell source" }` are distinct. Empty commands and NUL bytes refuse. Argument order is preserved. +- Optional `entrypoint` uses the same explicit `exec` or `shell` tags. Its + `{ "exec": [] }` form clears the image entrypoint; an empty `command.exec` + still refuses. A nonempty argv needs a nonempty executable. Omission preserves + image defaults. Optional `init` is a strict boolean; explicit false stays false. +- Optional `shutdown` has `signal`, `grace`, or both. Signals use the 31 canonical + `SIG`-prefixed Linux names enumerated in the schema; aliases, numeric + signals and realtime signal tokens refuse. Grace is a positive integer in + `ms`, `s`, `m` or `h`, normalized to milliseconds up to 4,294,967,295 ms. + Empty objects, nulls and unknown fields refuse. Omitted signal/grace remains + omitted rather than guessing an image default. +- Optional `restart` is `{ "kind": "no" }`, `{ "kind": "always" }`, + `{ "kind": "unless-stopped" }` or `{ "kind": "on-failure" }`. Only + `on-failure` accepts `max_retries`, a positive integer up to 4,294,967,295. + Jobs reject `always` and `unless-stopped`, including inactive jobs, to preserve + their successful-exit completion contract. Omitted restart means no automatic + restart without adding a serialized default to the plan. +- These process fields are validated intent. Backend signal support, entrypoint + clearing, init behavior, restart execution and shutdown precision require + separate runtime qualification. Planning accepts grace values above 30 seconds; + an existing backend admission limit is not an authored-format restriction. + Local settings cannot supply workload process definitions. All four fields + remain absent when omitted, preserving existing plans and hashes. Compiler + protocol capability `process_plan_version: 1` is required when authored process + settings are present, even when the workload is inactive. - Mounts select exactly one relative `source` or declared `storage`, an absolute container `target` and explicit `access`: `read-only` or `read-write`. Targets must be unique after lexical normalization. Mount order is preserved. @@ -107,8 +135,7 @@ backslashes and drive syntax. Lexical `.` and repeated separators normalize; no filesystem or symlink resolution occurs. Working directories and mount targets must be absolute POSIX container paths without `..`. -Container shutdown/restart policies, -network/security/resources, cache protocols, backend options, arbitrary extensions, +Network/security/resources, cache protocols, advanced build options, backend options, arbitrary extensions, and other local settings are not yet implemented. They refuse rather than being silently dropped. This foundation does not replace the full native contract or qualify a migrated advanced project. diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index 7396bbfde..f5ebb1523 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -327,3 +327,39 @@ unchanged. The shared 8 MiB report budget counts binding reports and every expan endpoint before insertion. No new fields alter old hashes or replies when absent. This compiler does not resolve DNS, execute hooks, start services, or prove endpoint reachability; native execution and backend translation remain separate work. + +## Workload process policy + +`process_plan_version: 1` adds optional service/job `entrypoint`, `init`, +`shutdown`, and `restart` fields. Each field preserves omission; the compiler +does not supply a new default or rewrite image behavior. Explicit `init: false` +and explicit no-restart intent remain distinct from omission in the plan and +semantic identity. Every declaration is validated before profile filtering. + +`entrypoint` has exactly one form: `{exec: ["program", "argument"]}` or +`{shell: "explicit shell text"}`. The distinct entrypoint type also permits +`{exec: []}` to clear an inherited image entrypoint. A nonempty exec list requires +a nonempty first argument; later arguments can be empty strings. Shell text must +be nonempty. No argument or shell text can contain NUL bytes. Normal commands, +readiness commands, and host commands still reject empty exec lists. Entrypoint +intent is retained independently of the normal workload command. + +`shutdown` contains optional `signal` and `grace`, with at least one field required. +Signal is a canonical named Linux signal from the generated `ShutdownSignal` +enum, including the 31 ordinary names from `SIGHUP` through `SIGSYS`. Numeric +signals, prefixless names, aliases (`SIGIOT`, `SIGCLD`, `SIGPOLL`, `SIGUNUSED`), +and realtime syntax refuse. Grace uses the existing positive integer duration +parser for `ms`, `s`, `m`, or `h`, normalized to milliseconds within the supported +u32 millisecond range. Authored grace is not capped by a backend's execution +timeout; a backend must preserve or explicitly refuse unsupported intent during +admission. The plan does not prove that a signal can be delivered by a runtime. + +`restart` is a tagged object with `kind: "no"`, `"always"`, `"unless-stopped"`, +or `"on-failure"`. Only `on-failure` may include `max_retries`, a positive u32 +integer; omission is retained as symbolic intent. Jobs reject perpetual `always` +and `unless-stopped` policies even when inactive. Jobs permit `no` and +`on-failure` as authored intent, without claiming that a backend executes job +retries. The generated schema includes this job restriction and entrypoint +clearing distinction. Null, unknown fields, tuple forms, ambiguous tags, and +invalid scalar types refuse with redacted diagnostics. Compilation performs no +entrypoint execution, init launch, signal delivery, or restart supervision. diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json index baf3787ee..7648bc24b 100644 --- a/packages/config-compiler/generated/hack.project.schema.json +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -172,6 +172,45 @@ } ] }, + "Entrypoint": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "exec": { + "items": { + "type": "string" + }, + "prefixItems": [ + { + "minLength": 1, + "type": "string" + } + ], + "type": "array" + } + }, + "required": [ + "exec" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "shell": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "shell" + ], + "type": "object" + } + ], + "description": "Unlike a normal command, an explicitly empty exec list clears the image entrypoint." + }, "EnvironmentSelection": { "additionalProperties": false, "properties": { @@ -715,6 +754,69 @@ } ] }, + "Restart": { + "description": "Retry count only applies to failure-triggered restart. Jobs cannot request perpetual restart.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "no", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "always", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unless-stopped", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "on-failure", + "type": "string" + }, + "max_retries": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, "RouteAlias": { "anyOf": [ { @@ -779,6 +881,58 @@ ], "type": "string" }, + "Shutdown": { + "additionalProperties": false, + "description": "Omitted fields preserve image/backend defaults; at least one authored field is required.", + "minProperties": 1, + "properties": { + "grace": { + "pattern": "^[0-9]*[1-9][0-9]*(?:ms|s|m|h)$", + "type": "string" + }, + "signal": { + "$ref": "#/$defs/ShutdownSignal" + } + }, + "type": "object" + }, + "ShutdownSignal": { + "description": "Portable signal spelling; numeric IDs and backend aliases are intentionally not accepted.", + "enum": [ + "SIGHUP", + "SIGTERM", + "SIGINT", + "SIGQUIT", + "SIGILL", + "SIGTRAP", + "SIGABRT", + "SIGBUS", + "SIGFPE", + "SIGKILL", + "SIGUSR1", + "SIGSEGV", + "SIGUSR2", + "SIGPIPE", + "SIGALRM", + "SIGSTKFLT", + "SIGCHLD", + "SIGCONT", + "SIGSTOP", + "SIGTSTP", + "SIGTTIN", + "SIGTTOU", + "SIGURG", + "SIGXCPU", + "SIGXFSZ", + "SIGVTALRM", + "SIGPROF", + "SIGWINCH", + "SIGIO", + "SIGPWR", + "SIGSYS" + ], + "type": "string" + }, "Source": { "additionalProperties": false, "properties": { @@ -869,6 +1023,9 @@ }, "type": "array" }, + "entrypoint": { + "$ref": "#/$defs/Entrypoint" + }, "environment": { "additionalProperties": { "$ref": "#/$defs/EnvironmentValue" @@ -879,6 +1036,9 @@ "image": { "type": "string" }, + "init": { + "type": "boolean" + }, "mounts": { "default": [], "items": { @@ -896,6 +1056,12 @@ "readiness": { "$ref": "#/$defs/Readiness" }, + "restart": { + "$ref": "#/$defs/Restart" + }, + "shutdown": { + "$ref": "#/$defs/Shutdown" + }, "working_directory": { "type": "string" } @@ -942,7 +1108,25 @@ }, "jobs": { "additionalProperties": { - "$ref": "#/$defs/Workload" + "allOf": [ + { + "$ref": "#/$defs/Workload" + }, + { + "properties": { + "restart": { + "properties": { + "kind": { + "enum": [ + "no", + "on-failure" + ] + } + } + } + } + } + ] }, "default": {}, "type": "object" diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index b9aa3ab1b..ddddeb638 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -43,6 +43,10 @@ export type Source = { root?: string, mode?: SourceMode, }; export type EnvironmentSelection = { default_overlay?: string, }; export type Build = { context: string, dockerfile?: string, target?: string, }; export type Command = { exec: Array, } | { shell: string, }; +export type Entrypoint = { exec: Array, } | { shell: string, }; +export type ShutdownSignal = "SIGHUP" | "SIGTERM" | "SIGINT" | "SIGQUIT" | "SIGILL" | "SIGTRAP" | "SIGABRT" | "SIGBUS" | "SIGFPE" | "SIGKILL" | "SIGUSR1" | "SIGSEGV" | "SIGUSR2" | "SIGPIPE" | "SIGALRM" | "SIGSTKFLT" | "SIGCHLD" | "SIGCONT" | "SIGSTOP" | "SIGTSTP" | "SIGTTIN" | "SIGTTOU" | "SIGURG" | "SIGXCPU" | "SIGXFSZ" | "SIGVTALRM" | "SIGPROF" | "SIGWINCH" | "SIGIO" | "SIGPWR" | "SIGSYS"; +export type Shutdown = { signal?: ShutdownSignal, grace?: string, }; +export type Restart = { "kind": "no", } | { "kind": "always", } | { "kind": "unless-stopped", } | { "kind": "on-failure", max_retries?: number, }; export type True = true; export type EnvironmentValue = { literal: string, } | { default: string, } | { env_ref: string, } | { unset: True, } | { endpoint: EndpointReference, }; export type StorageKind = "persistent"; @@ -54,7 +58,7 @@ export type ServiceCondition = "started" | "ready"; export type JobCondition = "completed"; export type Dependency = { service: string, condition: ServiceCondition, } | { job: string, condition: JobCondition, }; export type Readiness = { "kind": "exec", command: Command, interval: string, timeout: string, retries: number, } | { "kind": "http", port: number, path: string, interval: string, timeout: string, retries: number, } | { "kind": "tcp", port: number, interval: string, timeout: string, retries: number, }; -export type Workload = { image?: string, build?: Build, command?: Command, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; +export type Workload = { image?: string, build?: Build, command?: Command, entrypoint?: Entrypoint, init?: boolean, shutdown?: Shutdown, restart?: Restart, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, host_bindings?: { [key in string]: HostBindingTarget }, }; export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, host_bindings?: { [key in string]: HostBindingTarget }, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; export type Diagnostic = { code: string, message: string, pointer: string, line: number, column: number, }; diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 3c37ef085..90103d778 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -5,6 +5,7 @@ pub mod host; mod json; pub mod local; pub mod model; +pub mod process; pub mod routing; mod shape; mod validate; @@ -104,6 +105,15 @@ fn diagnostic_message(code: &str) -> &'static str { "invalid_command" => { "Use a nonempty exec argument list or explicit shell command without NUL bytes." } + "invalid_entrypoint" => { + "Use an exec entrypoint or a nonempty explicit shell entrypoint without NUL bytes." + } + "invalid_shutdown" => { + "Shutdown intent requires at least one supported signal or grace duration." + } + "invalid_restart" => { + "Use a supported restart policy and positive failure retry count; jobs cannot restart perpetually." + } "invalid_environment_key" => "Use a valid environment variable name.", "invalid_environment_value" => "Environment values cannot contain NUL bytes.", "unknown_storage" => "The mount must reference declared storage.", @@ -264,6 +274,19 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { schema["$defs"]["HostSingleton"]["properties"]["ports"]["items"]["minimum"] = serde_json::json!(1); schema["properties"]["host_bindings"]["propertyNames"] = endpoint::binding_name_schema(); + schema["properties"]["jobs"]["additionalProperties"] = serde_json::json!({ + "allOf": [ + {"$ref":"#/$defs/Workload"}, + {"properties":{"restart":{"properties":{"kind":{"enum":["no","on-failure"]}}}}} + ] + }); + if let Some(variants) = schema["$defs"]["Entrypoint"]["anyOf"].as_array_mut() { + for variant in variants { + if let Some(exec) = variant["properties"].get_mut("exec") { + exec["prefixItems"] = serde_json::json!([{"type":"string","minLength":1}]); + } + } + } let schema = serde_json::to_string_pretty(&schema)? + "\n"; let cfg = ts_rs::Config::default(); let declarations = [ @@ -311,6 +334,10 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { EnvironmentSelection::decl(&cfg), Build::decl(&cfg), Command::decl(&cfg), + process::Entrypoint::decl(&cfg), + process::ShutdownSignal::decl(&cfg), + process::Shutdown::decl(&cfg), + process::Restart::decl(&cfg), True::decl(&cfg), EnvironmentValue::decl(&cfg), StorageKind::decl(&cfg), @@ -351,5 +378,5 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { } pub fn protocol() -> Value { - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1}) } diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs index 8567f056e..847806f29 100644 --- a/packages/config-compiler/src/model.rs +++ b/packages/config-compiler/src/model.rs @@ -169,6 +169,38 @@ pub struct Workload { deserialize_with = "present", skip_serializing_if = "Option::is_none" )] + #[schemars(with = "crate::process::Entrypoint")] + #[ts(optional, type = "Entrypoint")] + pub entrypoint: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + #[ts(optional, type = "boolean")] + pub init: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::process::Shutdown")] + #[ts(optional, type = "Shutdown")] + pub shutdown: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::process::Restart")] + #[ts(optional, type = "Restart")] + pub restart: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] #[schemars(with = "String")] #[ts(optional, type = "string")] pub working_directory: Option, diff --git a/packages/config-compiler/src/process.rs b/packages/config-compiler/src/process.rs new file mode 100644 index 000000000..c170b63f5 --- /dev/null +++ b/packages/config-compiler/src/process.rs @@ -0,0 +1,178 @@ +//! Authored workload process intent only. No signal delivery, init process or restart execution occurs here. +use crate::{Diagnostic, json::child, model::Workload, validate}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Unlike a normal command, an explicitly empty exec list clears the image entrypoint. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum Entrypoint { + Exec { + exec: Vec, + }, + Shell { + #[schemars(length(min = 1))] + shell: String, + }, +} + +/// Portable signal spelling; numeric IDs and backend aliases are intentionally not accepted. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub enum ShutdownSignal { + #[serde(rename = "SIGHUP")] + Hup, + #[serde(rename = "SIGTERM")] + Term, + #[serde(rename = "SIGINT")] + Int, + #[serde(rename = "SIGQUIT")] + Quit, + #[serde(rename = "SIGILL")] + Ill, + #[serde(rename = "SIGTRAP")] + Trap, + #[serde(rename = "SIGABRT")] + Abrt, + #[serde(rename = "SIGBUS")] + Bus, + #[serde(rename = "SIGFPE")] + Fpe, + #[serde(rename = "SIGKILL")] + Kill, + #[serde(rename = "SIGUSR1")] + Usr1, + #[serde(rename = "SIGSEGV")] + Segv, + #[serde(rename = "SIGUSR2")] + Usr2, + #[serde(rename = "SIGPIPE")] + Pipe, + #[serde(rename = "SIGALRM")] + Alrm, + #[serde(rename = "SIGSTKFLT")] + Stkflt, + #[serde(rename = "SIGCHLD")] + Chld, + #[serde(rename = "SIGCONT")] + Cont, + #[serde(rename = "SIGSTOP")] + Stop, + #[serde(rename = "SIGTSTP")] + Tstp, + #[serde(rename = "SIGTTIN")] + Ttin, + #[serde(rename = "SIGTTOU")] + Ttou, + #[serde(rename = "SIGURG")] + Urg, + #[serde(rename = "SIGXCPU")] + Xcpu, + #[serde(rename = "SIGXFSZ")] + Xfsz, + #[serde(rename = "SIGVTALRM")] + Vtalrm, + #[serde(rename = "SIGPROF")] + Prof, + #[serde(rename = "SIGWINCH")] + Winch, + #[serde(rename = "SIGIO")] + Io, + #[serde(rename = "SIGPWR")] + Pwr, + #[serde(rename = "SIGSYS")] + Sys, +} + +/// Omitted fields preserve image/backend defaults; at least one authored field is required. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[schemars(extend("minProperties" = 1))] +pub struct Shutdown { + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "ShutdownSignal")] + #[ts(optional, type = "ShutdownSignal")] + pub signal: Option, + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String", regex(pattern = "^[0-9]*[1-9][0-9]*(?:ms|s|m|h)$"))] + #[ts(optional, type = "string")] + pub grace: Option, +} + +/// Retry count only applies to failure-triggered restart. Jobs cannot request perpetual restart. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Restart { + No {}, + Always {}, + UnlessStopped {}, + OnFailure { + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "u32", range(min = 1, max = 4294967295_u64))] + #[ts(optional, type = "number")] + max_retries: Option, + }, +} + +pub(crate) fn normalize( + workload: &mut Workload, + job: bool, + pointer: &str, + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + if let Some(entrypoint) = &workload.entrypoint { + let valid = match entrypoint { + Entrypoint::Exec { exec } => { + exec.first().is_none_or(|first| !first.is_empty()) + && exec.iter().all(|argument| !argument.contains('\0')) + } + Entrypoint::Shell { shell } => !shell.is_empty() && !shell.contains('\0'), + }; + if !valid { + return Err(at("invalid_entrypoint", &child(pointer, "entrypoint"))); + } + } + if let Some(shutdown) = &mut workload.shutdown { + if shutdown.signal.is_none() && shutdown.grace.is_none() { + return Err(at("invalid_shutdown", &child(pointer, "shutdown"))); + } + if let Some(grace) = &mut shutdown.grace { + *grace = validate::duration(grace) + .ok_or_else(|| at("invalid_duration", &format!("{pointer}/shutdown/grace")))?; + } + } + if let Some(restart) = &workload.restart { + if job + && matches!( + restart, + Restart::Always { .. } | Restart::UnlessStopped { .. } + ) + { + return Err(at("invalid_restart", &child(pointer, "restart"))); + } + if matches!( + restart, + Restart::OnFailure { + max_retries: Some(0) + } + ) { + return Err(at( + "invalid_restart", + &format!("{pointer}/restart/max_retries"), + )); + } + } + Ok(()) +} diff --git a/packages/config-compiler/src/shape.rs b/packages/config-compiler/src/shape.rs index 5cc2ad037..97b8c38a9 100644 --- a/packages/config-compiler/src/shape.rs +++ b/packages/config-compiler/src/shape.rs @@ -51,7 +51,14 @@ pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { for key in workloads.keys() { let pointer = json::child(&root, key); object(document, &pointer)?; - for field in ["build", "command", "readiness"] { + for field in [ + "build", + "command", + "readiness", + "entrypoint", + "shutdown", + "restart", + ] { optional_object(document, &json::child(&pointer, field))?; } optional_object(document, &format!("{pointer}/readiness/command"))?; diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs index 482982e9a..0bfbaa428 100644 --- a/packages/config-compiler/src/validate.rs +++ b/packages/config-compiler/src/validate.rs @@ -71,7 +71,7 @@ fn absolute(value: &str) -> Option { .join("/") )) } -fn duration(value: &str) -> Option { +pub(crate) fn duration(value: &str) -> Option { let (digits, factor) = if let Some(s) = value.strip_suffix("ms") { (s, 1) } else if let Some(s) = value.strip_suffix('s') { @@ -146,6 +146,7 @@ pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result Value { + json!({"schema_version":1,"name":"example","profiles":["dev"],"services":{"web":{"image":"web:1"}},"jobs":{"check":{"image":"check:1"}}}) +} +fn compiled(project: &Value, profiles: &[String]) -> Value { + serde_json::to_value(compile(project.to_string().as_bytes(), profiles)).unwrap() +} +fn refused(project: &Value, code: &str) -> Value { + let result = compiled(project, &[]); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["diagnostics"][0]["code"], code, "{result}"); + result +} + +#[test] +fn absent_process_fields_preserve_plan_and_resolution_generation() { + let project = project(); + let compiled = compiled(&project, &[]); + // Measured with the matching pre-process candidate tree 3926d78d, preserved as an absent-field compatibility fixture. + assert_eq!( + compiled["semantic_hash"], + "ee92e08522a5abe049c81847d8393415197f8c4adb75399360d61b3808f7a7a4" + ); + for workload in ["web", "check"] { + let namespace = if workload == "web" { + "services" + } else { + "jobs" + }; + for field in ["entrypoint", "init", "shutdown", "restart"] { + assert!(compiled["plan"][namespace][workload].get(field).is_none()); + } + } + let request = json!({"request_version":1,"project":project.to_string()}); + let resolved = serde_json::to_value(resolve(request.to_string().as_bytes(), &[])).unwrap(); + assert_eq!( + resolved["local_resolution"]["resolution_hash"], + "6bf9f7b546ad21b8ba49eeb2e05844130d7272e0b45a9360ab48bca5d16a03fb" + ); + let mut request = request; + request["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"check":{}},"inactive_scopes":[]}); + let planned = serde_json::to_value(plan(request.to_string().as_bytes(), &[])).unwrap(); + for result in [&resolved, &planned] { + assert_eq!(result["plan"], compiled["plan"]); + assert_eq!(result["semantic_hash"], compiled["semantic_hash"]); + } + assert_eq!(resolved["local_resolution"], planned["local_resolution"]); + assert_eq!(protocol()["process_plan_version"], 1); +} + +#[test] +fn explicit_entrypoint_clear_preserves_normal_command_and_presence() { + let mut project = project(); + project["services"]["web"]["command"] = json!({"exec":["server","","--foreground"]}); + let omitted = compiled(&project, &[]); + for value in [ + json!({"exec":[]}), + json!({"exec":["entrypoint","","--flag"]}), + json!({"shell":"exec service \"$@\""}), + ] { + project["services"]["web"]["entrypoint"] = value.clone(); + let result = compiled(&project, &[]); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["plan"]["services"]["web"]["entrypoint"], value); + assert_eq!( + result["plan"]["services"]["web"]["command"], + omitted["plan"]["services"]["web"]["command"] + ); + assert_ne!(result["semantic_hash"], omitted["semantic_hash"]); + } + project["services"]["web"]["command"] = json!({"exec":[]}); + refused(&project, "invalid_command"); +} + +#[test] +fn invalid_entrypoints_are_redacted_without_relaxing_host_or_readiness_commands() { + for value in [ + json!({"exec":[""]}), + json!({"exec":["exec","private-sentinel\0"]}), + json!({"shell":""}), + json!({"shell":"private-sentinel\0"}), + ] { + let mut project = project(); + project["services"]["web"]["entrypoint"] = value; + let result = refused(&project, "invalid_entrypoint"); + assert_eq!( + result["diagnostics"][0]["pointer"], + "/services/web/entrypoint" + ); + assert!(!result.to_string().contains("private-sentinel")); + } + let mut project = project(); + project["host"] = json!({"processes":{"watch":{"command":{"exec":[]}}}}); + refused(&project, "invalid_command"); + project.as_object_mut().unwrap().remove("host"); + project["services"]["web"]["readiness"] = + json!({"kind":"exec","command":{"exec":[]},"interval":"1s","timeout":"1s","retries":1}); + refused(&project, "invalid_command"); +} + +#[test] +fn false_init_is_distinct_from_omission_and_true() { + let mut project = project(); + let omitted = compiled(&project, &[]); + project["services"]["web"]["init"] = json!(false); + let disabled = compiled(&project, &[]); + assert_eq!(disabled["plan"]["services"]["web"]["init"], false); + assert_ne!(disabled["semantic_hash"], omitted["semantic_hash"]); + project["services"]["web"]["init"] = json!(true); + let enabled = compiled(&project, &[]); + assert_eq!(enabled["plan"]["services"]["web"]["init"], true); + assert_ne!(enabled["semantic_hash"], disabled["semantic_hash"]); +} + +#[test] +fn shutdown_signal_and_grace_are_independent_authored_intents() { + for signal in [ + "SIGHUP", + "SIGINT", + "SIGQUIT", + "SIGILL", + "SIGTRAP", + "SIGABRT", + "SIGBUS", + "SIGFPE", + "SIGKILL", + "SIGUSR1", + "SIGSEGV", + "SIGUSR2", + "SIGPIPE", + "SIGALRM", + "SIGTERM", + "SIGSTKFLT", + "SIGCHLD", + "SIGCONT", + "SIGSTOP", + "SIGTSTP", + "SIGTTIN", + "SIGTTOU", + "SIGURG", + "SIGXCPU", + "SIGXFSZ", + "SIGVTALRM", + "SIGPROF", + "SIGWINCH", + "SIGIO", + "SIGPWR", + "SIGSYS", + ] { + let mut project = project(); + project["services"]["web"]["shutdown"] = json!({"signal":signal}); + assert_eq!( + compiled(&project, &[])["plan"]["services"]["web"]["shutdown"], + json!({"signal":signal}) + ); + project["services"]["web"]["shutdown"]["grace"] = json!("45s"); + assert_eq!( + compiled(&project, &[])["plan"]["services"]["web"]["shutdown"], + json!({"signal":signal,"grace":"45000ms"}) + ); + } + let mut project = project(); + project["jobs"]["check"]["shutdown"] = json!({"grace":"2m"}); + assert_eq!( + compiled(&project, &[])["plan"]["jobs"]["check"]["shutdown"], + json!({"grace":"120000ms"}) + ); + project["services"]["web"]["shutdown"] = json!({}); + refused(&project, "invalid_shutdown"); +} + +#[test] +fn shutdown_grace_uses_the_existing_positive_duration_parser_without_admission_caps() { + for (authored, normalized) in [ + ("1ms", "1ms"), + ("0001s", "1000ms"), + ("45s", "45000ms"), + ("2m", "120000ms"), + ("1h", "3600000ms"), + ("4294967295ms", "4294967295ms"), + ] { + let mut project = project(); + project["services"]["web"]["shutdown"] = json!({"grace":authored}); + let result = compiled(&project, &[]); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!( + result["plan"]["services"]["web"]["shutdown"]["grace"], + normalized + ); + } + for authored in [ + "", + "0ms", + "0s", + "0m", + "0h", + "1.5s", + "-1s", + "+1s", + "1sec", + " 1s", + "1s ", + "1S", + "4294967296ms", + "4294968s", + "71583m", + "1194h", + "18446744073709551616ms", + "private-sentinel", + ] { + let mut project = project(); + project["services"]["web"]["shutdown"] = json!({"grace":authored}); + let result = refused(&project, "invalid_duration"); + assert_eq!( + result["diagnostics"][0]["pointer"], + "/services/web/shutdown/grace" + ); + assert!(!result.to_string().contains("private-sentinel")); + } + let mut project = project(); + project["services"]["web"]["shutdown"] = json!({"grace":"1s"}); + let first = compiled(&project, &[]); + project["services"]["web"]["shutdown"]["grace"] = json!("1000ms"); + assert_eq!( + compiled(&project, &[])["semantic_hash"], + first["semantic_hash"] + ); +} + +#[test] +fn restart_tags_preserve_no_restart_and_failure_retry_presence() { + for value in [ + json!({"kind":"no"}), + json!({"kind":"always"}), + json!({"kind":"unless-stopped"}), + json!({"kind":"on-failure"}), + json!({"kind":"on-failure","max_retries":1}), + json!({"kind":"on-failure","max_retries":4294967295_u32}), + ] { + let mut project = project(); + project["services"]["web"]["restart"] = value.clone(); + let result = compiled(&project, &[]); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["plan"]["services"]["web"]["restart"], value); + } + let mut project = project(); + let omitted = compiled(&project, &[]); + project["services"]["web"]["restart"] = json!({"kind":"no"}); + assert_ne!( + compiled(&project, &[])["semantic_hash"], + omitted["semantic_hash"] + ); + project["services"]["web"]["restart"] = json!({"kind":"on-failure","max_retries":0}); + let result = refused(&project, "invalid_restart"); + assert_eq!( + result["diagnostics"][0]["pointer"], + "/services/web/restart/max_retries" + ); +} + +#[test] +fn perpetual_job_restarts_refuse_before_profile_filtering() { + for kind in ["always", "unless-stopped"] { + let mut project = project(); + project["jobs"]["check"]["profiles"] = json!(["dev"]); + project["jobs"]["check"]["restart"] = json!({"kind":kind}); + refused(&project, "invalid_restart"); + assert_eq!(compiled(&project, &["dev".into()])["ok"], false); + } + for value in [ + json!({"kind":"no"}), + json!({"kind":"on-failure"}), + json!({"kind":"on-failure","max_retries":3}), + ] { + let mut project = project(); + project["jobs"]["check"]["restart"] = value.clone(); + let result = compiled(&project, &[]); + assert_eq!(result["ok"], true); + assert_eq!(result["plan"]["jobs"]["check"]["restart"], value); + } + for (field, value, code) in [ + ("entrypoint", json!({"exec":[""]}), "invalid_entrypoint"), + ("shutdown", json!({}), "invalid_shutdown"), + ( + "restart", + json!({"kind":"on-failure","max_retries":0}), + "invalid_restart", + ), + ] { + let mut project = project(); + project["services"]["web"]["profiles"] = json!(["dev"]); + project["services"]["web"][field] = value; + refused(&project, code); + } +} + +#[test] +fn process_fields_are_strict_objects_and_do_not_leak_invalid_values() { + for (field, invalid) in [ + ( + "entrypoint", + vec![ + json!(null), + json!([]), + json!([[]]), + json!("private-sentinel"), + json!({}), + json!({"exec":[],"shell":"private-sentinel"}), + json!({"exec":[],"secret":"private-sentinel"}), + ], + ), + ( + "init", + vec![json!(null), json!(0), json!("false"), json!([]), json!({})], + ), + ( + "shutdown", + vec![ + json!(null), + json!([]), + json!(["SIGTERM", "1s"]), + json!({"signal":null}), + json!({"signal":"TERM"}), + json!({"signal":15}), + json!({"signal":"sigterm"}), + json!({"signal":"SIGUNKNOWN"}), + json!({"signal":"SIGRTMIN+1"}), + json!({"grace":null}), + json!({"grace":1}), + json!({"signal":"SIGTERM","command":"private-sentinel"}), + ], + ), + ( + "restart", + vec![ + json!(null), + json!([]), + json!(["always"]), + json!("no"), + json!({}), + json!({"kind":"unless_stopped"}), + json!({"kind":"no","max_retries":1}), + json!({"kind":"always","max_retries":1}), + json!({"kind":"on-failure","max_retries":null}), + json!({"kind":"on-failure","max_retries":-1}), + json!({"kind":"on-failure","max_retries":1.5}), + json!({"kind":"on-failure","max_retries":4294967296_u64}), + json!({"kind":"on-failure","max_retries":"1"}), + json!({"kind":"no","secret":"private-sentinel"}), + ], + ), + ] { + for value in invalid { + let mut project = project(); + project["services"]["web"][field] = value; + let result = compiled(&project, &[]); + assert_eq!(result["ok"], false, "{field}: {result}"); + assert!(!result.to_string().contains("private-sentinel")); + } + } +} + +#[test] +fn generated_process_contracts_keep_closed_tags_and_presence() { + let (schema, dto) = artifacts().unwrap(); + let schema: Value = serde_json::from_str(&schema).unwrap(); + assert_eq!(schema["$defs"]["Shutdown"]["additionalProperties"], false); + assert_eq!(schema["$defs"]["Shutdown"]["minProperties"], 1); + assert_eq!( + schema["$defs"]["Shutdown"]["properties"]["grace"]["type"], + "string" + ); + assert_eq!( + schema["$defs"]["Workload"]["properties"]["init"]["type"], + "boolean" + ); + assert!( + !schema["$defs"]["Workload"]["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("init"))) + ); + assert!(dto.contains("entrypoint?: Entrypoint")); + assert!(dto.contains("init?: boolean")); + assert!(dto.contains("shutdown?: Shutdown")); + assert!(dto.contains("restart?: Restart")); + assert!(dto.contains("max_retries?: number")); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index c1a5c2c25..d08e8eea4 100644 --- a/packages/config-compiler/tests/protocol.rs +++ b/packages/config-compiler/tests/protocol.rs @@ -21,7 +21,7 @@ fn handshake_and_compile_need_no_environment_or_host_tools() { let protocol: Value = serde_json::from_slice(&handshake.stdout).unwrap(); assert_eq!( protocol, - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts new file mode 100644 index 000000000..1fbfb3458 --- /dev/null +++ b/scripts/check-native-process-plan-cli.ts @@ -0,0 +1,397 @@ +#!/usr/bin/env bun +import { + copyFile, + mkdir, + mkdtemp, + readdir, + realpath, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Qualify pure process planning through a relocated bundle and real Git worktree. */ +const root = resolve(import.meta.dir, ".."); +const directory = await realpath( + await mkdtemp(join(tmpdir(), "hack-process-cli-")) +); +const bundle = join(directory, "bundle"); +const home = join(directory, "home"); +const primary = join(directory, "primary"); +const checkout = join(directory, "checkout"); +const marker = join(directory, "hook-must-not-run"); +const canary = "private-synthetic-process-value"; +const plain = { image: "example/web:1" }; +const policy = { + ...plain, + command: { exec: ["web", "--port", "3000"] }, + entrypoint: { exec: [] }, + init: false, + shutdown: { signal: "SIGHUP", grace: "45s" }, + restart: { kind: "on-failure", max_retries: 3 }, +}; +const project = { + schema_version: 1, + name: "process-fixture", + profiles: ["optional"], + services: { web: policy, admin: { ...plain, profiles: ["optional"] } }, + jobs: { seed: { ...plain, restart: { kind: "on-failure", max_retries: 2 } } }, + routes: { + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }, + host: { + up: { + before: [ + { name: "prepare", command: { exec: ["/usr/bin/touch", marker] } }, + ], + }, + }, +}; +let checks = 0; +try { + await Promise.all([ + mkdir(bundle), + mkdir(home), + mkdir(join(primary, ".hack"), { recursive: true }), + ]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + await authored(primary, { + schema_version: 1, + name: "process-fixture", + services: { web: plain }, + }); + const absent = workload( + await success(["config", "validate", "--json"], primary), + "web" + ); + assert( + ["entrypoint", "init", "shutdown", "restart"].every( + (key) => !Object.hasOwn(absent, key) + ), + "omission introduces no process defaults" + ); + await authored(primary, project); + await metadata(primary); + const initial = await success(["config", "plan", "--json"], primary); + const web = workload(initial, "web"); + const entrypointArgs = record(web.entrypoint).exec; + assert( + Array.isArray(entrypointArgs) && entrypointArgs.length === 0, + "empty entrypoint explicitly clears image entrypoint" + ); + assert(web.init === false, "explicit false survives planning"); + assert( + record(web.shutdown).signal === "SIGHUP" && + record(web.shutdown).grace === "45000ms", + "shutdown normalizes without a backend grace cap" + ); + assert( + record(web.restart).kind === "on-failure" && + record(web.restart).max_retries === 3, + "restart retains its typed retry bound" + ); + assert( + record(workload(initial, "seed", "jobs").restart).max_retries === 2, + "job retry intent survives" + ); + await authored(primary, { + ...project, + services: { + ...project.services, + web: { ...policy, shutdown: { signal: "SIGHUP", grace: "45000ms" } }, + }, + }); + assert( + (await success(["config", "validate", "--json"], primary)).semantic_hash === + initial.semantic_hash, + "equivalent durations have the same semantic hash" + ); + await authored(primary, project); + await Bun.write( + join(primary, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, routes: { domain: "custom.example" } }) + ); + await git(["init", "-b", "main"], primary); + await git(["add", ".hack/hack.project.json"], primary); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "fixture", + ], + primary + ); + await git(["worktree", "add", "-b", "feature/process", checkout], primary); + const inherited = await success(["config", "plan", "--json"], checkout); + assert( + JSON.stringify(workload(inherited, "web")) === JSON.stringify(web), + "real linked worktree preserves workload process policy" + ); + assert( + inherited.semantic_hash === initial.semantic_hash, + "local domain and branch isolation do not change authored semantics" + ); + const selected = await success( + ["config", "plan", "--profile", "optional", "--json"], + checkout + ); + const admin = workload(selected, "admin"); + assert( + !(Object.hasOwn(admin, "restart") || Object.hasOwn(admin, "entrypoint")), + "selected workloads retain omitted image defaults" + ); + for (const restart of [{ kind: "always" }, { kind: "unless-stopped" }]) { + await authored(checkout, { + ...project, + jobs: { seed: { ...plain, profiles: ["optional"], restart } }, + }); + await refuses( + checkout, + "successful jobs reject service restart policy even when inactive" + ); + } + for (const bad of [ + { entrypoint: null }, + { entrypoint: { exec: ["", "arg"] } }, + { entrypoint: { exec: [], shell: canary } }, + { command: { exec: [] } }, + { init: null }, + { init: "false" }, + { shutdown: {} }, + { shutdown: { signal: "SIGPOLL" } }, + { shutdown: { signal: "TERM" } }, + { shutdown: { signal: 15 } }, + { shutdown: { grace: "0s" } }, + { shutdown: { grace: "1.5s" } }, + { shutdown: { grace: "4294967296ms" } }, + { shutdown: { grace: null } }, + { restart: { kind: "on-failure", max_retries: 0 } }, + { restart: { kind: "on-failure", max_retries: null } }, + { restart: { kind: "always", max_retries: 1 } }, + { restart: { kind: canary } }, + ]) { + await authored(checkout, { + ...project, + services: { + ...project.services, + admin: { ...plain, profiles: ["optional"], ...bad }, + }, + }); + await refuses( + checkout, + "invalid inactive process policy refuses before pruning" + ); + } + await authored(checkout, { + ...project, + services: { + ...project.services, + web: { + ...policy, + entrypoint: { shell: "exec web" }, + init: true, + shutdown: { signal: "SIGSYS", grace: "4294967295ms" }, + restart: { kind: "unless-stopped" }, + }, + }, + }); + const maximum = workload( + await success(["config", "validate", "--json"], checkout), + "web" + ); + assert( + record(maximum.entrypoint).shell === "exec web" && + maximum.init === true && + record(maximum.shutdown).signal === "SIGSYS", + "shell entrypoint and canonical Linux signal survive" + ); + assert( + record(maximum.shutdown).grace === "4294967295ms", + "compiler representation bound is accepted" + ); + await authored(checkout, project); + await Bun.write( + join(checkout, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + services: { web: { entrypoint: { shell: canary } } }, + }) + ); + await refuses(checkout, "local settings cannot inject process definitions"); + await rm(join(checkout, ".hack/hack.local.json")); + await Bun.write( + join(checkout, ".hack/hack.env.default.yaml"), + `broken [${canary}` + ); + await success(["config", "validate", "--json"], checkout); + await refuses(checkout, "metadata planning refuses poisoned managed data", [ + "config", + "plan", + "--json", + ]); + await authored(checkout, { + ...project, + services: { web: { ...plain, shutdown: { signal: canary } } }, + }); + const invalid = await refuses( + checkout, + "authored process validation precedes managed metadata", + ["config", "plan", "--json"] + ); + assert( + Array.isArray(invalid.diagnostics) && + record(invalid.diagnostics[0]).code === "invalid_shape", + "authored diagnostic remains authoritative" + ); + await authored(checkout, project); + await rm(join(checkout, ".hack/hack.env.default.yaml")); + await success( + [ + "config", + "validate", + "--file", + join(checkout, ".hack/hack.project.json"), + "--json", + ], + checkout + ); + const runtime = await invoke(["up"], checkout, {}, false); + assert( + runtime.exit !== 0 && + (runtime.stdout + runtime.stderr).includes( + "E_NATIVE_PROJECT_UNSUPPORTED" + ), + "native execution remains fenced" + ); + assert( + !(await Bun.file(marker).exists()), + "planning runs no preparation hook" + ); + for (const cwd of [primary, checkout]) { + assert( + !(await readdir(join(cwd, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), + "planning creates no runtime state" + ); + } + assert( + (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + "planning creates no registry or runtime home state" + ); + process.stdout.write( + `Relocated native process planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} + +async function authored(cwd: string, value: unknown) { + await Bun.write(join(cwd, ".hack/hack.project.json"), JSON.stringify(value)); +} +async function metadata(cwd: string) { + await Bun.write( + join(cwd, ".hack/hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: {} }, + }) + ); +} +function record(value: unknown): Record { + assert(isRecord(value), "expected report object"); + return value; +} +function workload( + value: Record, + name: string, + kind = "services" +) { + return record(record(record(value.plan)[kind])[name]); +} +async function refuses( + cwd: string, + message: string, + args = ["config", "validate", "--json"] +) { + const result = await invoke(args, cwd); + assert(result.exit === 1 && result.value.ok === false, message); + return result.value; +} +async function success( + args: readonly string[], + cwd: string, + extra: Readonly> = {} +) { + const result = await invoke(args, cwd, extra); + assert( + result.exit === 0 && result.value.ok === true, + `successful process command (${args.join(" ")}): ${result.stdout}${result.stderr}` + ); + return result.value; +} +async function invoke( + args: readonly string[], + cwd: string, + extra: Readonly> = {}, + json = true +) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + ...extra, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill(), 15_000); + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert( + !(stdout + stderr).includes(canary), + "synthetic managed value is redacted" + ); + const value: unknown = json ? JSON.parse(stdout) : {}; + return { exit, stdout, stderr, value: record(value) }; + } finally { + clearTimeout(timer); + } +} +async function git(args: readonly string[], cwd: string) { + const child = Bun.spawn(["/usr/bin/git", ...args], { + cwd, + env: { PATH: "/usr/bin:/bin", HOME: home }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert(exit === 0, `isolated Git fixture ${args[0]}: ${stdout}${stderr}`); +} +function assert(condition: unknown, message: string): asserts condition { + checks += 1; + if (!condition) { + throw new Error(message); + } +} diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index ffb435f82..d9a3643fd 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -22,6 +22,12 @@ import { nativeHostSelectionMatches, parseNativeHostTargets, } from "./native-host-plan-protocol.ts"; +import { + authoredProcessPlanningRequired, + nativeProcessPlanIsValid, + nativeProcessPlanningRequired, + nativeProcessSourceMatches, +} from "./native-process-plan-protocol.ts"; import { type NativeRoutingResolution, nativeRoutingPlanIsValid, @@ -165,6 +171,7 @@ export async function compileNativeConfig(opts: { readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; readonly requireEndpointPlanning?: boolean; + readonly requireProcessPlanning?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -180,6 +187,9 @@ export async function compileNativeConfig(opts: { requireHostPlanning: opts.requireHostPlanning, requireRoutingPlanning: opts.requireRoutingPlanning, requireEndpointPlanning: opts.requireEndpointPlanning, + requireProcessPlanning: + opts.requireProcessPlanning || + authoredProcessPlanningRequired(opts.input), }); const response = await invokeCompiler({ ...request, @@ -187,6 +197,12 @@ export async function compileNativeConfig(opts: { input: opts.input, }); const result = parseCompileResponse(response); + if (result.ok) { + assertProcessSource({ input: opts.input, result, profiles: opts.profiles }); + if (nativeProcessPlanningRequired(result.plan)) { + await checkProtocol({ ...request, requireProcessPlanning: true }); + } + } if (result.ok && hasRouting(result.plan) && !opts.requireRoutingPlanning) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } @@ -223,6 +239,7 @@ export async function resolveNativeConfig(opts: { readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; readonly requireEndpointPlanning?: boolean; + readonly requireProcessPlanning?: boolean; readonly probeRoutingInputs?: boolean; }): Promise { const plainInput = encodeResolveRequest(opts); @@ -235,6 +252,9 @@ export async function resolveNativeConfig(opts: { requireRoutingPlanning: opts.requireRoutingPlanning || hasRoutingInputs(opts), requireEndpointPlanning: opts.requireEndpointPlanning, + requireProcessPlanning: + opts.requireProcessPlanning || + authoredProcessPlanningRequired(opts.input), }); const routingProbe = opts.probeRoutingInputs === true && capabilities.routingPlanning; @@ -259,6 +279,14 @@ export async function resolveNativeConfig(opts: { if (!parsed.ok) { return parsed; } + assertProcessSource({ + input: opts.input, + result: parsed, + profiles: opts.profiles, + }); + if (nativeProcessPlanningRequired(parsed.plan)) { + await checkProtocol({ ...request, requireProcessPlanning: true }); + } if (parsed.routing_resolution || parsed.routing_inputs_required) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } @@ -355,6 +383,7 @@ export async function planNativeConfig( readonly signal?: AbortSignal; readonly requireRoutingPlanning?: boolean; readonly requireEndpointPlanning?: boolean; + readonly requireProcessPlanning?: boolean; } ): Promise { const metadata = parseNativeEnvMetadata(opts.envMetadata); @@ -378,6 +407,9 @@ export async function planNativeConfig( requireRoutingPlanning: opts.requireRoutingPlanning || hasRoutingInputs(opts), requireEndpointPlanning: opts.requireEndpointPlanning, + requireProcessPlanning: + opts.requireProcessPlanning || + authoredProcessPlanningRequired(opts.input), }); const response = await invokeCompiler({ ...request, @@ -397,6 +429,14 @@ export async function planNativeConfig( if (!parsed.ok) { return parsed; } + assertProcessSource({ + input: opts.input, + result: parsed, + profiles: opts.profiles, + }); + if (nativeProcessPlanningRequired(parsed.plan)) { + await checkProtocol({ ...request, requireProcessPlanning: true }); + } if (parsed.routing_resolution) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } @@ -665,6 +705,7 @@ async function checkProtocol(opts: { readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; readonly requireEndpointPlanning?: boolean; + readonly requireProcessPlanning?: boolean; }): Promise<{ readonly routingPlanning: boolean }> { const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); @@ -678,6 +719,7 @@ async function checkProtocol(opts: { (opts.requireHostPlanning && protocol.host_env_plan_version !== 1) || (opts.requireRoutingPlanning && protocol.routing_plan_version !== 1) || (opts.requireEndpointPlanning && protocol.endpoint_plan_version !== 1) || + (opts.requireProcessPlanning && protocol.process_plan_version !== 1) || (opts.requireLocalResolution && (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { @@ -964,18 +1006,7 @@ function parseCompileValue(opts: { plan: value.plan, declared, }); - if (!nativeRoutingPlanIsValid({ plan: value.plan, declared })) { - throw failure( - "E_COMPILER_RESPONSE", - "Native compiler returned invalid routing declarations." - ); - } - if (!nativeEndpointPlanIsValid({ plan: value.plan, declared })) { - throw failure( - "E_COMPILER_RESPONSE", - "Native compiler returned invalid endpoint declarations." - ); - } + assertPlanDeclarations({ plan: value.plan, declared }); return { transport_version: 1, ok: true, @@ -1000,6 +1031,44 @@ function parseCompileValue(opts: { ); } +function assertPlanDeclarations(opts: { + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; +}): void { + for (const [kind, valid] of [ + ["routing", nativeRoutingPlanIsValid(opts)], + ["endpoint", nativeEndpointPlanIsValid(opts)], + ["process", nativeProcessPlanIsValid(opts)], + ] as const) { + if (!valid) { + throw failure( + "E_COMPILER_RESPONSE", + `Native compiler returned invalid ${kind} declarations.` + ); + } + } +} + +function assertProcessSource(opts: { + readonly input: Uint8Array; + readonly result: Extract; + readonly profiles?: readonly string[]; +}): void { + if ( + !nativeProcessSourceMatches({ + input: opts.input, + plan: opts.result.plan, + declared: opts.result.declared_workloads, + profiles: opts.profiles, + }) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler changed the authored process requirements." + ); + } +} + function hasRouting(plan: Readonly>): boolean { return plan.routes !== undefined || plan.open !== undefined; } diff --git a/src/lib/native-process-plan-protocol.ts b/src/lib/native-process-plan-protocol.ts new file mode 100644 index 000000000..76ad1f95e --- /dev/null +++ b/src/lib/native-process-plan-protocol.ts @@ -0,0 +1,384 @@ +import type { + Entrypoint, + Restart, + Shutdown, + ShutdownSignal, +} from "../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "./guards.ts"; +import type { NativeDeclaredWorkloads } from "./native-env-plan-protocol.ts"; + +const FIELDS = ["entrypoint", "init", "shutdown", "restart"] as const; +const U32_MAX = 4_294_967_295; +const DURATION = /^(\d+)(ms|s|m|h)$/; +const CANONICAL_MILLISECONDS = /^[1-9]\d*ms$/; +const LEADING_ZEROES = /^0+/; +const SIGNALS: Readonly> = { + SIGHUP: true, + SIGINT: true, + SIGQUIT: true, + SIGILL: true, + SIGTRAP: true, + SIGABRT: true, + SIGBUS: true, + SIGFPE: true, + SIGKILL: true, + SIGUSR1: true, + SIGSEGV: true, + SIGUSR2: true, + SIGPIPE: true, + SIGALRM: true, + SIGTERM: true, + SIGSTKFLT: true, + SIGCHLD: true, + SIGCONT: true, + SIGSTOP: true, + SIGTSTP: true, + SIGTTIN: true, + SIGTTOU: true, + SIGURG: true, + SIGXCPU: true, + SIGXFSZ: true, + SIGVTALRM: true, + SIGPROF: true, + SIGWINCH: true, + SIGIO: true, + SIGPWR: true, + SIGSYS: true, +}; +const UNITS = { ms: 1n, s: 1000n, m: 60_000n, h: 3_600_000n } as const; + +function only( + value: Record, + keys: readonly string[] +): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function positiveU32(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 && + value <= U32_MAX + ); +} + +function project(input: Uint8Array): Record | undefined { + try { + const value: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input) + ); + return isRecord(value) ? value : undefined; + } catch { + // Rust owns whole-document validity and duplicate-key diagnostics. + return undefined; + } +} + +function hasProcessFields(value: unknown): boolean { + return isRecord(value) && FIELDS.some((field) => Object.hasOwn(value, field)); +} + +/** Request capability from authored presence, including fields in inactive workloads. */ +export function authoredProcessPlanningRequired(input: Uint8Array): boolean { + const source = project(input); + return source !== undefined && nativeProcessPlanningRequired(source); +} + +/** Inspect intent presence only, preserving absence and image-provided defaults. */ +export function nativeProcessPlanningRequired( + plan: Readonly> +): boolean { + return [plan.services, plan.jobs].some( + (entries) => + isRecord(entries) && Object.values(entries).some(hasProcessFields) + ); +} + +function entrypoint(value: unknown): value is Entrypoint { + if (!isRecord(value)) { + return false; + } + if (Object.hasOwn(value, "exec")) { + return ( + only(value, ["exec"]) && + Array.isArray(value.exec) && + (value.exec.length === 0 || value.exec[0] !== "") && + value.exec.every( + (part) => typeof part === "string" && !part.includes("\0") + ) + ); + } + return ( + only(value, ["shell"]) && + typeof value.shell === "string" && + value.shell.length > 0 && + !value.shell.includes("\0") + ); +} + +function shutdown(value: unknown, normalized: boolean): value is Shutdown { + if ( + !(isRecord(value) && only(value, ["signal", "grace"])) || + Object.keys(value).length === 0 + ) { + return false; + } + if ( + Object.hasOwn(value, "signal") && + !(typeof value.signal === "string" && Object.hasOwn(SIGNALS, value.signal)) + ) { + return false; + } + if (!Object.hasOwn(value, "grace")) { + return true; + } + if (typeof value.grace !== "string") { + return false; + } + const milliseconds = duration(value.grace); + return ( + milliseconds !== undefined && + (!normalized || + (CANONICAL_MILLISECONDS.test(value.grace) && + value.grace === `${milliseconds}ms`)) + ); +} + +function duration(value: string): bigint | undefined { + const match = DURATION.exec(value); + const amount = match?.[1]; + const unit = match?.[2]; + if (!(amount && unit && Object.hasOwn(UNITS, unit))) { + return undefined; + } + const significant = amount.replace(LEADING_ZEROES, ""); + if (significant.length === 0 || significant.length > 10) { + return undefined; + } + const factor = UNITS[unit as keyof typeof UNITS]; + const milliseconds = BigInt(significant) * factor; + return milliseconds > 0n && milliseconds <= BigInt(U32_MAX) + ? milliseconds + : undefined; +} + +function restart(value: unknown, kind: "service" | "job"): value is Restart { + if (!(isRecord(value) && only(value, ["kind", "max_retries"]))) { + return false; + } + if (value.kind === "on-failure") { + return ( + !Object.hasOwn(value, "max_retries") || positiveU32(value.max_retries) + ); + } + if (value.kind === "no") { + return !Object.hasOwn(value, "max_retries"); + } + return ( + kind === "service" && + (value.kind === "always" || value.kind === "unless-stopped") && + !Object.hasOwn(value, "max_retries") + ); +} + +function fieldsValid( + value: Record, + kind: "service" | "job", + normalized: boolean +): boolean { + return ( + (!Object.hasOwn(value, "entrypoint") || entrypoint(value.entrypoint)) && + (!Object.hasOwn(value, "init") || typeof value.init === "boolean") && + (!Object.hasOwn(value, "shutdown") || + shutdown(value.shutdown, normalized)) && + (!Object.hasOwn(value, "restart") || restart(value.restart, kind)) + ); +} + +/** Validate only process-owned normalized fields and their service/job namespace. */ +export function nativeProcessPlanIsValid(opts: { + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; +}): boolean { + if (!nativeProcessPlanningRequired(opts.plan)) { + return true; + } + const seen = new Set(); + for (const [field, kind] of [ + ["services", "service"], + ["jobs", "job"], + ] as const) { + const workloads = opts.plan[field]; + if (!isRecord(workloads)) { + return false; + } + for (const [name, workload] of Object.entries(workloads)) { + if ( + !(isRecord(workload) && fieldsValid(workload, kind, true)) || + seen.has(name) || + !opts.declared || + !Object.hasOwn(opts.declared, name) || + opts.declared[name] !== kind + ) { + return false; + } + seen.add(name); + } + } + return true; +} + +function sameValue(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); + } + return ( + isRecord(left) && + isRecord(right) && + Object.keys(left).length === Object.keys(right).length && + Object.entries(left).every( + ([key, value]) => + Object.hasOwn(right, key) && sameValue(value, right[key]) + ) + ); +} + +function fieldsMatch( + source: Record, + output: Record +): boolean { + for (const field of FIELDS) { + if (Object.hasOwn(source, field) !== Object.hasOwn(output, field)) { + return false; + } + if (field === "shutdown" && isRecord(source.shutdown)) { + const normalized = { ...source.shutdown }; + if (typeof normalized.grace === "string") { + const milliseconds = duration(normalized.grace); + if (milliseconds === undefined) { + return false; + } + normalized.grace = `${milliseconds}ms`; + } + if (!sameValue(normalized, output.shutdown)) { + return false; + } + } else if (!sameValue(source[field], output[field])) { + return false; + } + } + return true; +} + +function selected( + source: Record, + profiles: readonly string[] +): boolean | undefined { + if (!Object.hasOwn(source, "profiles")) { + return true; + } + if ( + !( + Array.isArray(source.profiles) && + source.profiles.every((profile) => typeof profile === "string") + ) + ) { + return undefined; + } + return ( + source.profiles.length === 0 || + source.profiles.some((profile) => profiles.includes(profile)) + ); +} + +/** Cross-check source claims after Rust succeeds; no default engine behavior is synthesized. */ +export function nativeProcessSourceMatches(opts: { + readonly input: Uint8Array; + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; + readonly profiles?: readonly string[]; +}): boolean { + const source = project(opts.input); + if ( + !( + (source && nativeProcessPlanningRequired(source)) || + nativeProcessPlanningRequired(opts.plan) + ) + ) { + return true; + } + if ( + !(source && opts.declared && nativeProcessPlanIsValid(opts)) || + typeof source.name !== "string" || + opts.plan.name !== source.name + ) { + return false; + } + const profiles = [...(opts.profiles ?? [])].sort(); + if (new Set(profiles).size !== profiles.length) { + return false; + } + if (!sameValue(profiles, opts.plan.selected_profiles)) { + return false; + } + const sourceKinds = new Map(); + for (const [field, kind] of [ + ["services", "service"], + ["jobs", "job"], + ] as const) { + const inputs = Object.hasOwn(source, field) ? source[field] : {}; + const outputs = opts.plan[field]; + if (!(isRecord(inputs) && isRecord(outputs))) { + return false; + } + if ( + !sourceNamespaceMatches({ inputs, outputs, profiles, kind, sourceKinds }) + ) { + return false; + } + } + return ( + sourceKinds.size === Object.keys(opts.declared).length && + [...sourceKinds].every( + ([name, kind]) => + Object.hasOwn(opts.declared ?? {}, name) && + opts.declared?.[name] === kind + ) + ); +} + +function sourceNamespaceMatches(opts: { + readonly inputs: Record; + readonly outputs: Record; + readonly profiles: readonly string[]; + readonly kind: "service" | "job"; + readonly sourceKinds: Map; +}): boolean { + for (const [name, workload] of Object.entries(opts.inputs)) { + if ( + !(isRecord(workload) && fieldsValid(workload, opts.kind, false)) || + opts.sourceKinds.has(name) + ) { + return false; + } + opts.sourceKinds.set(name, opts.kind); + const active = selected(workload, opts.profiles); + if (active === undefined || active !== Object.hasOwn(opts.outputs, name)) { + return false; + } + } + return Object.entries(opts.outputs).every( + ([name, workload]) => + Object.hasOwn(opts.inputs, name) && + isRecord(opts.inputs[name]) && + isRecord(workload) && + fieldsMatch(opts.inputs[name], workload) + ); +} diff --git a/tests/native-process-plan-transport.test.ts b/tests/native-process-plan-transport.test.ts new file mode 100644 index 000000000..1cd699a31 --- /dev/null +++ b/tests/native-process-plan-transport.test.ts @@ -0,0 +1,525 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileNativeConfig, + type NativeConfigCompileResult, + NativeConfigCompilerError, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + process_plan_version: 1, +}; +const CANARY = "private-process-wire-canary"; +const WEB = { + image: "fixture", + entrypoint: { exec: [] }, + init: false, + shutdown: { signal: "SIGPWR", grace: "2s" }, + restart: { kind: "always" }, +}; +const SEED = { + image: "fixture", + profiles: ["tools"], + entrypoint: { shell: "printf fixture" }, + shutdown: { signal: "SIGTERM", grace: "1m" }, + restart: { kind: "on-failure", max_retries: 5 }, +}; +const SOURCE = { + schema_version: 1, + name: "fixture", + profiles: ["tools"], + services: { web: WEB }, + jobs: { seed: SEED }, +}; +const INPUT = new TextEncoder().encode(JSON.stringify(SOURCE)); +const NORMALIZED_WEB = { + ...WEB, + shutdown: { ...WEB.shutdown, grace: "2000ms" }, +}; +const COMPILED = { + transport_version: 1, + ok: true, + plan: { + plan_version: 1, + name: "fixture", + selected_profiles: [], + services: { web: NORMALIZED_WEB }, + jobs: {}, + }, + semantic_hash: "a".repeat(64), + declared_workloads: { web: "service", seed: "job" }, +} satisfies NativeConfigCompileResult; +const LOCAL = { + overlay: null, + origin: "project", + auto_branch: false, + inherit_local: false, + resolution_hash: "b".repeat(64), +}; +const METADATA = { + metadata_version: 1, + overlay: null, + overlay_exists: false, + workloads: { web: {}, seed: {} }, + inactive_scopes: [], +} as const satisfies NativeEnvMetadata; +let root = ""; +beforeEach(async () => { + root = await realpath( + await mkdtemp(join(tmpdir(), "native-process-transport-")) + ); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function fixture( + opts: { + readonly protocol?: Record; + readonly result?: unknown; + readonly exit?: number; + readonly body?: string; + } = {} +) { + const binary = join(root, "compiler"); + const body = + opts.body ?? + `const result=${JSON.stringify(opts.result ?? COMPILED)};if(result.ok&&process.argv[2]!=='compile'){result.local_resolution=${JSON.stringify(LOCAL)}}if(result.ok&&process.argv[2]==='plan'){result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:Object.fromEntries([...Object.keys(result.plan.services??{}),...Object.keys(result.plan.jobs??{})].map(name=>[name,{}])),warnings:[],diagnostics:[]}}console.log(JSON.stringify(result));process.stderr.write(${JSON.stringify(CANARY)});process.exitCode=${opts.exit ?? 0}`; + await Bun.write( + binary, + `#!${process.execPath}\nif(process.argv[2]==='--protocol'){console.log(${JSON.stringify(JSON.stringify(opts.protocol ?? PROTOCOL))})}else{${body}}` + ); + await chmod(binary, 0o700); + return binary; +} +async function failure( + operation: Promise, + code = "E_COMPILER_RESPONSE" +) { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toBeInstanceOf(NativeConfigCompilerError); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(CANARY); + expect(String(error)).not.toContain(root); +} +function withWeb(web: unknown) { + return { ...COMPILED, plan: { ...COMPILED.plan, services: { web } } }; +} + +test.each([ + undefined, + null, + 2, +])("authored process capability %s is required before any payload", async (version) => { + const receipt = join(root, "input-receipt"); + const binary = await fixture({ + protocol: { ...PROTOCOL, process_plan_version: version }, + body: `await Bun.write(${JSON.stringify(receipt)},await Bun.stdin.text())`, + }); + await failure( + compileNativeConfig({ input: INPUT, binary }), + "E_COMPILER_VERSION" + ); + await failure( + resolveNativeConfig({ input: INPUT, binary }), + "E_COMPILER_VERSION" + ); + await failure( + planNativeConfig({ input: INPUT, binary, envMetadata: METADATA }), + "E_COMPILER_VERSION" + ); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("inactive authored process requirements still negotiate capability before input", async () => { + const input = new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + name: "fixture", + services: {}, + jobs: { seed: SEED }, + }) + ); + const receipt = join(root, "payload"); + const binary = await fixture({ + protocol: { ...PROTOCOL, process_plan_version: undefined }, + body: `await Bun.write(${JSON.stringify(receipt)},await Bun.stdin.text())`, + }); + await failure(compileNativeConfig({ input, binary }), "E_COMPILER_VERSION"); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("explicit process negotiation remains available before field-free payloads", async () => { + const input = new TextEncoder().encode( + '{"schema_version":1,"name":"fixture"}' + ); + await failure( + compileNativeConfig({ + input, + binary: await fixture({ + protocol: { ...PROTOCOL, process_plan_version: undefined }, + }), + requireProcessPlanning: true, + }), + "E_COMPILER_VERSION" + ); +}); + +test("process-free old compiler calls retain their exact result without implied defaults", async () => { + const result = { + ...COMPILED, + plan: { plan_version: 1, services: { web: {} }, jobs: {} }, + } satisfies NativeConfigCompileResult; + const input = new TextEncoder().encode( + '{"schema_version":1,"name":"fixture"}' + ); + const binary = await fixture({ + protocol: { ...PROTOCOL, process_plan_version: undefined }, + result, + }); + expect(await compileNativeConfig({ input, binary })).toEqual(result); + expect((await resolveNativeConfig({ input, binary })).ok).toBe(true); + expect( + (await planNativeConfig({ input, binary, envMetadata: METADATA })).ok + ).toBe(true); +}); + +test("entrypoint clearing, false init and normalized shutdown remain distinct authored requirements", async () => { + const binary = await fixture(); + expect((await compileNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect((await resolveNativeConfig({ input: INPUT, binary })).ok).toBe(true); + const result = await planNativeConfig({ + input: INPUT, + binary, + envMetadata: METADATA, + }); + expect(result).toHaveProperty("plan.services.web", NORMALIZED_WEB); + expect(result).not.toHaveProperty("plan.services.web.command"); + expect(JSON.stringify(result)).not.toContain(CANARY); +}); + +test.each([ + "compile", + "resolve", + "plan", +] as const)("%s refuses silent drops despite a claimed matching process capability", async (operation) => { + const result = withWeb({ image: "fixture" }); + const binary = await fixture({ result }); + const options = { input: INPUT, binary }; + await failure( + operation === "compile" + ? compileNativeConfig(options) + : operation === "resolve" + ? resolveNativeConfig(options) + : planNativeConfig({ ...options, envMetadata: METADATA }) + ); +}); + +test.each([ + { name: "entrypoint omitted", field: "entrypoint", value: undefined }, + { + name: "changed entrypoint", + field: "entrypoint", + value: { shell: "printf changed" }, + }, + { name: "false init dropped", field: "init", value: undefined }, + { name: "changed init", field: "init", value: true }, + { name: "shutdown omitted", field: "shutdown", value: undefined }, + { + name: "changed signal", + field: "shutdown", + value: { signal: "SIGKILL", grace: "2000ms" }, + }, + { + name: "changed grace", + field: "shutdown", + value: { signal: "SIGPWR", grace: "2001ms" }, + }, + { name: "missing grace", field: "shutdown", value: { signal: "SIGPWR" } }, + { name: "restart omitted", field: "restart", value: undefined }, + { name: "changed restart", field: "restart", value: { kind: "no" } }, +])("refuses changed source requirement: $name", async ({ field, value }) => { + await failure( + compileNativeConfig({ + input: INPUT, + binary: await fixture({ + result: withWeb({ ...NORMALIZED_WEB, [field]: value }), + }), + }) + ); +}); + +test.each([ + { name: "null entrypoint", field: "entrypoint", value: null }, + { + name: "both entrypoint variants", + field: "entrypoint", + value: { exec: [], shell: "true" }, + }, + { + name: "empty entrypoint program", + field: "entrypoint", + value: { exec: [""] }, + }, + { name: "entrypoint non-string", field: "entrypoint", value: { exec: [1] } }, + { + name: "entrypoint NUL", + field: "entrypoint", + value: { exec: ["tool", "a\0b"] }, + }, + { name: "empty shell", field: "entrypoint", value: { shell: "" } }, + { name: "numeric init", field: "init", value: 1 }, + { name: "null init", field: "init", value: null }, + { name: "empty shutdown", field: "shutdown", value: {} }, + { name: "null shutdown", field: "shutdown", value: null }, + { name: "alias signal", field: "shutdown", value: { signal: "SIGIOT" } }, + { name: "prefixless signal", field: "shutdown", value: { signal: "TERM" } }, + { name: "numeric signal", field: "shutdown", value: { signal: 15 } }, + { name: "realtime signal", field: "shutdown", value: { signal: "SIGRTMIN" } }, + { + name: "unknown shutdown field", + field: "shutdown", + value: { grace: "2000ms", signal: "SIGPWR", private: CANARY }, + }, + { name: "noncanonical grace", field: "shutdown", value: { grace: "2s" } }, + { + name: "noncanonical padded grace", + field: "shutdown", + value: { grace: "0002ms" }, + }, + { name: "zero grace", field: "shutdown", value: { grace: "0ms" } }, + { + name: "overflow grace", + field: "shutdown", + value: { grace: "4294967296ms" }, + }, + { name: "unknown restart", field: "restart", value: { kind: "sometimes" } }, + { + name: "retry on always", + field: "restart", + value: { kind: "always", max_retries: 1 }, + }, + { + name: "zero retry", + field: "restart", + value: { kind: "on-failure", max_retries: 0 }, + }, + { + name: "overflow retry", + field: "restart", + value: { kind: "on-failure", max_retries: 4_294_967_296 }, + }, + { + name: "extra restart field", + field: "restart", + value: { kind: "no", private: CANARY }, + }, +])("refuses malformed process reply: $name", async ({ field, value }) => { + await failure( + compileNativeConfig({ + input: INPUT, + binary: await fixture({ + result: withWeb({ ...NORMALIZED_WEB, [field]: value }), + }), + }) + ); +}); + +test("an omitted authored field cannot be replaced by a guessed image default", async () => { + const source = { + ...SOURCE, + services: { web: { image: "fixture", init: false } }, + }; + await failure( + compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(source)), + binary: await fixture(), + }) + ); +}); + +test("process output cannot manufacture a workload absent from the authored namespace", async () => { + const input = new TextEncoder().encode( + '{"schema_version":1,"name":"fixture"}' + ); + await failure(compileNativeConfig({ input, binary: await fixture() })); +}); + +test("a declared job cannot become a service to conceal a never-ending restart", async () => { + const source = { + schema_version: 1, + name: "fixture", + jobs: { task: { image: "fixture", restart: { kind: "always" } } }, + }; + const result = { + ...COMPILED, + declared_workloads: { task: "service" }, + plan: { + ...COMPILED.plan, + services: { task: { image: "fixture", restart: { kind: "always" } } }, + jobs: {}, + }, + }; + await failure( + compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(source)), + binary: await fixture({ result }), + }) + ); +}); + +test("job always restart is rejected even when the claimed plan omits that inactive job", async () => { + const input = new TextEncoder().encode( + JSON.stringify({ + ...SOURCE, + jobs: { seed: { ...SEED, restart: { kind: "always" } } }, + }) + ); + await failure(compileNativeConfig({ input, binary: await fixture() })); +}); + +test("profile selection is bound to the actual caller and cannot silently omit active process intent", async () => { + const binary = await fixture(); + await failure( + compileNativeConfig({ input: INPUT, binary, profiles: ["tools"] }) + ); + const result = { + ...COMPILED, + plan: { + ...COMPILED.plan, + selected_profiles: ["tools"], + jobs: { + seed: { ...SEED, shutdown: { ...SEED.shutdown, grace: "60000ms" } }, + }, + }, + }; + expect( + ( + await compileNativeConfig({ + input: INPUT, + binary: await fixture({ result }), + profiles: ["tools"], + }) + ).ok + ).toBe(true); +}); + +test("inactive process intent is omitted only under the matching profile context", async () => { + const result = { + ...COMPILED, + plan: { + ...COMPILED.plan, + jobs: { + seed: { ...SEED, shutdown: { ...SEED.shutdown, grace: "60000ms" } }, + }, + }, + }; + await failure( + compileNativeConfig({ input: INPUT, binary: await fixture({ result }) }) + ); +}); + +test.each([ + "foreign", + undefined, +])("process-aware replies bind project name %s to the original source", async (name) => { + const result = { ...COMPILED, plan: { ...COMPILED.plan, name } }; + await failure( + compileNativeConfig({ input: INPUT, binary: await fixture({ result }) }) + ); +}); + +test("process-aware replies cannot omit active field-free siblings", async () => { + const input = new TextEncoder().encode( + JSON.stringify({ + ...SOURCE, + services: { ...SOURCE.services, sibling: { image: "fixture" } }, + }) + ); + const result = { + ...COMPILED, + declared_workloads: { ...COMPILED.declared_workloads, sibling: "service" }, + }; + await failure( + compileNativeConfig({ input, binary: await fixture({ result }) }) + ); +}); + +test("process-aware replies cannot include inactive field-free siblings", async () => { + const sibling = { image: "fixture", profiles: ["tools"] }; + const input = new TextEncoder().encode( + JSON.stringify({ + ...SOURCE, + services: { ...SOURCE.services, sibling }, + }) + ); + const result = { + ...COMPILED, + declared_workloads: { ...COMPILED.declared_workloads, sibling: "service" }, + plan: { + ...COMPILED.plan, + services: { ...COMPILED.plan.services, sibling }, + }, + }; + await failure( + compileNativeConfig({ input, binary: await fixture({ result }) }) + ); +}); + +test("authoritative invalid-process diagnostics remain structured rather than TS-authored errors", async () => { + const diagnostics = [ + { + code: "invalid_restart", + pointer: "/jobs/seed/restart", + message: "Job restart cannot create a never-ending job.", + line: 1, + column: 1, + }, + ]; + const input = new TextEncoder().encode( + JSON.stringify({ + ...SOURCE, + jobs: { seed: { ...SEED, restart: { kind: "always" } } }, + }) + ); + const result = await compileNativeConfig({ + input, + binary: await fixture({ + result: { transport_version: 1, ok: false, diagnostics }, + exit: 1, + }), + }); + expect(result).toEqual({ transport_version: 1, ok: false, diagnostics }); +}); + +test("malformed JSON remains an authoritative compiler diagnostic", async () => { + const diagnostics = [ + { + code: "invalid_json", + pointer: "", + message: "Invalid JSON.", + line: 1, + column: 1, + }, + ]; + const result = await compileNativeConfig({ + input: new TextEncoder().encode('{"services":{"web":{"init":'), + binary: await fixture({ + protocol: { ...PROTOCOL, process_plan_version: undefined }, + result: { transport_version: 1, ok: false, diagnostics }, + exit: 1, + }), + }); + expect(result).toEqual({ transport_version: 1, ok: false, diagnostics }); +}); diff --git a/tests/native-process-project.test.ts b/tests/native-process-project.test.ts new file mode 100644 index 000000000..949c0e65c --- /dev/null +++ b/tests/native-process-project.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + planNativeProject, + validateNativeProject, +} from "../src/lib/native-project-validation.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + process_plan_version: 1, +}; +const KEYS = [ + "HACK_HOME", + "HACK_GLOBAL_CONFIG_PATH", + "HACK_CONFIG_COMPILER_BINARY", + "HACK_ENV_SECRET_KEY", + "CI", + "HACK_EXECUTION_MODE", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", +] as const; +const CANARY = "private-process-project-canary"; +const WEB = { + image: "fixture", + entrypoint: { exec: [] }, + init: false, + shutdown: { signal: "SIGPWR", grace: "2s" }, + restart: { kind: "unless-stopped" }, +}; +const SOURCE = { + schema_version: 1, + name: "fixture", + services: { web: WEB }, + jobs: {}, + worktree: { inherit_local: true, auto_branch: false }, +}; +let root = ""; +let projectRoot = ""; +let receipt = ""; +let saved: Record; + +beforeEach(async () => { + saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); + for (const key of KEYS) { + Reflect.deleteProperty(process.env, key); + } + root = await realpath( + await mkdtemp(join(tmpdir(), "native-process-project-")) + ); + projectRoot = join(root, "project"); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + await writeFile( + join(projectRoot, ".hack/hack.project.json"), + JSON.stringify(SOURCE) + ); + process.env.HACK_HOME = join(root, "home"); + receipt = join(root, "requests.jsonl"); +}); + +afterEach(async () => { + for (const key of KEYS) { + restoreEnv(key, saved[key]); + } + await rm(root, { recursive: true, force: true }); +}); + +async function mockCompiler( + opts: { + readonly capability?: boolean; + readonly changeAt?: "resolve" | "plan"; + readonly invalid?: boolean; + } = {} +) { + const binary = join(root, "compiler"); + const protocol = { + ...PROTOCOL, + process_plan_version: opts.capability === false ? undefined : 1, + }; + await writeFile( + binary, + `#!${process.execPath} +import {appendFile} from 'node:fs/promises'; +if(process.argv[2]==='--protocol'){console.log(${JSON.stringify(JSON.stringify(protocol))})} +else{ + const operation=process.argv[2];const raw=await Bun.stdin.text(); + const request=operation==='compile'?{}:JSON.parse(raw); + await appendFile(${JSON.stringify(receipt)},JSON.stringify({operation,request})+'\\n'); + if(${Boolean(opts.invalid)}){ + console.log(JSON.stringify({transport_version:1,ok:false,diagnostics:[{code:'invalid_restart',pointer:'/services/web/restart',message:'Invalid restart policy.',line:1,column:1}]}));process.exitCode=1; + }else{ + const web=${JSON.stringify(WEB)};web.shutdown.grace='2000ms'; + if(operation===${JSON.stringify(opts.changeAt)})web.init=true; + const plan={plan_version:1,name:'fixture',selected_profiles:[],services:{web},jobs:{},worktree:{inherit_local:true,auto_branch:false}}; + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{web:'service'},plan}; + if(operation!=='compile')result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:true,resolution_hash:'b'.repeat(64)}; + if(operation==='plan')result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{web:{}},warnings:[],diagnostics:[]}; + console.log(JSON.stringify(result)); + } +}` + ); + await chmod(binary, 0o700); + process.env.HACK_CONFIG_COMPILER_BINARY = binary; +} + +async function calls() { + return (await readFile(receipt, "utf8")) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + operation: string; + request: Record; + } + ); +} + +async function failure(operation: Promise, code: string) { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(CANARY); + expect(String(error)).not.toContain(root); +} + +async function poisonMetadataAndGlobal() { + await writeFile( + join(projectRoot, ".hack/hack.env.default.yaml"), + `invalid [${CANARY}` + ); + await mkdir(join(root, "home"), { recursive: true }); + await writeFile(join(root, "home/hack.config.json"), `invalid ${CANARY}`); +} + +async function managedValues( + project: string, + filename = "hack.env.default.yaml" +) { + await writeFile( + join(project, ".hack", filename), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { + global: { TOKEN: { secure: `invalid-ciphertext-${CANARY}` } }, + web: { LABEL: CANARY }, + }, + }) + ); +} + +async function git(project: string, args: string[]) { + const child = Bun.spawn(["git", "-C", project, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(await child.exited).toBe(0); +} + +async function linked() { + await git(projectRoot, ["init", "--quiet", "-b", "main"]); + await git(projectRoot, ["add", ".hack/hack.project.json"]); + await git(projectRoot, [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ]); + const checkout = join(root, "linked"); + await git(projectRoot, [ + "worktree", + "add", + "--quiet", + "-b", + "fixture", + checkout, + ]); + return checkout; +} + +test("authored process capability is required before local policy, managed metadata or global reads", async () => { + await mockCompiler({ capability: false }); + await mkdir(join(projectRoot, ".hack/hack.local.json")); + await poisonMetadataAndGlobal(); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_VERSION" + ); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("process validation preserves planning-only behavior without managed values or unrelated global policy", async () => { + await mockCompiler(); + await poisonMetadataAndGlobal(); + const before = await readdir(join(projectRoot, ".hack")); + const result = await validateNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("plan.services.web.init", false); + expect(result).toHaveProperty("plan.services.web.shutdown.grace", "2000ms"); + expect((await calls()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + ]); + expect(await readdir(join(projectRoot, ".hack"))).toEqual(before); +}); + +test("process metadata planning passes only names and provenance to the compiler", async () => { + await mockCompiler(); + await managedValues(projectRoot); + const before = await readdir(join(projectRoot, ".hack")); + const result = await planNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("plan.services.web.entrypoint", { exec: [] }); + const requests = await calls(); + expect(requests.map((call) => call.operation)).toEqual([ + "compile", + "resolve", + "plan", + ]); + expect(requests[2]?.request).toHaveProperty("env_metadata.workloads.web", { + TOKEN: { scope: "global", secret: true }, + LABEL: { scope: "web", secret: false }, + }); + expect(JSON.stringify(requests)).not.toContain(CANARY); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(await readdir(join(projectRoot, ".hack"))).toEqual(before); + expect(await Bun.file(join(root, "home")).exists()).toBe(false); +}); + +test.each([ + "resolve", + "plan", +] as const)("%s cannot alter process intent behind unchanged identity hashes", async (changeAt) => { + await mockCompiler({ changeAt }); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_RESPONSE" + ); + expect((await calls()).map((call) => call.operation)).toEqual( + changeAt === "resolve" + ? ["compile", "resolve"] + : ["compile", "resolve", "plan"] + ); +}); + +test("authoritative invalid-process diagnostics stop before poisoned local or metadata inputs", async () => { + await mockCompiler({ invalid: true }); + await mkdir(join(projectRoot, ".hack/hack.local.json")); + await poisonMetadataAndGlobal(); + const result = await planNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("ok", false); + expect(result).toHaveProperty("diagnostics.0", { + code: "invalid_restart", + pointer: "/services/web/restart", + message: "Invalid restart policy.", + line: 1, + column: 1, + document: "project", + }); + expect((await calls()).map((call) => call.operation)).toEqual(["compile"]); +}); + +test("linked worktree process planning preserves source policy and verified local inheritance without runtime effects", async () => { + const checkout = await linked(); + const primaryLocal = JSON.stringify({ schema_version: 1 }); + const checkoutLocal = JSON.stringify({ schema_version: 1 }); + await writeFile(join(projectRoot, ".hack/hack.local.json"), primaryLocal); + await writeFile(join(checkout, ".hack/hack.local.json"), checkoutLocal); + await managedValues(projectRoot, "hack.env.local.yaml"); + await mockCompiler(); + const primaryBefore = await readdir(join(projectRoot, ".hack")); + const checkoutBefore = await readdir(join(checkout, ".hack")); + const result = await planNativeProject({ startDir: checkout }); + expect(result).toHaveProperty( + "plan.services.web.restart.kind", + "unless-stopped" + ); + const requests = await calls(); + for (const call of requests.filter( + (entry) => entry.operation !== "compile" + )) { + expect(call.request).toHaveProperty("primary_local", primaryLocal); + expect(call.request).toHaveProperty("checkout_local", checkoutLocal); + expect(call.request).toHaveProperty("project", JSON.stringify(SOURCE)); + } + expect(requests[2]?.request).toHaveProperty( + "env_metadata.workloads.web.TOKEN", + { + scope: "global", + secret: true, + } + ); + expect(JSON.stringify(requests)).not.toContain(CANARY); + expect(await readdir(join(projectRoot, ".hack"))).toEqual(primaryBefore); + expect(await readdir(join(checkout, ".hack"))).toEqual(checkoutBefore); + expect(await Bun.file(join(root, "home")).exists()).toBe(false); +});