| 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/hmbown/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 provider_secret = format!("codewhale-{}-key", inputs.provider_slug); |
| 299 | vec![ |
| 300 | ProvisionStep::new( |
| 301 | "Create the resource group", |
| 302 | "az", |
| 303 | &[ |
| 304 | "group", |
| 305 | "create", |
| 306 | "--name", |
| 307 | &rg, |
| 308 | "--location", |
| 309 | &inputs.region, |
| 310 | ], |
| 311 | ), |
| 312 | ProvisionStep::new( |
| 313 | "Create the Key Vault that holds the provider key + runtime token", |
| 314 | "az", |
| 315 | &[ |
| 316 | "keyvault", |
| 317 | "create", |
| 318 | "--name", |
| 319 | &vault, |
| 320 | "--resource-group", |
| 321 | &rg, |
| 322 | "--location", |
| 323 | &inputs.region, |
| 324 | ], |
| 325 | ), |
| 326 | ProvisionStep::new( |
| 327 | format!( |
| 328 | "Store the {} provider key in Key Vault (value piped via stdin, not argv)", |
| 329 | inputs.provider_slug |
| 330 | ), |
| 331 | "az", |
| 332 | &[ |
| 333 | "keyvault", |
| 334 | "secret", |
| 335 | "set", |
| 336 | "--vault-name", |
| 337 | &vault, |
| 338 | "--name", |
| 339 | &provider_secret, |
| 340 | ], |
| 341 | ), |
| 342 | ProvisionStep::new( |
| 343 | format!( |
| 344 | "Create the VM from {} with cloud-init custom-data + a system-assigned identity", |
| 345 | inputs.image |
| 346 | ), |
| 347 | "az", |
| 348 | &[ |
| 349 | "vm", |
| 350 | "create", |
| 351 | "--resource-group", |
| 352 | &rg, |
| 353 | "--name", |
| 354 | &inputs.instance_name, |
| 355 | "--custom-data", |
| 356 | "cloud-init.yaml", |
| 357 | "--assign-identity", |
| 358 | ], |
| 359 | ), |
| 360 | ProvisionStep::new( |
| 361 | "Scope the NSG to SSH (22) from the caller IP only; 7878 stays on 127.0.0.1", |
| 362 | "az", |
| 363 | &[ |
| 364 | "vm", |
| 365 | "open-port", |
| 366 | "--resource-group", |
| 367 | &rg, |
| 368 | "--name", |
| 369 | &inputs.instance_name, |
| 370 | "--port", |
| 371 | "22", |
| 372 | ], |
| 373 | ), |
| 374 | ] |
| 375 | } |
| 376 | |
| 377 | fn digitalocean_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> { |
| 378 | // A Droplet stood up from a cloud-init user-data file, then the host-side |
| 379 | // service install. doctl is the cloud CLI; commands are data only here. |
| 380 | vec![ |
| 381 | ProvisionStep::new( |
| 382 | "Create the Droplet from the generated cloud-init user-data (native + systemd)", |
| 383 | "doctl", |
| 384 | &[ |
| 385 | "compute", |
| 386 | "droplet", |
| 387 | "create", |
| 388 | &inputs.instance_name, |
| 389 | "--region", |
| 390 | &inputs.region, |
| 391 | "--image", |
| 392 | "ubuntu-24-04-x64", |
| 393 | "--size", |
| 394 | "s-2vcpu-4gb", |
| 395 | "--user-data-file", |
| 396 | "cloud-init.yaml", |
| 397 | "--ssh-keys", |
| 398 | "<your-ssh-key-fingerprint>", |
| 399 | "--wait", |
| 400 | ], |
| 401 | ), |
| 402 | ProvisionStep::new( |
| 403 | "Read the Droplet's public IPv4 for the SSH step below", |
| 404 | "doctl", |
| 405 | &[ |
| 406 | "compute", |
| 407 | "droplet", |
| 408 | "get", |
| 409 | &inputs.instance_name, |
| 410 | "--format", |
| 411 | "PublicIPv4", |
| 412 | "--no-header", |
| 413 | ], |
| 414 | ), |
| 415 | ProvisionStep::new( |
| 416 | "On the Droplet: write /etc/codewhale/*.env, install both systemd units, enable --now", |
| 417 | "bash", |
| 418 | &["scripts/tencent-lighthouse/install-services.sh"], |
| 419 | ), |
| 420 | ] |
| 421 | } |
| 422 | |
| 423 | #[cfg(test)] |
| 424 | mod tests { |
| 425 | use super::*; |
| 426 | use std::collections::HashSet; |
| 427 | use std::path::{Path, PathBuf}; |
| 428 | |
| 429 | /// Repo root, resolved from this crate's manifest dir (`crates/tui`). |
| 430 | fn repo_root() -> PathBuf { |
| 431 | Path::new(env!("CARGO_MANIFEST_DIR")) |
| 432 | .parent() |
| 433 | .and_then(Path::parent) |
| 434 | .expect("crates/tui has a two-level parent (repo root)") |
| 435 | .to_path_buf() |
| 436 | } |
| 437 | |
| 438 | #[test] |
| 439 | fn bridge_slugs_are_unique() { |
| 440 | let mut seen = HashSet::new(); |
| 441 | for b in BRIDGES { |
| 442 | assert!(seen.insert(b.slug), "duplicate bridge slug: {}", b.slug); |
| 443 | } |
| 444 | assert_eq!(seen.len(), BRIDGES.len()); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn cloud_slugs_are_unique() { |
| 449 | let mut seen = HashSet::new(); |
| 450 | for c in CLOUD_TARGETS { |
| 451 | assert!(seen.insert(c.slug), "duplicate cloud slug: {}", c.slug); |
| 452 | } |
| 453 | assert_eq!(seen.len(), CLOUD_TARGETS.len()); |
| 454 | } |
| 455 | |
| 456 | #[test] |
| 457 | fn digitalocean_is_registered() { |
| 458 | // Hunter explicitly wants DigitalOcean in the matrix. |
| 459 | assert!( |
| 460 | cloud_by_slug("digitalocean").is_some(), |
| 461 | "DigitalOcean must be a registered cloud target" |
| 462 | ); |
| 463 | let r#do = cloud_by_slug("digitalocean").unwrap(); |
| 464 | assert_eq!(r#do.secret_store, SecretStore::EnvFile); |
| 465 | assert_eq!(r#do.install, InstallMethod::NativeSystemd); |
| 466 | assert_eq!(r#do.cli_tool, "doctl"); |
| 467 | } |
| 468 | |
| 469 | #[test] |
| 470 | fn every_bridge_references_existing_files() { |
| 471 | let root = repo_root(); |
| 472 | for b in BRIDGES { |
| 473 | let pkg = root.join(b.package_dir); |
| 474 | assert!( |
| 475 | pkg.is_dir(), |
| 476 | "bridge {} package_dir missing: {}", |
| 477 | b.slug, |
| 478 | pkg.display() |
| 479 | ); |
| 480 | let unit = root |
| 481 | .join("deploy/tencent-lighthouse/systemd") |
| 482 | .join(b.service_unit); |
| 483 | assert!( |
| 484 | unit.is_file(), |
| 485 | "bridge {} service_unit missing: {}", |
| 486 | b.slug, |
| 487 | unit.display() |
| 488 | ); |
| 489 | let template = root.join(b.env_template); |
| 490 | assert!( |
| 491 | template.is_file(), |
| 492 | "bridge {} env_template missing: {}", |
| 493 | b.slug, |
| 494 | template.display() |
| 495 | ); |
| 496 | assert!( |
| 497 | !b.secret_keys.is_empty(), |
| 498 | "bridge {} must declare at least one secret key", |
| 499 | b.slug |
| 500 | ); |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | #[test] |
| 505 | fn lookup_helpers_are_case_insensitive() { |
| 506 | assert_eq!(bridge_by_slug("TELEGRAM").map(|b| b.slug), Some("telegram")); |
| 507 | assert_eq!(cloud_by_slug("Azure").map(|c| c.slug), Some("azure")); |
| 508 | assert!(bridge_by_slug("nope").is_none()); |
| 509 | assert!(cloud_by_slug("nope").is_none()); |
| 510 | } |
| 511 | |
| 512 | #[test] |
| 513 | fn cloud_plans_return_ordered_steps_without_executing() { |
| 514 | // Build (never run) a plan for each cloud and assert on program+args. |
| 515 | let inputs = DeployInputs::default(); |
| 516 | for c in CLOUD_TARGETS { |
| 517 | let steps = (c.plan)(&inputs); |
| 518 | assert!(!steps.is_empty(), "cloud {} produced an empty plan", c.slug); |
| 519 | // First step's program is the cloud's own tooling or a host script. |
| 520 | assert!( |
| 521 | steps |
| 522 | .iter() |
| 523 | .all(|s| !s.program.is_empty() && !s.description.is_empty()), |
| 524 | "cloud {} has a malformed step", |
| 525 | c.slug |
| 526 | ); |
| 527 | } |
| 528 | |
| 529 | // DigitalOcean specifically drives doctl. |
| 530 | let do_steps = (DIGITALOCEAN.plan)(&inputs); |
| 531 | assert!( |
| 532 | do_steps.iter().any(|s| s.program == "doctl"), |
| 533 | "DigitalOcean plan must use doctl" |
| 534 | ); |
| 535 | // Azure specifically drives az. |
| 536 | let az_steps = (AZURE.plan)(&inputs); |
| 537 | assert!( |
| 538 | az_steps.iter().any(|s| s.program == "az"), |
| 539 | "Azure plan must use az" |
| 540 | ); |
| 541 | } |
| 542 | |
| 543 | #[test] |
| 544 | fn display_command_redacts_secret_args() { |
| 545 | let mut step = |
| 546 | ProvisionStep::new("set secret", "az", &["keyvault", "secret", "set", "VALUE"]); |
| 547 | step.secret_args = vec![3]; |
| 548 | let rendered = step.display_command(); |
| 549 | assert!(rendered.contains("<redacted>")); |
| 550 | assert!(!rendered.contains("VALUE")); |
| 551 | } |
| 552 | } |
| 553 |