| 1 | //! Table-driven registries for `codewhale remote-setup`. |
| 2 | //! |
| 3 | //! Mirrors the `ProviderKind`/`provider::Provider` registry pattern in |
| 4 | //! `crates/config/src/lib.rs`: adding a cloud or a bridge is one row of data, |
| 5 | //! not a new control-flow branch. The wizard in [`super`] iterates these tables |
| 6 | //! rather than hard-coding clouds/bridges, so the matrix grows by data. |
| 7 | //! |
| 8 | //! - [`BridgeSpec`] — pure transport between a chat app and the local runtime. |
| 9 | //! - [`CloudTarget`] — where the agent runs and where its secrets live. |
| 10 | //! - The provider dimension is *not* duplicated here: it reads the existing |
| 11 | //! `codewhale_config::provider` registry (see [`super::bundle::ProviderInfo`]). |
| 12 | |
| 13 | /// Where a cloud target stores the runtime/provider secrets. |
| 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 15 | pub enum SecretStore { |
| 16 | /// Secrets live in `/etc/codewhale/*.env` files on the host. |
| 17 | EnvFile, |
| 18 | /// Secrets live in a managed vault (e.g. Azure Key Vault), read at boot. |
| 19 | KeyVault, |
| 20 | } |
| 21 | |
| 22 | impl SecretStore { |
| 23 | #[must_use] |
| 24 | pub fn label(self) -> &'static str { |
| 25 | match self { |
| 26 | SecretStore::EnvFile => "EnvFile (/etc/codewhale/*.env)", |
| 27 | SecretStore::KeyVault => "Key Vault (managed identity at boot)", |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | /// How the runtime + bridge are installed on the host. |
| 33 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 34 | pub enum InstallMethod { |
| 35 | /// Native `cargo install` + systemd units (mirrors deploy/tencent-lighthouse). |
| 36 | NativeSystemd, |
| 37 | /// Container image pulled and run under systemd / a container runtime. |
| 38 | Docker, |
| 39 | } |
| 40 | |
| 41 | impl InstallMethod { |
| 42 | #[must_use] |
| 43 | pub fn label(self) -> &'static str { |
| 44 | match self { |
| 45 | InstallMethod::NativeSystemd => "native + systemd", |
| 46 | InstallMethod::Docker => "Docker image", |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// A single provisioning step expressed as **data**, never a shell string. |
| 52 | /// |
| 53 | /// Commands are returned as `(program, args)` so the confirmation gate can print |
| 54 | /// every command before running anything, secrets are fed via stdin/temp files |
| 55 | /// (never argv or shell history — `secret_args` lists arg indexes to redact when |
| 56 | /// printing), and `--apply` simply executes the already-printed plan. In the |
| 57 | /// generate-only MVP these steps are only *rendered into the RUNBOOK*; nothing |
| 58 | /// is executed. |
| 59 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 60 | pub struct ProvisionStep { |
| 61 | /// Human-readable description shown in the plan / RUNBOOK. |
| 62 | pub description: String, |
| 63 | /// Program to run (e.g. `az`, `doctl`). |
| 64 | pub program: String, |
| 65 | /// Arguments, in order. |
| 66 | pub args: Vec<String>, |
| 67 | /// Indexes into `args` whose values are secret and must be redacted when |
| 68 | /// the plan is printed. (Empty for the data-only RUNBOOK rows here.) |
| 69 | pub secret_args: Vec<usize>, |
| 70 | } |
| 71 | |
| 72 | impl ProvisionStep { |
| 73 | pub fn new(description: impl Into<String>, program: impl Into<String>, args: &[&str]) -> Self { |
| 74 | Self { |
| 75 | description: description.into(), |
| 76 | program: program.into(), |
| 77 | args: args.iter().map(|a| (*a).to_string()).collect(), |
| 78 | secret_args: Vec::new(), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Render the command for display, redacting any secret arg positions. |
| 83 | #[must_use] |
| 84 | pub fn display_command(&self) -> String { |
| 85 | let mut parts = Vec::with_capacity(self.args.len() + 1); |
| 86 | parts.push(self.program.clone()); |
| 87 | for (idx, arg) in self.args.iter().enumerate() { |
| 88 | if self.secret_args.contains(&idx) { |
| 89 | parts.push("<redacted>".to_string()); |
| 90 | } else { |
| 91 | parts.push(arg.clone()); |
| 92 | } |
| 93 | } |
| 94 | parts.join(" ") |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// Inputs collected by the wizard that a cloud `plan()` reads. |
| 99 | /// |
| 100 | /// Deliberately minimal and side-effect-free: a `plan()` turns these into an |
| 101 | /// ordered list of [`ProvisionStep`]s. Secret *values* are never placed here; |
| 102 | /// the plan references where they will be read from (env file / vault), so this |
| 103 | /// struct stays safe to print and to construct in tests. |
| 104 | #[derive(Debug, Clone)] |
| 105 | pub struct DeployInputs { |
| 106 | /// Bridge slug, e.g. `"telegram"`. |
| 107 | pub bridge_slug: String, |
| 108 | /// Provider slug, e.g. `"deepseek"`. |
| 109 | pub provider_slug: String, |
| 110 | /// Cloud region / location (default per cloud). |
| 111 | pub region: String, |
| 112 | /// Logical instance / resource name. |
| 113 | pub instance_name: String, |
| 114 | /// Container image used by Docker installs. |
| 115 | pub image: String, |
| 116 | } |
| 117 | |
| 118 | impl Default for DeployInputs { |
| 119 | fn default() -> Self { |
| 120 | Self { |
| 121 | bridge_slug: "telegram".to_string(), |
| 122 | provider_slug: "deepseek".to_string(), |
| 123 | region: String::new(), |
| 124 | instance_name: "codewhale-remote".to_string(), |
| 125 | image: "ghcr.io/codewhale-hq/codewhale:latest".to_string(), |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// A chat bridge: pure transport between a chat app and `127.0.0.1:7878`. |
| 131 | #[derive(Debug, Clone, Copy)] |
| 132 | pub struct BridgeSpec { |
| 133 | /// Stable slug used on the CLI and in paths, e.g. `"telegram"`. |
| 134 | pub slug: &'static str, |
| 135 | /// Human-readable label. |
| 136 | pub display: &'static str, |
| 137 | /// Package directory (relative to repo root), e.g. `"integrations/telegram-bridge"`. |
| 138 | pub package_dir: &'static str, |
| 139 | /// Systemd unit filename for the bridge. |
| 140 | pub service_unit: &'static str, |
| 141 | /// Repo-relative path of the reference env template shipped with deploy/. |
| 142 | pub env_template: &'static str, |
| 143 | /// Bridge-specific secret env keys the wizard prompts for (token(s), etc.). |
| 144 | pub secret_keys: &'static [&'static str], |
| 145 | /// One-liner shown before prompting (where to get the bridge credentials). |
| 146 | pub setup_hint: &'static str, |
| 147 | /// systemd `WorkingDirectory` the unit expects the bridge to be installed at. |
| 148 | pub install_dir: &'static str, |
| 149 | } |
| 150 | |
| 151 | /// A cloud target: where the agent runs and where secrets live. |
| 152 | #[derive(Debug, Clone, Copy)] |
| 153 | pub struct CloudTarget { |
| 154 | /// Stable slug used on the CLI and in paths, e.g. `"azure"`. |
| 155 | pub slug: &'static str, |
| 156 | /// Human-readable label. |
| 157 | pub display: &'static str, |
| 158 | /// Where runtime/provider secrets are stored. |
| 159 | pub secret_store: SecretStore, |
| 160 | /// How the runtime + bridge are installed. |
| 161 | pub install: InstallMethod, |
| 162 | /// Default region/location for this cloud. |
| 163 | pub default_region: &'static str, |
| 164 | /// Cloud CLI used by the (stubbed) auto-provision path, e.g. `"az"`. |
| 165 | pub cli_tool: &'static str, |
| 166 | /// Builds the ordered provisioning plan as data. In the generate-only MVP |
| 167 | /// this is only rendered into the RUNBOOK; `--apply` is not implemented. |
| 168 | pub plan: fn(&DeployInputs) -> Vec<ProvisionStep>, |
| 169 | } |
| 170 | |
| 171 | // --------------------------------------------------------------------------- |
| 172 | // Bridge registry |
| 173 | // --------------------------------------------------------------------------- |
| 174 | |
| 175 | /// Telegram bridge — long-poll transport, secret is the BotFather token. |
| 176 | pub const TELEGRAM: BridgeSpec = BridgeSpec { |
| 177 | slug: "telegram", |
| 178 | display: "Telegram", |
| 179 | package_dir: "integrations/telegram-bridge", |
| 180 | service_unit: "codewhale-telegram-bridge.service", |
| 181 | env_template: "deploy/tencent-lighthouse/examples/telegram-bridge.env.example", |
| 182 | secret_keys: &["TELEGRAM_BOT_TOKEN"], |
| 183 | setup_hint: "Create a bot with @BotFather in Telegram and copy the HTTP API token.", |
| 184 | install_dir: "/opt/codewhale/telegram-bridge", |
| 185 | }; |
| 186 | |
| 187 | /// Feishu/Lark bridge — app id + secret are the bridge credentials. |
| 188 | pub const FEISHU: BridgeSpec = BridgeSpec { |
| 189 | slug: "feishu", |
| 190 | display: "Feishu/Lark", |
| 191 | package_dir: "integrations/feishu-bridge", |
| 192 | service_unit: "codewhale-feishu-bridge.service", |
| 193 | env_template: "deploy/tencent-lighthouse/examples/feishu-bridge.env.example", |
| 194 | secret_keys: &["FEISHU_APP_ID", "FEISHU_APP_SECRET"], |
| 195 | setup_hint: "Create a custom app in the Feishu/Lark Open Platform; copy its App ID and App Secret.", |
| 196 | install_dir: "/opt/codewhale/bridge", |
| 197 | }; |
| 198 | |
| 199 | /// All registered bridges. Adding a bridge is one row here. |
| 200 | pub const BRIDGES: &[BridgeSpec] = &[FEISHU, TELEGRAM]; |
| 201 | |
| 202 | /// Look up a bridge by slug. |
| 203 | #[must_use] |
| 204 | pub fn bridge_by_slug(slug: &str) -> Option<&'static BridgeSpec> { |
| 205 | BRIDGES.iter().find(|b| b.slug.eq_ignore_ascii_case(slug)) |
| 206 | } |
| 207 | |
| 208 | // --------------------------------------------------------------------------- |
| 209 | // Cloud registry |
| 210 | // --------------------------------------------------------------------------- |
| 211 | |
| 212 | /// Tencent Lighthouse — native systemd, env-file secrets, CNB-driven deploy. |
| 213 | pub const LIGHTHOUSE: CloudTarget = CloudTarget { |
| 214 | slug: "lighthouse", |
| 215 | display: "Tencent Lighthouse", |
| 216 | secret_store: SecretStore::EnvFile, |
| 217 | install: InstallMethod::NativeSystemd, |
| 218 | default_region: "ap-hongkong", |
| 219 | cli_tool: "cnb", |
| 220 | plan: lighthouse_plan, |
| 221 | }; |
| 222 | |
| 223 | /// Azure VM — Docker image + Key Vault secrets via managed identity. |
| 224 | pub const AZURE: CloudTarget = CloudTarget { |
| 225 | slug: "azure", |
| 226 | display: "Azure VM", |
| 227 | secret_store: SecretStore::KeyVault, |
| 228 | install: InstallMethod::Docker, |
| 229 | default_region: "eastus", |
| 230 | cli_tool: "az", |
| 231 | plan: azure_plan, |
| 232 | }; |
| 233 | |
| 234 | /// DigitalOcean Droplet — native systemd, env-file secrets, cloud-init + doctl. |
| 235 | /// |
| 236 | /// Hunter-requested target. Modeled like Azure/Lighthouse: secrets in |
| 237 | /// `/etc/codewhale/*.env`, native+systemd install driven by a cloud-init |
| 238 | /// user-data file, and `doctl` for the create/destroy commands. The `plan()` |
| 239 | /// returns `doctl` `ProvisionStep` data, but since `--apply` is stubbed in the |
| 240 | /// MVP the plan is only printed inside the generated RUNBOOK. |
| 241 | pub const DIGITALOCEAN: CloudTarget = CloudTarget { |
| 242 | slug: "digitalocean", |
| 243 | display: "DigitalOcean Droplet", |
| 244 | secret_store: SecretStore::EnvFile, |
| 245 | install: InstallMethod::NativeSystemd, |
| 246 | default_region: "sfo3", |
| 247 | cli_tool: "doctl", |
| 248 | plan: digitalocean_plan, |
| 249 | }; |
| 250 | |
| 251 | /// All registered cloud targets. Adding a cloud is one row here. |
| 252 | pub const CLOUD_TARGETS: &[CloudTarget] = &[LIGHTHOUSE, AZURE, DIGITALOCEAN]; |
| 253 | |
| 254 | /// Look up a cloud target by slug. |
| 255 | #[must_use] |
| 256 | pub fn cloud_by_slug(slug: &str) -> Option<&'static CloudTarget> { |
| 257 | CLOUD_TARGETS |
| 258 | .iter() |
| 259 | .find(|c| c.slug.eq_ignore_ascii_case(slug)) |
| 260 | } |
| 261 | |
| 262 | // --------------------------------------------------------------------------- |
| 263 | // Cloud plans (data only — never executed in the MVP) |
| 264 | // --------------------------------------------------------------------------- |
| 265 | |
| 266 | fn lighthouse_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> { |
| 267 | // Lighthouse provisioning is driven by the existing CNB pipeline |
| 268 | // (deploy/tencent-lighthouse/cnb/*). The "plan" here is the CNB trigger plus |
| 269 | // the host-side service install the RUNBOOK walks the user through. |
| 270 | let restart_bridge = format!("codewhale-{}-bridge", inputs.bridge_slug); |
| 271 | vec![ |
| 272 | ProvisionStep::new( |
| 273 | "Render and commit the CNB pipeline (cnb.yml + tag_deploy.yml) for this deploy", |
| 274 | "git", |
| 275 | &["add", ".cnb.yml", ".cnb/tag_deploy.yml"], |
| 276 | ), |
| 277 | ProvisionStep::new( |
| 278 | "Trigger the CNB `web_trigger_lighthouse` button to build + ship to the host", |
| 279 | "cnb", |
| 280 | &["trigger", "web_trigger_lighthouse"], |
| 281 | ), |
| 282 | ProvisionStep::new( |
| 283 | "On the host: install both systemd units and start the runtime + bridge", |
| 284 | "bash", |
| 285 | &["scripts/tencent-lighthouse/install-services.sh"], |
| 286 | ), |
| 287 | ProvisionStep::new( |
| 288 | format!("Restart the bridge service after the deploy ({restart_bridge})"), |
| 289 | "systemctl", |
| 290 | &["restart", &restart_bridge], |
| 291 | ), |
| 292 | ] |
| 293 | } |
| 294 | |
| 295 | fn azure_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> { |
| 296 | let rg = format!("{}-rg", inputs.instance_name); |
| 297 | let vault = format!("{}-kv", inputs.instance_name); |
| 298 | let nsg = format!("{}-nsg", inputs.instance_name); |
| 299 | let provider_secret = format!("codewhale-{}-key", inputs.provider_slug); |
| 300 | vec![ |
| 301 | ProvisionStep::new( |
| 302 | "Create the resource group", |
| 303 | "az", |
| 304 | &[ |
| 305 | "group", |
| 306 | "create", |
| 307 | "--name", |
| 308 | &rg, |
| 309 | "--location", |
| 310 | &inputs.region, |
| 311 | ], |
| 312 | ), |
| 313 | ProvisionStep::new( |
| 314 | "Create the Key Vault that holds the provider key + runtime token", |
| 315 | "az", |
| 316 | &[ |
| 317 | "keyvault", |
| 318 | "create", |
| 319 | "--name", |
| 320 | &vault, |
| 321 | "--resource-group", |
| 322 | &rg, |
| 323 | "--location", |
| 324 | &inputs.region, |
| 325 | ], |
| 326 | ), |
| 327 | ProvisionStep::new( |
| 328 | format!( |
| 329 | "Store the {} provider key in Key Vault (value piped via stdin, not argv)", |
| 330 | inputs.provider_slug |
| 331 | ), |
| 332 | "az", |
| 333 | &[ |
| 334 | "keyvault", |
| 335 | "secret", |
| 336 | "set", |
| 337 | "--vault-name", |
| 338 | &vault, |
| 339 | "--name", |
| 340 | &provider_secret, |
| 341 | ], |
| 342 | ), |
| 343 | ProvisionStep::new( |
| 344 | format!( |
| 345 | "Create the VM from {} with cloud-init custom-data + a system-assigned identity", |
| 346 | inputs.image |
| 347 | ), |
| 348 | "az", |
| 349 | &[ |
| 350 | "vm", |
| 351 | "create", |
| 352 | "--resource-group", |
| 353 | &rg, |
| 354 | "--name", |
| 355 | &inputs.instance_name, |
| 356 | "--custom-data", |
| 357 | "cloud-init.yaml", |
| 358 | "--assign-identity", |
| 359 | // A Linux `vm create` otherwise adds an NSG rule admitting SSH |
| 360 | // from any address; the next step adds the caller-only rule. |
| 361 | "--nsg", |
| 362 | &nsg, |
| 363 | "--nsg-rule", |
| 364 | "NONE", |
| 365 | ], |
| 366 | ), |
| 367 | ProvisionStep::new( |
| 368 | "Allow SSH (22) from the caller IP only (replace <your-public-ip>); 7878 stays on 127.0.0.1", |
| 369 | "az", |
| 370 | &[ |
| 371 | "network", |
| 372 | "nsg", |
| 373 | "rule", |
| 374 | "create", |
| 375 | "--resource-group", |
| 376 | &rg, |
| 377 | "--nsg-name", |
| 378 | &nsg, |
| 379 | "--name", |
| 380 | "allow-ssh-from-caller", |
| 381 | "--priority", |
| 382 | "1000", |
| 383 | "--direction", |
| 384 | "Inbound", |
| 385 | "--access", |
| 386 | "Allow", |
| 387 | "--protocol", |
| 388 | "Tcp", |
| 389 | "--destination-port-ranges", |
| 390 | "22", |
| 391 | "--source-address-prefixes", |
| 392 | "<your-public-ip>/32", |
| 393 | ], |
| 394 | ), |
| 395 | ] |
| 396 | } |
| 397 | |
| 398 | fn digitalocean_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> { |
| 399 | // A Droplet stood up from a cloud-init user-data file, then the host-side |
| 400 | // service install. doctl is the cloud CLI; commands are data only here. |
| 401 | vec![ |
| 402 | ProvisionStep::new( |
| 403 | "Create the Droplet from the generated cloud-init user-data (native + systemd)", |
| 404 | "doctl", |
| 405 | &[ |
| 406 | "compute", |
| 407 | "droplet", |
| 408 | "create", |
| 409 | &inputs.instance_name, |
| 410 | "--region", |
| 411 | &inputs.region, |
| 412 | "--image", |
| 413 | "ubuntu-24-04-x64", |
| 414 | "--size", |
| 415 | "s-2vcpu-4gb", |
| 416 | "--user-data-file", |
| 417 | "cloud-init.yaml", |
| 418 | "--ssh-keys", |
| 419 | "<your-ssh-key-fingerprint>", |
| 420 | "--wait", |
| 421 | ], |
| 422 | ), |
| 423 | ProvisionStep::new( |
| 424 | "Read the Droplet's public IPv4 for the SSH step below", |
| 425 | "doctl", |
| 426 | &[ |
| 427 | "compute", |
| 428 | "droplet", |
| 429 | "get", |
| 430 | &inputs.instance_name, |
| 431 | "--format", |
| 432 | "PublicIPv4", |
| 433 | "--no-header", |
| 434 | ], |
| 435 | ), |
| 436 | ProvisionStep::new( |
| 437 | "On the Droplet: write /etc/codewhale/*.env, install both systemd units, enable --now", |
| 438 | "bash", |
| 439 | &["scripts/tencent-lighthouse/install-services.sh"], |
| 440 | ), |
| 441 | ] |
| 442 | } |
| 443 | |
| 444 | #[cfg(test)] |
| 445 | mod tests { |
| 446 | use super::*; |
| 447 | use std::collections::HashSet; |
| 448 | use std::path::{Path, PathBuf}; |
| 449 | |
| 450 | /// Repo root, resolved from this crate's manifest dir (`crates/tui`). |
| 451 | fn repo_root() -> PathBuf { |
| 452 | Path::new(env!("CARGO_MANIFEST_DIR")) |
| 453 | .parent() |
| 454 | .and_then(Path::parent) |
| 455 | .expect("crates/tui has a two-level parent (repo root)") |
| 456 | .to_path_buf() |
| 457 | } |
| 458 | |
| 459 | #[test] |
| 460 | fn bridge_slugs_are_unique() { |
| 461 | let mut seen = HashSet::new(); |
| 462 | for b in BRIDGES { |
| 463 | assert!(seen.insert(b.slug), "duplicate bridge slug: {}", b.slug); |
| 464 | } |
| 465 | assert_eq!(seen.len(), BRIDGES.len()); |
| 466 | } |
| 467 | |
| 468 | #[test] |
| 469 | fn cloud_slugs_are_unique() { |
| 470 | let mut seen = HashSet::new(); |
| 471 | for c in CLOUD_TARGETS { |
| 472 | assert!(seen.insert(c.slug), "duplicate cloud slug: {}", c.slug); |
| 473 | } |
| 474 | assert_eq!(seen.len(), CLOUD_TARGETS.len()); |
| 475 | } |
| 476 | |
| 477 | #[test] |
| 478 | fn digitalocean_is_registered() { |
| 479 | // Hunter explicitly wants DigitalOcean in the matrix. |
| 480 | assert!( |
| 481 | cloud_by_slug("digitalocean").is_some(), |
| 482 | "DigitalOcean must be a registered cloud target" |
| 483 | ); |
| 484 | let r#do = cloud_by_slug("digitalocean").unwrap(); |
| 485 | assert_eq!(r#do.secret_store, SecretStore::EnvFile); |
| 486 | assert_eq!(r#do.install, InstallMethod::NativeSystemd); |
| 487 | assert_eq!(r#do.cli_tool, "doctl"); |
| 488 | } |
| 489 | |
| 490 | #[test] |
| 491 | fn every_bridge_references_existing_files() { |
| 492 | let root = repo_root(); |
| 493 | for b in BRIDGES { |
| 494 | let pkg = root.join(b.package_dir); |
| 495 | assert!( |
| 496 | pkg.is_dir(), |
| 497 | "bridge {} package_dir missing: {}", |
| 498 | b.slug, |
| 499 | pkg.display() |
| 500 | ); |
| 501 | let unit = root |
| 502 | .join("deploy/tencent-lighthouse/systemd") |
| 503 | .join(b.service_unit); |
| 504 | assert!( |
| 505 | unit.is_file(), |
| 506 | "bridge {} service_unit missing: {}", |
| 507 | b.slug, |
| 508 | unit.display() |
| 509 | ); |
| 510 | let template = root.join(b.env_template); |
| 511 | assert!( |
| 512 | template.is_file(), |
| 513 | "bridge {} env_template missing: {}", |
| 514 | b.slug, |
| 515 | template.display() |
| 516 | ); |
| 517 | assert!( |
| 518 | !b.secret_keys.is_empty(), |
| 519 | "bridge {} must declare at least one secret key", |
| 520 | b.slug |
| 521 | ); |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn lookup_helpers_are_case_insensitive() { |
| 527 | assert_eq!(bridge_by_slug("TELEGRAM").map(|b| b.slug), Some("telegram")); |
| 528 | assert_eq!(cloud_by_slug("Azure").map(|c| c.slug), Some("azure")); |
| 529 | assert!(bridge_by_slug("nope").is_none()); |
| 530 | assert!(cloud_by_slug("nope").is_none()); |
| 531 | } |
| 532 | |
| 533 | #[test] |
| 534 | fn cloud_plans_return_ordered_steps_without_executing() { |
| 535 | // Build (never run) a plan for each cloud and assert on program+args. |
| 536 | let inputs = DeployInputs::default(); |
| 537 | for c in CLOUD_TARGETS { |
| 538 | let steps = (c.plan)(&inputs); |
| 539 | assert!(!steps.is_empty(), "cloud {} produced an empty plan", c.slug); |
| 540 | // First step's program is the cloud's own tooling or a host script. |
| 541 | assert!( |
| 542 | steps |
| 543 | .iter() |
| 544 | .all(|s| !s.program.is_empty() && !s.description.is_empty()), |
| 545 | "cloud {} has a malformed step", |
| 546 | c.slug |
| 547 | ); |
| 548 | } |
| 549 | |
| 550 | // DigitalOcean specifically drives doctl. |
| 551 | let do_steps = (DIGITALOCEAN.plan)(&inputs); |
| 552 | assert!( |
| 553 | do_steps.iter().any(|s| s.program == "doctl"), |
| 554 | "DigitalOcean plan must use doctl" |
| 555 | ); |
| 556 | // Azure specifically drives az. |
| 557 | let az_steps = (AZURE.plan)(&inputs); |
| 558 | assert!( |
| 559 | az_steps.iter().any(|s| s.program == "az"), |
| 560 | "Azure plan must use az" |
| 561 | ); |
| 562 | } |
| 563 | |
| 564 | #[test] |
| 565 | fn azure_plan_admits_ssh_from_the_caller_only() { |
| 566 | let steps = (AZURE.plan)(&DeployInputs::default()); |
| 567 | let arg_after = |step: &ProvisionStep, flag: &str| { |
| 568 | let at = step.args.iter().position(|arg| arg == flag)?; |
| 569 | step.args.get(at + 1).cloned() |
| 570 | }; |
| 571 | // `open-port` has no source restriction: it admits the whole internet. |
| 572 | assert!( |
| 573 | steps |
| 574 | .iter() |
| 575 | .all(|s| !s.args.iter().any(|a| a == "open-port")), |
| 576 | "Azure plan must not open a port to every source" |
| 577 | ); |
| 578 | let create = steps |
| 579 | .iter() |
| 580 | .find(|s| { |
| 581 | s.args |
| 582 | .starts_with(&["vm".to_string(), "create".to_string()]) |
| 583 | }) |
| 584 | .expect("vm create step"); |
| 585 | assert_eq!(arg_after(create, "--nsg-rule").as_deref(), Some("NONE")); |
| 586 | let ssh_rules: Vec<_> = steps |
| 587 | .iter() |
| 588 | .filter(|s| arg_after(s, "--destination-port-ranges").as_deref() == Some("22")) |
| 589 | .collect(); |
| 590 | assert_eq!(ssh_rules.len(), 1, "exactly one SSH rule"); |
| 591 | let source = arg_after(ssh_rules[0], "--source-address-prefixes").unwrap(); |
| 592 | assert!( |
| 593 | source.ends_with("/32") && !source.starts_with('*') && !source.starts_with("0.0.0.0"), |
| 594 | "SSH source must be a single caller address, got {source}" |
| 595 | ); |
| 596 | assert_eq!( |
| 597 | arg_after(ssh_rules[0], "--nsg-name"), |
| 598 | arg_after(create, "--nsg"), |
| 599 | "the rule must land on the NSG the VM was created with" |
| 600 | ); |
| 601 | } |
| 602 | |
| 603 | #[test] |
| 604 | fn display_command_redacts_secret_args() { |
| 605 | let mut step = |
| 606 | ProvisionStep::new("set secret", "az", &["keyvault", "secret", "set", "VALUE"]); |
| 607 | step.secret_args = vec![3]; |
| 608 | let rendered = step.display_command(); |
| 609 | assert!(rendered.contains("<redacted>")); |
| 610 | assert!(!rendered.contains("VALUE")); |
| 611 | } |
| 612 | } |
| 613 |