#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretStore {
EnvFile,
KeyVault,
}
impl SecretStore {
#[must_use]
pub fn label(self) -> &'static str {
match self {
SecretStore::EnvFile => "EnvFile (/etc/codewhale/*.env)",
SecretStore::KeyVault => "Key Vault (managed identity at boot)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMethod {
NativeSystemd,
Docker,
}
impl InstallMethod {
#[must_use]
pub fn label(self) -> &'static str {
match self {
InstallMethod::NativeSystemd => "native + systemd",
InstallMethod::Docker => "Docker image",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvisionStep {
pub description: String,
pub program: String,
pub args: Vec<String>,
pub secret_args: Vec<usize>,
}
impl ProvisionStep {
pub fn new(description: impl Into<String>, program: impl Into<String>, args: &[&str]) -> Self {
Self {
description: description.into(),
program: program.into(),
args: args.iter().map(|a| (*a).to_string()).collect(),
secret_args: Vec::new(),
}
}
#[must_use]
pub fn display_command(&self) -> String {
let mut parts = Vec::with_capacity(self.args.len() + 1);
parts.push(self.program.clone());
for (idx, arg) in self.args.iter().enumerate() {
if self.secret_args.contains(&idx) {
parts.push("<redacted>".to_string());
} else {
parts.push(arg.clone());
}
}
parts.join(" ")
}
}
#[derive(Debug, Clone)]
pub struct DeployInputs {
pub bridge_slug: String,
pub provider_slug: String,
pub region: String,
pub instance_name: String,
pub image: String,
}
impl Default for DeployInputs {
fn default() -> Self {
Self {
bridge_slug: "telegram".to_string(),
provider_slug: "deepseek".to_string(),
region: String::new(),
instance_name: "codewhale-remote".to_string(),
image: "ghcr.io/hmbown/codewhale:latest".to_string(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BridgeSpec {
pub slug: &'static str,
pub display: &'static str,
pub package_dir: &'static str,
pub service_unit: &'static str,
pub env_template: &'static str,
pub secret_keys: &'static [&'static str],
pub setup_hint: &'static str,
pub install_dir: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct CloudTarget {
pub slug: &'static str,
pub display: &'static str,
pub secret_store: SecretStore,
pub install: InstallMethod,
pub default_region: &'static str,
pub cli_tool: &'static str,
pub plan: fn(&DeployInputs) -> Vec<ProvisionStep>,
}
pub const TELEGRAM: BridgeSpec = BridgeSpec {
slug: "telegram",
display: "Telegram",
package_dir: "integrations/telegram-bridge",
service_unit: "codewhale-telegram-bridge.service",
env_template: "deploy/tencent-lighthouse/examples/telegram-bridge.env.example",
secret_keys: &["TELEGRAM_BOT_TOKEN"],
setup_hint: "Create a bot with @BotFather in Telegram and copy the HTTP API token.",
install_dir: "/opt/codewhale/telegram-bridge",
};
pub const FEISHU: BridgeSpec = BridgeSpec {
slug: "feishu",
display: "Feishu/Lark",
package_dir: "integrations/feishu-bridge",
service_unit: "codewhale-feishu-bridge.service",
env_template: "deploy/tencent-lighthouse/examples/feishu-bridge.env.example",
secret_keys: &["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
setup_hint: "Create a custom app in the Feishu/Lark Open Platform; copy its App ID and App Secret.",
install_dir: "/opt/codewhale/bridge",
};
pub const BRIDGES: &[BridgeSpec] = &[FEISHU, TELEGRAM];
#[must_use]
pub fn bridge_by_slug(slug: &str) -> Option<&'static BridgeSpec> {
BRIDGES.iter().find(|b| b.slug.eq_ignore_ascii_case(slug))
}
pub const LIGHTHOUSE: CloudTarget = CloudTarget {
slug: "lighthouse",
display: "Tencent Lighthouse",
secret_store: SecretStore::EnvFile,
install: InstallMethod::NativeSystemd,
default_region: "ap-hongkong",
cli_tool: "cnb",
plan: lighthouse_plan,
};
pub const AZURE: CloudTarget = CloudTarget {
slug: "azure",
display: "Azure VM",
secret_store: SecretStore::KeyVault,
install: InstallMethod::Docker,
default_region: "eastus",
cli_tool: "az",
plan: azure_plan,
};
pub const DIGITALOCEAN: CloudTarget = CloudTarget {
slug: "digitalocean",
display: "DigitalOcean Droplet",
secret_store: SecretStore::EnvFile,
install: InstallMethod::NativeSystemd,
default_region: "sfo3",
cli_tool: "doctl",
plan: digitalocean_plan,
};
pub const CLOUD_TARGETS: &[CloudTarget] = &[LIGHTHOUSE, AZURE, DIGITALOCEAN];
#[must_use]
pub fn cloud_by_slug(slug: &str) -> Option<&'static CloudTarget> {
CLOUD_TARGETS
.iter()
.find(|c| c.slug.eq_ignore_ascii_case(slug))
}
fn lighthouse_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> {
let restart_bridge = format!("codewhale-{}-bridge", inputs.bridge_slug);
vec![
ProvisionStep::new(
"Render and commit the CNB pipeline (cnb.yml + tag_deploy.yml) for this deploy",
"git",
&["add", ".cnb.yml", ".cnb/tag_deploy.yml"],
),
ProvisionStep::new(
"Trigger the CNB `web_trigger_lighthouse` button to build + ship to the host",
"cnb",
&["trigger", "web_trigger_lighthouse"],
),
ProvisionStep::new(
"On the host: install both systemd units and start the runtime + bridge",
"bash",
&["scripts/tencent-lighthouse/install-services.sh"],
),
ProvisionStep::new(
format!("Restart the bridge service after the deploy ({restart_bridge})"),
"systemctl",
&["restart", &restart_bridge],
),
]
}
fn azure_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> {
let rg = format!("{}-rg", inputs.instance_name);
let vault = format!("{}-kv", inputs.instance_name);
let provider_secret = format!("codewhale-{}-key", inputs.provider_slug);
vec![
ProvisionStep::new(
"Create the resource group",
"az",
&[
"group",
"create",
"--name",
&rg,
"--location",
&inputs.region,
],
),
ProvisionStep::new(
"Create the Key Vault that holds the provider key + runtime token",
"az",
&[
"keyvault",
"create",
"--name",
&vault,
"--resource-group",
&rg,
"--location",
&inputs.region,
],
),
ProvisionStep::new(
format!(
"Store the {} provider key in Key Vault (value piped via stdin, not argv)",
inputs.provider_slug
),
"az",
&[
"keyvault",
"secret",
"set",
"--vault-name",
&vault,
"--name",
&provider_secret,
],
),
ProvisionStep::new(
format!(
"Create the VM from {} with cloud-init custom-data + a system-assigned identity",
inputs.image
),
"az",
&[
"vm",
"create",
"--resource-group",
&rg,
"--name",
&inputs.instance_name,
"--custom-data",
"cloud-init.yaml",
"--assign-identity",
],
),
ProvisionStep::new(
"Scope the NSG to SSH (22) from the caller IP only; 7878 stays on 127.0.0.1",
"az",
&[
"vm",
"open-port",
"--resource-group",
&rg,
"--name",
&inputs.instance_name,
"--port",
"22",
],
),
]
}
fn digitalocean_plan(inputs: &DeployInputs) -> Vec<ProvisionStep> {
vec![
ProvisionStep::new(
"Create the Droplet from the generated cloud-init user-data (native + systemd)",
"doctl",
&[
"compute",
"droplet",
"create",
&inputs.instance_name,
"--region",
&inputs.region,
"--image",
"ubuntu-24-04-x64",
"--size",
"s-2vcpu-4gb",
"--user-data-file",
"cloud-init.yaml",
"--ssh-keys",
"<your-ssh-key-fingerprint>",
"--wait",
],
),
ProvisionStep::new(
"Read the Droplet's public IPv4 for the SSH step below",
"doctl",
&[
"compute",
"droplet",
"get",
&inputs.instance_name,
"--format",
"PublicIPv4",
"--no-header",
],
),
ProvisionStep::new(
"On the Droplet: write /etc/codewhale/*.env, install both systemd units, enable --now",
"bash",
&["scripts/tencent-lighthouse/install-services.sh"],
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crates/tui has a two-level parent (repo root)")
.to_path_buf()
}
#[test]
fn bridge_slugs_are_unique() {
let mut seen = HashSet::new();
for b in BRIDGES {
assert!(seen.insert(b.slug), "duplicate bridge slug: {}", b.slug);
}
assert_eq!(seen.len(), BRIDGES.len());
}
#[test]
fn cloud_slugs_are_unique() {
let mut seen = HashSet::new();
for c in CLOUD_TARGETS {
assert!(seen.insert(c.slug), "duplicate cloud slug: {}", c.slug);
}
assert_eq!(seen.len(), CLOUD_TARGETS.len());
}
#[test]
fn digitalocean_is_registered() {
assert!(
cloud_by_slug("digitalocean").is_some(),
"DigitalOcean must be a registered cloud target"
);
let r#do = cloud_by_slug("digitalocean").unwrap();
assert_eq!(r#do.secret_store, SecretStore::EnvFile);
assert_eq!(r#do.install, InstallMethod::NativeSystemd);
assert_eq!(r#do.cli_tool, "doctl");
}
#[test]
fn every_bridge_references_existing_files() {
let root = repo_root();
for b in BRIDGES {
let pkg = root.join(b.package_dir);
assert!(
pkg.is_dir(),
"bridge {} package_dir missing: {}",
b.slug,
pkg.display()
);
let unit = root
.join("deploy/tencent-lighthouse/systemd")
.join(b.service_unit);
assert!(
unit.is_file(),
"bridge {} service_unit missing: {}",
b.slug,
unit.display()
);
let template = root.join(b.env_template);
assert!(
template.is_file(),
"bridge {} env_template missing: {}",
b.slug,
template.display()
);
assert!(
!b.secret_keys.is_empty(),
"bridge {} must declare at least one secret key",
b.slug
);
}
}
#[test]
fn lookup_helpers_are_case_insensitive() {
assert_eq!(bridge_by_slug("TELEGRAM").map(|b| b.slug), Some("telegram"));
assert_eq!(cloud_by_slug("Azure").map(|c| c.slug), Some("azure"));
assert!(bridge_by_slug("nope").is_none());
assert!(cloud_by_slug("nope").is_none());
}
#[test]
fn cloud_plans_return_ordered_steps_without_executing() {
let inputs = DeployInputs::default();
for c in CLOUD_TARGETS {
let steps = (c.plan)(&inputs);
assert!(!steps.is_empty(), "cloud {} produced an empty plan", c.slug);
assert!(
steps
.iter()
.all(|s| !s.program.is_empty() && !s.description.is_empty()),
"cloud {} has a malformed step",
c.slug
);
}
let do_steps = (DIGITALOCEAN.plan)(&inputs);
assert!(
do_steps.iter().any(|s| s.program == "doctl"),
"DigitalOcean plan must use doctl"
);
let az_steps = (AZURE.plan)(&inputs);
assert!(
az_steps.iter().any(|s| s.program == "az"),
"Azure plan must use az"
);
}
#[test]
fn display_command_redacts_secret_args() {
let mut step =
ProvisionStep::new("set secret", "az", &["keyvault", "secret", "set", "VALUE"]);
step.secret_args = vec![3];
let rendered = step.display_command();
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains("VALUE"));
}
}