Skip to main content

cloud/
provision.rs

1//! Provisioning orchestrator: config → cloud-init render → MachineProvider call.
2//!
3//! Decoupled from the concrete provider so the CLI passes a `&dyn MachineProvider`
4//! (Hetzner in production, in-memory fakes in tests).
5
6use crate::cloud_init::{self, RenderInput};
7use crate::config::MachineConfig;
8use crate::provider::{Location, MachineProvider, ProjectId, ServerId, ServerSpec};
9use anyhow::{Context, Result};
10use std::path::Path;
11
12/// A rendered provision payload, ready to send to a `MachineProvider`.
13#[derive(Debug)]
14pub struct ProvisionRequest {
15    pub machine_name: String,
16    pub server_type: String,
17    pub location: Location,
18    pub user_data: String,
19    /// Provider-side SSH-key IDs to authorize for `root` at create time.
20    /// Carried from `MachineConfig.ssh_keys`; empty defaults to no
21    /// per-key auth (Hetzner emails a random root password we discard).
22    pub ssh_keys: Vec<u64>,
23}
24
25/// Build a provision request: load the cloud-init template for the workspace and
26/// substitute per-machine values. The yubaba binary is fetched on the machine
27/// at first boot from `yubaba_url` and verified against `yubaba_sha256`
28/// (R040-F11) — base64-embedding it would blow past Hetzner's 32 KiB cap.
29///
30/// `headscale_preauth_key` decides mesh membership (R330-F28). `Some` ⟺ this
31/// machine is JOINING an existing mesh: the rendered cloud-init emits the
32/// tailscaled install + `tailscale up --auth-key=<key>` join block. `None` ⟺
33/// STANDALONE / coordinator-to-be — no mesh exists yet, so no join block is
34/// emitted; the node comes up as bare yubaba and becomes the coordinator later
35/// via `yah mesh bootstrap`. Membership is gated purely on this key's presence,
36/// independent of `machine.hosts_operator_bridge`.
37///
38/// `mesh_url` is the stable Headscale coordinator URL (R040-F18). When present
39/// (only meaningful alongside a preauth key), the rendered cloud-init passes
40/// `--login-server <url>` to `tailscale up` so the machine joins the camp's
41/// Headscale instead of Tailscale SaaS. When `None`, a joining machine uses the
42/// default Tailscale SaaS coordinator.
43///
44/// `yubaba_channel` selects the release channel (`"stable"` or `"beta"`);
45/// use [`cloud_init::DEFAULT_YUBABA_CHANNEL`] for Phase 1. containerd is
46/// installed unpinned (R330-T9 — an exact apt pin matched no Debian repo).
47pub fn build_request(
48    workspace_root: &Path,
49    machine: &MachineConfig,
50    yubaba_url: String,
51    yubaba_sha256: String,
52    yubaba_channel: String,
53    headscale_preauth_key: Option<String>,
54    mesh_url: Option<String>,
55    cloudflared_token: Option<String>,
56    yubaba_cosign_identity_regexp: Option<String>,
57) -> Result<ProvisionRequest> {
58    let template = cloud_init::load_template(workspace_root)?;
59    let input = RenderInput {
60        machine,
61        yubaba_url,
62        yubaba_sha256,
63        yubaba_channel,
64        headscale_preauth_key,
65        mesh_url,
66        cloudflared_token,
67        yubaba_cosign_identity_regexp,
68    };
69    let user_data = cloud_init::render(&template, &input)?;
70    machine.validate()?;
71    let location = Location::try_from(machine.location())
72        .with_context(|| format!("machine '{}' has unknown location", machine.name))?;
73    Ok(ProvisionRequest {
74        machine_name: machine.name.clone(),
75        server_type: machine.server_type().to_string(),
76        location,
77        user_data,
78        ssh_keys: machine.ssh_keys.clone(),
79    })
80}
81
82/// Execute a built request against a provider. Returns the new server ID on success.
83///
84/// Hostkey-fingerprint write-back lands with A8 (yah-yubaba `/identity` endpoint
85/// and `MachineConfig::save` are both already in place; the missing piece is the
86/// yubaba binary itself).
87pub async fn execute(
88    provider: &dyn MachineProvider,
89    project: &ProjectId,
90    req: &ProvisionRequest,
91) -> Result<ServerId> {
92    let spec = ServerSpec {
93        name: req.machine_name.clone(),
94        server_type: req.server_type.clone(),
95        image: "debian-12".into(),
96        location: req.location.clone(),
97        ssh_keys: req.ssh_keys.clone(),
98    };
99    provider.create_server(project, &spec, &req.user_data).await
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::cloud_init;
106    use crate::config::{BucketSpec, MachineConfig};
107
108    fn sample_machine() -> MachineConfig {
109        MachineConfig {
110            name: "noisetable-pdx-1".into(),
111            provider: "hetzner".into(),
112            location: Some("pdx".into()),
113            server_type: Some("cpx22".into()),
114            hosts_mirrors: vec!["noisetable".into(), "yah".into()],
115            mesh_tags: vec!["tag:region-pdx".into(), "tag:tier-t2".into()],
116            region: None,
117            zone: None,
118            arch: None,
119            bucket: Some(BucketSpec {
120                name: "noisetable-assets-pdx-1".into(),
121                public_read: false,
122            }),
123            vendor: None,
124            nickname: None,
125            legacy_hostkey_fingerprint: None,
126            registration: Default::default(),
127            ssh_keys: vec![],
128            cloudflared: None,
129            hosts_operator_bridge: false,
130            connect: None,
131            allocatable: None,
132            taints: vec![],
133        }
134    }
135
136    fn build_req_defaults(
137        dir: &std::path::Path,
138        machine: &MachineConfig,
139        extra_url: Option<String>,
140        extra_cf: Option<String>,
141    ) -> crate::provision::ProvisionRequest {
142        build_request(
143            dir,
144            machine,
145            "https://example.com/yah-yubaba".into(),
146            "deadbeef".into(),
147            cloud_init::DEFAULT_YUBABA_CHANNEL.into(),
148            Some("KEY".into()),
149            extra_url,
150            extra_cf,
151            None,
152        )
153        .unwrap()
154    }
155
156    #[test]
157    fn build_request_renders_user_data_and_picks_location() {
158        // A preauth key present → join block emitted, so the key ("KEY") and tags appear.
159        let machine = sample_machine();
160        let dir = tempfile::tempdir().unwrap();
161        let req = build_req_defaults(dir.path(), &machine, None, None);
162        assert_eq!(req.machine_name, "noisetable-pdx-1");
163        assert_eq!(req.location, Location::Pdx);
164        assert!(req.user_data.contains("https://example.com/yah-yubaba"));
165        assert!(req.user_data.contains("deadbeef"));
166        assert!(req.user_data.contains("KEY"));
167        assert!(req.user_data.contains("tag:region-pdx,tag:tier-t2"));
168    }
169
170    #[test]
171    fn build_request_with_mesh_url_adds_login_server() {
172        let machine = sample_machine();
173        let dir = tempfile::tempdir().unwrap();
174        let req = build_req_defaults(
175            dir.path(),
176            &machine,
177            Some("https://mesh.example.com".into()),
178            None,
179        );
180        assert!(req
181            .user_data
182            .contains("--login-server https://mesh.example.com"));
183    }
184
185    #[test]
186    fn build_request_standalone_omits_join_block() {
187        // R330-F28: a standalone / coordinator-to-be node carries no preauth
188        // key (and no mesh_url). build_request must NOT emit the tailscale-up
189        // join block — the node comes up as bare yubaba.
190        let machine = sample_machine();
191        let dir = tempfile::tempdir().unwrap();
192        let req = build_request(
193            dir.path(),
194            &machine,
195            "https://example.com/yah-yubaba".into(),
196            "deadbeef".into(),
197            cloud_init::DEFAULT_YUBABA_CHANNEL.into(),
198            None, // standalone: no preauth
199            None, // standalone: no mesh_url
200            None,
201            None,
202        )
203        .unwrap();
204        assert!(
205            !req.user_data.contains("tailscale up --auth-key"),
206            "standalone node must not emit the tailscale-up join block"
207        );
208        // Prose in the template header mentions --login-server; the real arg
209        // form (`--login-server https://`) must be absent.
210        assert!(!req.user_data.contains("--login-server https://"));
211    }
212
213    #[test]
214    fn build_request_rejects_unknown_location() {
215        let mut machine = sample_machine();
216        machine.location = Some("moon".into());
217        let dir = tempfile::tempdir().unwrap();
218        let err = build_request(
219            dir.path(),
220            &machine,
221            "x".into(),
222            "y".into(),
223            "stable".into(),
224            Some("z".into()),
225            None,
226            None,
227            None,
228        )
229        .unwrap_err()
230        .to_string();
231        assert!(err.contains("unknown location"), "unexpected: {err}");
232    }
233
234    #[test]
235    fn build_request_threads_cosign_identity_into_render() {
236        // R330-F21: when an identity_regexp is passed, the rendered cloud-init
237        // emits the cosign verify-blob block. Without it (the existing
238        // build_req_defaults helper passes None) the block stays empty.
239        let machine = sample_machine();
240        let dir = tempfile::tempdir().unwrap();
241        let req = build_request(
242            dir.path(),
243            &machine,
244            "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz".into(),
245            "deadbeef".into(),
246            cloud_init::DEFAULT_YUBABA_CHANNEL.into(),
247            None,
248            None,
249            None,
250            Some(r"^https://github\.com/yah-ai/yah/".into()),
251        )
252        .unwrap();
253        assert!(
254            req.user_data
255                .contains("cosign verify-blob --certificate-identity-regexp"),
256            "verify-blob runcmd missing once identity_regexp is threaded"
257        );
258        assert!(
259            req.user_data.contains(r"^https://github\.com/yah-ai/yah/"),
260            "identity_regexp value missing from rendered output"
261        );
262    }
263}