返回 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/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
553 lines RUST