Skip to content

Commit 8fb43b3

Browse files
committed
feat(skillet): deploy verified binaries before remote apply
1 parent 5e54f7f commit 8fb43b3

18 files changed

Lines changed: 594 additions & 117 deletions

File tree

‎ublue/design/host-application.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ it. Caddy staging still performs scoped-token and DNS provider operations.
1818
Workstation SSH uses the operator's usual known-hosts file and OpenSSH identity
1919
selection unless overridden, while retaining strict host verification and
2020
noninteractive authentication. VM keys remain explicit run-owned artifacts.
21-
The workflow assumes installed guest binaries and temporary provisioning sudo;
22-
it does not deploy binaries, migrate data, finalize access or isolate controllers.
21+
Remote apply deploys current generic and caller binaries before vault access or
22+
service activation; `--no-deploy` explicitly reuses installed binaries. Standalone
23+
`deploy` has no vault or service side effects. Both use Cargo-reported artifacts
24+
and the VM delivery owner without manufacturing a VM identity. VM callers retain
25+
run locks and ownership guards. Delivery verifies identity, bytes, root metadata
26+
and version, and uses unique staging plus atomic replacement per executable.
27+
A failed partial pair stops apply and can be retried; pair-wide atomicity and
28+
concurrent independent deployment are not guaranteed. Temporary provisioning
29+
sudo is required. Deployment does not migrate data, finalize access or isolate
30+
controllers.
2331

2432

2533
Caller-selected native networking prepares boot profiles before monitoring,

‎ublue/plan/HOST-NETWORKING.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ apply prepares networking without switching the active connection. See
3131
Offline parser validation and injected tests do not establish physical wireless
3232
association or actual reboot reachability. Record evidence separately in
3333
`plan/archive/ACCEPTANCE.md`. Live switching/checkpoint rollback remains future work.
34+
35+
Physical binary updates now use `skillet deploy --host HOST --target USER@HOST`;
36+
remote `apply` performs that deployment by default. Network-only delivery still
37+
requires running the guest base phase once to install the preparation unit.

‎ublue/plan/archive/ACCEPTANCE.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2743,3 +2743,22 @@ update / rollback matrix cases. A header backup is not a service-data backup.
27432743
- Formatting, strict workspace Clippy and 437 workspace/all-target tests passed
27442744
(five routine ignores). Fedora joey base repeat apply passed with no repeated
27452745
service start/restart. Physical binaries were not deployed by this change.
2746+
2747+
## 2026-10-13 — production deployment before apply
2748+
2749+
- Added `deploy --host HOST --target USER@HOST`; remote apply deploys first by
2750+
default, with `--no-deploy` restricted to remote use. Both paths print versions;
2751+
standalone deployment has no vault or service activation dependency.
2752+
- Physical and VM updates share Cargo-reported artifact selection and verified
2753+
per-executable atomic delivery. VM run guards/capture evidence are preserved.
2754+
Uploaded bytes/root ownership/mode are checked before promotion, SELinux labels
2755+
are restored, and both installed hashes and versions are checked. A failed
2756+
partial pair prevents apply and retry converges the remaining executable.
2757+
- Local checks passed: formatting, strict workspace Clippy, 445 workspace/all-target
2758+
tests (five routine ignores), deploy/apply CLI help, and Fedora joey base repeat
2759+
apply with no repeated service start/restart. Stateful fake transport tests
2760+
cover both files, corrupt staging, partial replacement/retry, no-op delivery,
2761+
profile mismatch before build/upload, and retained VM disposal guards.
2762+
- Live deployment to physical clamps and native/Flatpak VM update were not run.
2763+
The last read-only SSH probe to the physical host timed out; mock delivery and
2764+
container baseline results do not establish real SSH/SELinux deployment acceptance.

‎ublue/skillet/AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,8 @@ skillet/
232232
- Keep release and source identity shared across generic and host binaries.
233233
Version metadata must refresh after Git or tracked-source changes without
234234
requiring a clean build; report unavailable provenance explicitly.
235+
236+
- Remote application deploys its matching artifacts before consumer activation
237+
unless reuse is explicit. Share artifact selection and verified replacement
238+
across physical and disposable targets while retaining target-specific trust
239+
guards; stop application after incomplete deployment.

‎ublue/skillet/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ root with `bash ublue/butane/tests/bootstrap.sh`.
7070
[secrets checklist](../../SECRETS.md). The
7171
[host/module index](crates/host-profiles/README.md) links each service guide.
7272
Stop conflicting production identities and DNS writers before migration.
73-
7. **Apply the host.** Installed guest binaries and units must match the selected
74-
profile. From the workstation:
73+
7. **Apply the host.** Remote apply builds and deploys the current
74+
binaries before configuring services. From the workstation:
7575

7676
```sh
7777
cargo run --release -p skillet -- secrets unlock

‎ublue/skillet/crates/cli/src/apply.rs‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
use super::{SecretDeliverArgs, UiEnvironmentName};
22
use anyhow::{anyhow, Context, Result};
33
use skillet_cli_common::hosts::ApplyPhase;
4-
use skillet_vm::{
5-
credential::ActivationPolicy,
6-
transport::{GuestCommand, GuestTransport, SshTransport},
7-
};
4+
use skillet_vm::{credential::ActivationPolicy, transport::SshTransport};
85
use skillet_workstation::{host_apply, vault::Vault};
96
use std::path::PathBuf;
107

@@ -22,6 +19,9 @@ pub(super) struct Args {
2219
/// Provision all declared services over verified SSH to USER@HOST
2320
#[arg(long, requires = "host")]
2421
target: Option<String>,
22+
/// Reuse installed guest binaries instead of deploying current source
23+
#[arg(long, requires = "target")]
24+
no_deploy: bool,
2525
#[arg(long, default_value_t = 22)]
2626
port: u16,
2727
/// SSH private key; omitted uses OpenSSH configuration, defaults and agent
@@ -88,17 +88,17 @@ fn remote(args: &Args) -> Result<()> {
8888
args.identity.as_deref(),
8989
args.known_hosts.as_deref(),
9090
)?;
91-
let identity = transport.execute(
92-
&GuestCommand {
93-
program: "cat",
94-
arguments: &["/etc/skillet/host"],
95-
},
96-
None,
97-
)?;
98-
if !identity.status.success() || std::str::from_utf8(&identity.stdout)?.trim() != host {
99-
return Err(anyhow!(
100-
"SSH host identity differs from selected host; no credentials delivered"
101-
));
91+
skillet_workstation::deployment::verify_host(host, &transport)?;
92+
if args.no_deploy {
93+
println!(
94+
"Workstation Skillet {}; deployment skipped (--no-deploy)",
95+
skillet_cli_common::version::version()
96+
);
97+
let installed = skillet_workstation::deployment::installed_versions(host, &transport)?;
98+
println!("Installed: {}", installed.generic_version);
99+
println!("Installed: {}", installed.host_version);
100+
} else {
101+
super::deploy::deploy(host, &transport)?;
102102
}
103103
let path = args.database.clone().map_or_else(Vault::default_path, Ok)?;
104104
let mut vault = Vault::open(&path, args.key_file.as_deref())?;

‎ublue/skillet/crates/cli/src/apply/tests.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ fn remote_apply_has_simple_defaults_and_production_policy() {
2222
assert_eq!(args.phase, ApplyPhase::Full);
2323
assert!(matches!(args.environment, UiEnvironmentName::Production));
2424
assert_eq!(args.port, 22);
25+
assert!(!args.no_deploy);
2526
assert!(args.identity.is_none());
2627
assert!(args.known_hosts.is_none());
2728
}
@@ -98,3 +99,42 @@ fn invalid_remote_profile_or_policy_fails_before_connection_and_unlock() {
9899
.contains("all production services"));
99100
}
100101
}
102+
103+
#[test]
104+
fn deployment_can_only_be_skipped_for_remote_apply() {
105+
let args = parse(&[
106+
"skillet",
107+
"apply",
108+
"--host",
109+
"clamps",
110+
"--target",
111+
"admin@example.invalid",
112+
"--no-deploy",
113+
]);
114+
assert!(args.no_deploy);
115+
assert!(
116+
crate::Args::try_parse_from(["skillet", "apply", "--host", "clamps", "--no-deploy"])
117+
.is_err()
118+
);
119+
}
120+
121+
#[test]
122+
fn standalone_deploy_requires_profile_and_target_and_has_ssh_defaults() {
123+
assert!(crate::Args::try_parse_from(["skillet", "deploy", "--host", "clamps"]).is_err());
124+
assert!(crate::Args::try_parse_from([
125+
"skillet",
126+
"deploy",
127+
"--target",
128+
"admin@example.invalid"
129+
])
130+
.is_err());
131+
assert!(crate::Args::try_parse_from([
132+
"skillet",
133+
"deploy",
134+
"--host",
135+
"clamps",
136+
"--target",
137+
"admin@example.invalid"
138+
])
139+
.is_ok());
140+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use anyhow::{Context, Result};
2+
use skillet_vm::transport::GuestTransport;
3+
use std::path::PathBuf;
4+
5+
#[derive(clap::Args, Debug)]
6+
pub(super) struct Args {
7+
#[arg(long)]
8+
host: String,
9+
#[arg(long)]
10+
target: String,
11+
#[arg(long, default_value_t = 22)]
12+
port: u16,
13+
#[arg(long)]
14+
identity: Option<PathBuf>,
15+
#[arg(long)]
16+
known_hosts: Option<PathBuf>,
17+
}
18+
19+
pub(super) fn dispatch(args: &Args) -> Result<()> {
20+
skillet_hosts::profile_for_name(&args.host)
21+
.ok_or_else(|| anyhow::anyhow!("unknown host profile: {}", args.host))?;
22+
let transport = super::ssh::transport(
23+
&args.target,
24+
args.port,
25+
args.identity.as_deref(),
26+
args.known_hosts.as_deref(),
27+
)?;
28+
deploy(&args.host, &transport)
29+
}
30+
31+
pub(super) fn deploy(host: &str, guest: &impl GuestTransport) -> Result<()> {
32+
println!(
33+
"Workstation Skillet {}",
34+
skillet_cli_common::version::version()
35+
);
36+
println!("Building and deploying skillet and skillet-{host}");
37+
let report = skillet_workstation::deployment::deploy(host, &super::workspace_root()?, guest)
38+
.context(
39+
"deploying Skillet; apply has not started, retry deployment after resolving the failure",
40+
)?;
41+
println!("Deployed and verified: {}", report.generic_version);
42+
println!("Deployed and verified: {}", report.host_version);
43+
Ok(())
44+
}

‎ublue/skillet/crates/cli/src/main.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use tracing::{info, Level};
66
use tracing_subscriber::FmtSubscriber;
77

88
mod apply;
9+
mod deploy;
910
mod installation;
1011
mod root_recovery;
1112
mod secret_delivery;
@@ -58,6 +59,8 @@ enum Commands {
5859
},
5960
/// Apply locally, or deliver credentials and apply all declared services over SSH
6061
Apply(apply::Args),
62+
/// Build and deploy current generic and host binaries over verified SSH
63+
Deploy(deploy::Args),
6164
/// Run the real-runtime smoke scenario against a disposable VM
6265
Test {
6366
#[command(subcommand)]
@@ -384,6 +387,7 @@ fn main() -> Result<()> {
384387
command: SecretCommands::Lock(args),
385388
} => secret_delivery::lock_vault(args.database.as_deref())?,
386389
Commands::Apply(args) => apply::dispatch(&args)?,
390+
Commands::Deploy(args) => deploy::dispatch(&args)?,
387391
Commands::Test {
388392
command: TestCommands::Run(args),
389393
} => run_container_test(&args)?,

‎ublue/skillet/crates/vm/src/artifacts.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,35 @@ pub fn build(request: &BuildRequest<'_>) -> Result<BTreeMap<String, PathBuf>> {
5454
.collect()
5555
}
5656

57+
/// Inspect a selected executable with a bounded read-only command.
58+
pub fn binary_version(path: &Path) -> Result<String> {
59+
let mut command = Command::new(path);
60+
command.arg("--version");
61+
let output = crate::process::capture(command, std::time::Duration::from_secs(10))?;
62+
if !output.status.success() {
63+
return Err(Error::Invalid(
64+
"selected binary could not report its version".into(),
65+
));
66+
}
67+
version_text(&output.stdout)
68+
}
69+
70+
/// Version output is a single bounded printable line, safe for terminal display.
71+
pub fn version_text(bytes: &[u8]) -> Result<String> {
72+
let text = std::str::from_utf8(bytes)
73+
.map_err(|_| Error::Invalid("invalid binary version output".into()))?
74+
.trim();
75+
if text.is_empty()
76+
|| text.len() > 200
77+
|| !text
78+
.bytes()
79+
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
80+
{
81+
return Err(Error::Invalid("invalid binary version output".into()));
82+
}
83+
Ok(text.into())
84+
}
85+
5786
#[derive(Deserialize)]
5887
struct CargoMessage {
5988
reason: String,

0 commit comments

Comments
 (0)