1use 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#[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 pub ssh_keys: Vec<u64>,
23}
24
25pub 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
82pub 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 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 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, None, 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 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 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}