返回 CodeWhale
registry.rs
根目录 / crates / tui / src / remote_setup / registry.rs
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
613 lines RUST