Skip to main content

greentic_start/
lib.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, anyhow};
6use clap::{Parser, Subcommand, ValueEnum};
7use serde::Deserialize;
8mod admin_server;
9mod bin_resolver;
10mod bundle_ref;
11mod capabilities;
12mod cards;
13mod cloudflared;
14mod component_qa_ops;
15pub mod config;
16mod demo_qa_bridge;
17mod dev_store_path;
18mod discovery;
19mod domains;
20mod event_router;
21mod gmap;
22mod http_ingress;
23mod ingress;
24mod ingress_dispatch;
25mod ingress_types;
26mod messaging_app;
27mod messaging_dto;
28mod messaging_egress;
29mod ngrok;
30mod offers;
31mod onboard;
32mod operator_i18n;
33mod operator_log;
34mod post_ingress_hooks;
35mod project;
36mod provider_config_envelope;
37mod qa_persist;
38mod runner_exec;
39mod runner_host;
40mod runner_integration;
41pub mod runtime;
42pub mod runtime_state;
43mod secret_name;
44mod secret_requirements;
45mod secret_value;
46mod secrets_backend;
47mod secrets_client;
48mod secrets_gate;
49mod secrets_manager;
50mod secrets_setup;
51mod services;
52mod setup_input;
53mod setup_to_formspec;
54mod startup_contract;
55mod state_layout;
56mod static_routes;
57mod subscriptions_universal;
58pub mod supervisor;
59mod timer_scheduler;
60mod webhook_updater;
61
62use runtime::NatsMode;
63
64const DEMO_DEFAULT_TENANT: &str = "demo";
65const DEMO_DEFAULT_TEAM: &str = "default";
66
67#[derive(Parser)]
68#[command(name = "greentic-start", version)]
69struct Cli {
70    #[arg(long, global = true)]
71    locale: Option<String>,
72    #[command(subcommand)]
73    command: Command,
74}
75
76#[derive(Subcommand)]
77enum Command {
78    Start(StartArgs),
79    Up(StartArgs),
80    Stop(StopArgs),
81    Restart(StartArgs),
82}
83
84#[derive(Parser, Clone)]
85struct StartArgs {
86    #[arg(long)]
87    bundle: Option<String>,
88    #[arg(long)]
89    tenant: Option<String>,
90    #[arg(long)]
91    team: Option<String>,
92    #[arg(long, hide = true, conflicts_with = "nats")]
93    no_nats: bool,
94    #[arg(long = "nats", value_enum, default_value_t = NatsModeArg::Off)]
95    nats: NatsModeArg,
96    #[arg(long)]
97    nats_url: Option<String>,
98    #[arg(long)]
99    config: Option<PathBuf>,
100    #[arg(long, value_enum, default_value_t = CloudflaredModeArg::On)]
101    cloudflared: CloudflaredModeArg,
102    #[arg(long)]
103    cloudflared_binary: Option<PathBuf>,
104    #[arg(long, value_enum, default_value_t = NgrokModeArg::Off)]
105    ngrok: NgrokModeArg,
106    #[arg(long)]
107    ngrok_binary: Option<PathBuf>,
108    #[arg(long)]
109    runner_binary: Option<PathBuf>,
110    #[arg(long, value_enum, value_delimiter = ',')]
111    restart: Vec<RestartTarget>,
112    #[arg(long, value_name = "DIR")]
113    log_dir: Option<PathBuf>,
114    #[arg(long, conflicts_with = "quiet")]
115    verbose: bool,
116    #[arg(long, conflicts_with = "verbose")]
117    quiet: bool,
118    #[arg(long, help = "Enable mTLS admin API endpoint")]
119    admin: bool,
120    #[arg(long, default_value = "8443", help = "Port for the admin API endpoint")]
121    admin_port: u16,
122    #[arg(
123        long,
124        value_name = "DIR",
125        help = "Directory containing admin TLS certs (server.crt, server.key, ca.crt)"
126    )]
127    admin_certs_dir: Option<PathBuf>,
128    #[arg(
129        long,
130        value_delimiter = ',',
131        help = "Comma-separated list of allowed client CNs (empty = allow all valid certs)"
132    )]
133    admin_allowed_clients: Vec<String>,
134}
135
136#[derive(Parser, Clone)]
137struct StopArgs {
138    #[arg(long)]
139    bundle: Option<String>,
140    #[arg(long)]
141    state_dir: Option<PathBuf>,
142    #[arg(long, default_value = DEMO_DEFAULT_TENANT)]
143    tenant: String,
144    #[arg(long, default_value = DEMO_DEFAULT_TEAM)]
145    team: String,
146}
147
148#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
149pub enum NatsModeArg {
150    Off,
151    On,
152    External,
153}
154
155impl From<NatsModeArg> for NatsMode {
156    fn from(value: NatsModeArg) -> Self {
157        match value {
158            NatsModeArg::Off => NatsMode::Off,
159            NatsModeArg::On => NatsMode::On,
160            NatsModeArg::External => NatsMode::External,
161        }
162    }
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
166pub enum CloudflaredModeArg {
167    On,
168    Off,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
172pub enum NgrokModeArg {
173    On,
174    Off,
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
178pub enum RestartTarget {
179    All,
180    Cloudflared,
181    Ngrok,
182    Nats,
183    Gateway,
184    Egress,
185    Subscriptions,
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct StartRequest {
190    pub bundle: Option<String>,
191    pub tenant: Option<String>,
192    pub team: Option<String>,
193    pub no_nats: bool,
194    pub nats: NatsModeArg,
195    pub nats_url: Option<String>,
196    pub config: Option<PathBuf>,
197    pub cloudflared: CloudflaredModeArg,
198    pub cloudflared_binary: Option<PathBuf>,
199    pub ngrok: NgrokModeArg,
200    pub ngrok_binary: Option<PathBuf>,
201    pub runner_binary: Option<PathBuf>,
202    pub restart: Vec<RestartTarget>,
203    pub log_dir: Option<PathBuf>,
204    pub verbose: bool,
205    pub quiet: bool,
206    pub admin: bool,
207    pub admin_port: u16,
208    pub admin_certs_dir: Option<PathBuf>,
209    pub admin_allowed_clients: Vec<String>,
210}
211
212#[derive(Clone, Debug, PartialEq, Eq)]
213pub struct StopRequest {
214    pub bundle: Option<String>,
215    pub state_dir: Option<PathBuf>,
216    pub tenant: String,
217    pub team: String,
218}
219
220pub fn run_start_request(request: StartRequest) -> anyhow::Result<()> {
221    run_start(request)
222}
223
224pub fn run_restart_request(mut request: StartRequest) -> anyhow::Result<()> {
225    if request.restart.is_empty() {
226        request.restart.push(RestartTarget::All);
227    }
228    run_start(request)
229}
230
231pub fn run_stop_request(request: StopRequest) -> anyhow::Result<()> {
232    let state_dir = resolve_state_dir(request.state_dir, request.bundle.as_deref())?;
233    runtime::demo_down_runtime(&state_dir, &request.tenant, &request.team, false)
234}
235
236pub fn run_from_env() -> anyhow::Result<()> {
237    let selected_locale = std::env::args().skip(1).collect::<Vec<_>>();
238    let args = normalize_args(selected_locale);
239    let cli = Cli::try_parse_from(args)?;
240    if let Some(locale) = cli.locale.as_deref() {
241        operator_i18n::set_locale(locale);
242    }
243
244    match cli.command {
245        Command::Start(args) | Command::Up(args) => {
246            run_start_request(start_request_from_args(args))
247        }
248        Command::Restart(args) => run_restart_request(start_request_from_args(args)),
249        Command::Stop(args) => run_stop_request(stop_request_from_args(args)),
250    }
251}
252
253fn normalize_args(raw_tail: Vec<String>) -> Vec<String> {
254    let mut out = vec!["greentic-start".to_string()];
255    let mut stripped_demo_prefix = false;
256    let mut skip_next_value = false;
257    for arg in raw_tail {
258        if skip_next_value {
259            skip_next_value = false;
260            out.push(arg);
261            continue;
262        }
263        if arg_takes_value(&arg) {
264            skip_next_value = true;
265            out.push(arg);
266            continue;
267        }
268        if !stripped_demo_prefix && !arg.starts_with('-') {
269            stripped_demo_prefix = true;
270            if arg == "demo" {
271                continue;
272            }
273        }
274        out.push(arg);
275    }
276
277    let known = ["start", "up", "stop", "restart"];
278    let mut first_pos = None;
279    let mut skip_next_value = false;
280    for arg in out.iter().skip(1) {
281        if skip_next_value {
282            skip_next_value = false;
283            continue;
284        }
285        if arg_takes_value(arg) {
286            skip_next_value = true;
287            continue;
288        }
289        if !arg.starts_with('-') {
290            first_pos = Some(arg.clone());
291            break;
292        }
293    }
294    let should_insert_start = match first_pos {
295        Some(cmd) => !known.contains(&cmd.as_str()),
296        None => true,
297    };
298    if should_insert_start {
299        out.insert(1, "start".to_string());
300    }
301    out
302}
303
304fn arg_takes_value(arg: &str) -> bool {
305    matches!(
306        arg,
307        "--locale"
308            | "--bundle"
309            | "--tenant"
310            | "--team"
311            | "--nats"
312            | "--nats-url"
313            | "--config"
314            | "--cloudflared"
315            | "--cloudflared-binary"
316            | "--ngrok"
317            | "--ngrok-binary"
318            | "--runner-binary"
319            | "--restart"
320            | "--log-dir"
321            | "--state-dir"
322            | "--admin-port"
323            | "--admin-certs-dir"
324            | "--admin-allowed-clients"
325    )
326}
327
328fn run_start(request: StartRequest) -> anyhow::Result<()> {
329    let restart: BTreeSet<String> = request.restart.iter().map(restart_name).collect();
330    let log_level = if request.quiet {
331        operator_log::Level::Warn
332    } else if request.verbose {
333        operator_log::Level::Debug
334    } else {
335        operator_log::Level::Info
336    };
337
338    let demo_paths = resolve_demo_paths(request.config.clone(), request.bundle.as_deref())?;
339    let config_path = demo_paths.config_path.clone();
340    let config_dir = demo_paths.root_dir.clone();
341    let state_dir = demo_paths.state_dir.clone();
342    let log_dir = operator_log::init(
343        request
344            .log_dir
345            .clone()
346            .unwrap_or_else(|| config_dir.join("logs")),
347        log_level,
348    )?;
349
350    let mut demo_config = load_runtime_demo_config(&demo_paths, &request)?;
351    apply_nats_overrides(&mut demo_config, &request);
352    let static_routes = startup_contract::inspect_bundle(&config_dir)?;
353    let configured_public_base_url = startup_contract::configured_public_base_url_from_env()?;
354    let tenant = demo_config.tenant.clone();
355    let team = demo_config.team.clone();
356
357    // Mutual exclusivity: if ngrok is explicitly enabled, disable cloudflared
358    // This allows `--ngrok on` to work without needing `--cloudflared off`
359    let effective_cloudflared = match (&request.cloudflared, &request.ngrok) {
360        // ngrok explicitly enabled → disable cloudflared (unless cloudflared also explicitly set)
361        (CloudflaredModeArg::On, NgrokModeArg::On) => {
362            operator_log::info(
363                module_path!(),
364                "ngrok enabled, disabling cloudflared (use --cloudflared on --ngrok off to override)",
365            );
366            CloudflaredModeArg::Off
367        }
368        (mode, _) => *mode,
369    };
370
371    let cloudflared = match effective_cloudflared {
372        CloudflaredModeArg::Off => None,
373        CloudflaredModeArg::On => {
374            let explicit = request.cloudflared_binary.clone();
375            let binary = bin_resolver::resolve_binary(
376                "cloudflared",
377                &bin_resolver::ResolveCtx {
378                    config_dir: config_dir.clone(),
379                    explicit_path: explicit,
380                },
381            )?;
382            Some(cloudflared::CloudflaredConfig {
383                binary,
384                local_port: demo_config.services.gateway.port,
385                extra_args: Vec::new(),
386                restart: restart.contains("cloudflared"),
387            })
388        }
389    };
390
391    let ngrok = match request.ngrok {
392        NgrokModeArg::Off => None,
393        NgrokModeArg::On => {
394            let explicit = request.ngrok_binary.clone();
395            let binary = bin_resolver::resolve_binary(
396                "ngrok",
397                &bin_resolver::ResolveCtx {
398                    config_dir: config_dir.clone(),
399                    explicit_path: explicit,
400                },
401            )?;
402            Some(ngrok::NgrokConfig {
403                binary,
404                local_port: demo_config.services.gateway.port,
405                extra_args: Vec::new(),
406                restart: restart.contains("ngrok"),
407            })
408        }
409    };
410
411    let handles = runtime::demo_up_services(
412        &config_path,
413        &demo_config,
414        &static_routes,
415        configured_public_base_url,
416        cloudflared,
417        ngrok,
418        &restart,
419        request.runner_binary.clone(),
420        &log_dir,
421        request.verbose,
422    )?;
423
424    let _admin_server = if request.admin {
425        let certs_dir =
426            resolve_admin_certs_dir(&config_dir, &state_dir, request.admin_certs_dir.as_deref())?;
427        let tls_config = greentic_setup::admin::AdminTlsConfig {
428            server_cert: certs_dir.join("server.crt"),
429            server_key: certs_dir.join("server.key"),
430            client_ca: certs_dir.join("ca.crt"),
431            allowed_clients: load_admin_allowed_clients(
432                &config_dir,
433                &request.admin_allowed_clients,
434            ),
435            port: request.admin_port,
436        };
437        let admin_config = admin_server::AdminServerConfig {
438            tls_config,
439            bundle_root: config_dir.clone(),
440        };
441        match admin_server::AdminServer::start(admin_config) {
442            Ok(server) => Some(server),
443            Err(err) => {
444                operator_log::error(
445                    module_path!(),
446                    format!("failed to start admin server: {err}"),
447                );
448                None
449            }
450        }
451    } else {
452        None
453    };
454
455    println!(
456        "demo start running (config={} tenant={} team={}); press Ctrl+C to stop",
457        config_path.display(),
458        tenant,
459        team
460    );
461    wait_for_ctrlc()?;
462    if let Some(server) = _admin_server {
463        let _ = server.stop();
464    }
465    handles.stop()?;
466    runtime::demo_down_runtime(&state_dir, &tenant, &team, false)?;
467    Ok(())
468}
469
470fn apply_nats_overrides(config: &mut config::DemoConfig, args: &StartRequest) {
471    let nats_mode = if args.no_nats {
472        NatsModeArg::Off
473    } else {
474        args.nats
475    };
476
477    if let Some(nats_url) = args.nats_url.as_ref() {
478        config.services.nats.url = nats_url.clone();
479    }
480
481    match nats_mode {
482        NatsModeArg::Off => {
483            config.services.nats.enabled = false;
484            config.services.nats.spawn.enabled = false;
485        }
486        NatsModeArg::On => {
487            config.services.nats.enabled = true;
488            config.services.nats.spawn.enabled = true;
489        }
490        NatsModeArg::External => {
491            config.services.nats.enabled = true;
492            config.services.nats.spawn.enabled = false;
493        }
494    }
495}
496
497fn start_request_from_args(args: StartArgs) -> StartRequest {
498    StartRequest {
499        bundle: args.bundle,
500        tenant: args.tenant,
501        team: args.team,
502        no_nats: args.no_nats,
503        nats: args.nats,
504        nats_url: args.nats_url,
505        config: args.config,
506        cloudflared: args.cloudflared,
507        cloudflared_binary: args.cloudflared_binary,
508        ngrok: args.ngrok,
509        ngrok_binary: args.ngrok_binary,
510        runner_binary: args.runner_binary,
511        restart: args.restart,
512        log_dir: args.log_dir,
513        verbose: args.verbose,
514        quiet: args.quiet,
515        admin: args.admin,
516        admin_port: args.admin_port,
517        admin_certs_dir: args.admin_certs_dir,
518        admin_allowed_clients: args.admin_allowed_clients,
519    }
520}
521
522#[derive(Clone, Debug, Default, Deserialize)]
523struct AdminRegistryDocument {
524    #[serde(default)]
525    admins: Vec<AdminRegistryEntry>,
526}
527
528#[derive(Clone, Debug, Deserialize)]
529struct AdminRegistryEntry {
530    client_cn: String,
531}
532
533fn load_admin_allowed_clients(bundle_root: &Path, explicit: &[String]) -> Vec<String> {
534    let mut allowed = explicit.to_vec();
535    if let Ok(raw) = std::env::var("GREENTIC_ADMIN_ALLOWED_CLIENTS") {
536        allowed.extend(
537            raw.split(',')
538                .map(str::trim)
539                .filter(|value| !value.is_empty())
540                .map(ToOwned::to_owned),
541        );
542    }
543    let path = bundle_root
544        .join(".greentic")
545        .join("admin")
546        .join("admins.json");
547    let Ok(raw) = std::fs::read_to_string(&path) else {
548        allowed.sort();
549        allowed.dedup();
550        return allowed;
551    };
552    let Ok(doc) = serde_json::from_str::<AdminRegistryDocument>(&raw) else {
553        allowed.sort();
554        allowed.dedup();
555        return allowed;
556    };
557    allowed.extend(
558        doc.admins
559            .into_iter()
560            .map(|entry| entry.client_cn)
561            .filter(|cn| !cn.trim().is_empty()),
562    );
563    allowed.sort();
564    allowed.dedup();
565    allowed
566}
567
568fn resolve_admin_certs_dir(
569    bundle_root: &Path,
570    state_dir: &Path,
571    explicit: Option<&Path>,
572) -> anyhow::Result<PathBuf> {
573    if let Some(path) = explicit {
574        return Ok(path.to_path_buf());
575    }
576
577    let bundle_local = bundle_root.join(".greentic").join("admin").join("certs");
578    if has_admin_cert_files(&bundle_local) {
579        return Ok(bundle_local);
580    }
581
582    let generated = maybe_materialize_admin_certs_from_env(state_dir)?;
583    if let Some(path) = generated {
584        return Ok(path);
585    }
586
587    Ok(bundle_local)
588}
589
590fn has_admin_cert_files(dir: &Path) -> bool {
591    ["ca.crt", "server.crt", "server.key"]
592        .into_iter()
593        .all(|name| dir.join(name).exists())
594}
595
596fn maybe_materialize_admin_certs_from_env(state_dir: &Path) -> anyhow::Result<Option<PathBuf>> {
597    let ca_pem = std::env::var("GREENTIC_ADMIN_CA_PEM").ok();
598    let cert_pem = std::env::var("GREENTIC_ADMIN_SERVER_CERT_PEM").ok();
599    let key_pem = std::env::var("GREENTIC_ADMIN_SERVER_KEY_PEM").ok();
600
601    let Some(ca_pem) = ca_pem else {
602        return Ok(None);
603    };
604    let Some(cert_pem) = cert_pem else {
605        return Ok(None);
606    };
607    let Some(key_pem) = key_pem else {
608        return Ok(None);
609    };
610
611    let cert_dir = state_dir.join("admin").join("certs");
612    fs::create_dir_all(&cert_dir).with_context(|| {
613        format!(
614            "failed to create generated admin cert directory {}",
615            cert_dir.display()
616        )
617    })?;
618    fs::write(cert_dir.join("ca.crt"), ca_pem)
619        .with_context(|| format!("failed to write {}", cert_dir.join("ca.crt").display()))?;
620    fs::write(cert_dir.join("server.crt"), cert_pem)
621        .with_context(|| format!("failed to write {}", cert_dir.join("server.crt").display()))?;
622    fs::write(cert_dir.join("server.key"), key_pem)
623        .with_context(|| format!("failed to write {}", cert_dir.join("server.key").display()))?;
624    Ok(Some(cert_dir))
625}
626
627fn stop_request_from_args(args: StopArgs) -> StopRequest {
628    StopRequest {
629        bundle: args.bundle,
630        state_dir: args.state_dir,
631        tenant: args.tenant,
632        team: args.team,
633    }
634}
635
636struct DemoPaths {
637    config_path: PathBuf,
638    root_dir: PathBuf,
639    state_dir: PathBuf,
640    config_source: DemoConfigSource,
641}
642
643#[derive(Clone, Copy, Debug, PartialEq, Eq)]
644enum DemoConfigSource {
645    LegacyFile,
646    NormalizedBundle,
647}
648
649fn resolve_demo_paths(
650    explicit: Option<PathBuf>,
651    bundle: Option<&str>,
652) -> anyhow::Result<DemoPaths> {
653    if let Some(path) = explicit {
654        let root_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
655        let config_source = resolve_runtime_config_source(&root_dir, &path)?;
656        return Ok(DemoPaths {
657            state_dir: root_dir.join("state"),
658            root_dir,
659            config_path: path,
660            config_source,
661        });
662    }
663    if let Some(bundle_ref) = bundle {
664        let resolved = bundle_ref::resolve_bundle_ref(bundle_ref)?;
665        let root_dir = resolved.bundle_dir;
666        let (config_path, config_source) = resolve_bundle_config_path(&root_dir)?;
667        return Ok(DemoPaths {
668            state_dir: root_dir.join("state"),
669            root_dir,
670            config_path,
671            config_source,
672        });
673    }
674    let cwd = std::env::current_dir()?;
675    let demo_path = cwd.join("demo").join("demo.yaml");
676    if demo_path.exists() {
677        let root_dir = demo_path.parent().unwrap_or(Path::new(".")).to_path_buf();
678        return Ok(DemoPaths {
679            state_dir: root_dir.join("state"),
680            root_dir,
681            config_path: demo_path,
682            config_source: DemoConfigSource::LegacyFile,
683        });
684    }
685    let fallback = cwd.join("greentic.operator.yaml");
686    if fallback.exists() {
687        return Ok(DemoPaths {
688            state_dir: cwd.join("state"),
689            root_dir: cwd,
690            config_path: fallback,
691            config_source: DemoConfigSource::LegacyFile,
692        });
693    }
694    Err(anyhow!(
695        "no demo config found; pass --config, --bundle, or create ./demo/demo.yaml"
696    ))
697}
698
699fn resolve_bundle_config_path(root_dir: &Path) -> anyhow::Result<(PathBuf, DemoConfigSource)> {
700    let demo = root_dir.join("greentic.demo.yaml");
701    if demo.exists() {
702        return Ok((demo, DemoConfigSource::LegacyFile));
703    }
704    let fallback = root_dir.join("greentic.operator.yaml");
705    if fallback.exists() {
706        return Ok((fallback, DemoConfigSource::LegacyFile));
707    }
708    let nested_demo = root_dir.join("demo").join("demo.yaml");
709    if nested_demo.exists() {
710        return Ok((nested_demo, DemoConfigSource::LegacyFile));
711    }
712    let normalized = root_dir.join("bundle.yaml");
713    if normalized.exists() && normalized_bundle_has_runtime_payload(root_dir) {
714        return Ok((normalized, DemoConfigSource::NormalizedBundle));
715    }
716    Err(anyhow!(
717        "bundle config not found under {}; expected greentic.demo.yaml, greentic.operator.yaml, demo/demo.yaml, or a normalized bundle rooted on bundle.yaml",
718        root_dir.display()
719    ))
720}
721
722fn resolve_runtime_config_source(root_dir: &Path, path: &Path) -> anyhow::Result<DemoConfigSource> {
723    let name = path
724        .file_name()
725        .and_then(|value| value.to_str())
726        .unwrap_or("");
727    if matches!(
728        name,
729        "greentic.demo.yaml" | "greentic.operator.yaml" | "demo.yaml"
730    ) {
731        return Ok(DemoConfigSource::LegacyFile);
732    }
733    if name == "bundle.yaml" && normalized_bundle_has_runtime_payload(root_dir) {
734        return Ok(DemoConfigSource::NormalizedBundle);
735    }
736    Err(anyhow!(
737        "unsupported startup config {}; expected greentic.demo.yaml, greentic.operator.yaml, demo/demo.yaml, or bundle.yaml for a normalized bundle",
738        path.display()
739    ))
740}
741
742fn normalized_bundle_has_runtime_payload(root_dir: &Path) -> bool {
743    root_dir.join("bundle-manifest.json").exists() || root_dir.join("resolved").is_dir()
744}
745
746/// Extended bundle.yaml structure with optional demo config fields
747#[derive(Debug, Deserialize)]
748struct ExtendedBundleYaml {
749    #[serde(default)]
750    tenant: Option<String>,
751    #[serde(default)]
752    team: Option<String>,
753    #[serde(default)]
754    providers: Option<std::collections::BTreeMap<String, config::DemoProviderConfig>>,
755}
756
757/// Result of loading extended bundle.yaml
758struct ExtendedBundleResult {
759    tenant: Option<String>,
760    team: Option<String>,
761    providers: Option<std::collections::BTreeMap<String, config::DemoProviderConfig>>,
762}
763
764/// Load extended config from bundle.yaml if present (tenant, team, providers)
765fn load_extended_bundle_config(
766    bundle_path: &Path,
767    root_dir: &Path,
768) -> anyhow::Result<Option<ExtendedBundleResult>> {
769    if !bundle_path.exists() {
770        return Ok(None);
771    }
772
773    let raw = std::fs::read_to_string(bundle_path)
774        .with_context(|| format!("read {}", bundle_path.display()))?;
775
776    let parsed: ExtendedBundleYaml = serde_yaml_bw::from_str(&raw)
777        .with_context(|| format!("parse extended config from {}", bundle_path.display()))?;
778
779    let mut providers = parsed.providers;
780
781    // Resolve relative pack paths to absolute paths
782    if let Some(ref mut provider_map) = providers {
783        for (_name, cfg) in provider_map.iter_mut() {
784            if let Some(pack) = cfg.pack.as_mut() {
785                let pack_path = Path::new(pack);
786                if !pack_path.is_absolute() {
787                    let resolved = root_dir.join(pack_path);
788                    *pack = resolved.to_string_lossy().to_string();
789                }
790            }
791        }
792    }
793
794    Ok(Some(ExtendedBundleResult {
795        tenant: parsed.tenant,
796        team: parsed.team,
797        providers,
798    }))
799}
800
801fn load_runtime_demo_config(
802    demo_paths: &DemoPaths,
803    request: &StartRequest,
804) -> anyhow::Result<config::DemoConfig> {
805    let mut demo_config = match demo_paths.config_source {
806        DemoConfigSource::LegacyFile => config::load_demo_config(&demo_paths.config_path)?,
807        DemoConfigSource::NormalizedBundle => {
808            let mut config = config::DemoConfig::default();
809            let mut tenant_from_bundle = false;
810            let mut team_from_bundle = false;
811
812            // Try to load extended config from bundle.yaml (tenant, team, providers)
813            if let Some(extended) =
814                load_extended_bundle_config(&demo_paths.config_path, &demo_paths.root_dir)?
815            {
816                // Use tenant/team from bundle.yaml if present
817                if let Some(tenant) = extended.tenant {
818                    config.tenant = tenant;
819                    tenant_from_bundle = true;
820                }
821                if let Some(team) = extended.team {
822                    config.team = team;
823                    team_from_bundle = true;
824                }
825                // Load providers
826                if extended.providers.is_some() {
827                    config.providers = extended.providers;
828                }
829            }
830
831            // Fallback to inferred target from resolved/ directory if tenant/team not set in bundle.yaml
832            if !tenant_from_bundle
833                && let Some(target) = infer_normalized_bundle_target(&demo_paths.root_dir)?
834            {
835                config.tenant = target.tenant;
836                if !team_from_bundle && let Some(team) = target.team {
837                    config.team = team;
838                }
839            }
840
841            config
842        }
843    };
844    apply_target_overrides(&mut demo_config, request);
845    Ok(demo_config)
846}
847
848fn apply_target_overrides(config: &mut config::DemoConfig, request: &StartRequest) {
849    if let Some(tenant) = request.tenant.as_ref() {
850        config.tenant = tenant.clone();
851    }
852    if let Some(team) = request.team.as_ref() {
853        config.team = team.clone();
854    }
855}
856
857#[derive(Debug, Deserialize)]
858struct BundleManifestSummary {
859    #[serde(default)]
860    resolved_targets: Vec<ResolvedTargetSummary>,
861}
862
863#[derive(Debug, Deserialize)]
864struct ResolvedTargetSummary {
865    tenant: String,
866    #[serde(default)]
867    team: Option<String>,
868}
869
870#[derive(Debug, Deserialize)]
871struct ResolvedManifestSummary {
872    tenant: String,
873    #[serde(default)]
874    team: Option<String>,
875}
876
877fn infer_normalized_bundle_target(
878    root_dir: &Path,
879) -> anyhow::Result<Option<ResolvedTargetSummary>> {
880    let manifest_path = root_dir.join("bundle-manifest.json");
881    if manifest_path.exists() {
882        let raw = std::fs::read_to_string(&manifest_path)
883            .with_context(|| format!("read {}", manifest_path.display()))?;
884        let parsed: BundleManifestSummary = serde_json::from_str(&raw)
885            .with_context(|| format!("parse {}", manifest_path.display()))?;
886        if let Some(target) = parsed.resolved_targets.into_iter().next() {
887            return Ok(Some(target));
888        }
889    }
890
891    let resolved_dir = root_dir.join("resolved");
892    if !resolved_dir.is_dir() {
893        return Ok(None);
894    }
895
896    let mut entries = std::fs::read_dir(&resolved_dir)?
897        .collect::<Result<Vec<_>, _>>()
898        .with_context(|| format!("read {}", resolved_dir.display()))?;
899    entries.sort_by_key(|entry| entry.path());
900
901    for entry in entries {
902        if !entry.file_type()?.is_file() {
903            continue;
904        }
905        let path = entry.path();
906        if path.extension().and_then(|ext| ext.to_str()) != Some("yaml") {
907            continue;
908        }
909        if let Some(target) = infer_target_from_resolved_file(&path)? {
910            return Ok(Some(target));
911        }
912    }
913
914    Ok(None)
915}
916
917fn infer_target_from_resolved_file(path: &Path) -> anyhow::Result<Option<ResolvedTargetSummary>> {
918    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
919    if let Ok(parsed) = serde_yaml_bw::from_str::<ResolvedManifestSummary>(&raw) {
920        return Ok(Some(ResolvedTargetSummary {
921            tenant: parsed.tenant,
922            team: parsed.team,
923        }));
924    }
925
926    let stem = path
927        .file_stem()
928        .and_then(|value| value.to_str())
929        .unwrap_or("");
930    if stem.is_empty() {
931        return Ok(None);
932    }
933    if let Some((tenant, team)) = stem.split_once('.') {
934        return Ok(Some(ResolvedTargetSummary {
935            tenant: tenant.to_string(),
936            team: Some(team.to_string()),
937        }));
938    }
939    Ok(Some(ResolvedTargetSummary {
940        tenant: stem.to_string(),
941        team: None,
942    }))
943}
944
945fn resolve_state_dir(state_dir: Option<PathBuf>, bundle: Option<&str>) -> anyhow::Result<PathBuf> {
946    if let Some(state_dir) = state_dir {
947        return Ok(state_dir);
948    }
949    if let Some(bundle_ref) = bundle {
950        let resolved = bundle_ref::resolve_bundle_ref(bundle_ref)?;
951        return Ok(resolved.bundle_dir.join("state"));
952    }
953    Ok(PathBuf::from("state"))
954}
955
956fn wait_for_ctrlc() -> anyhow::Result<()> {
957    let runtime =
958        tokio::runtime::Runtime::new().context("failed to spawn runtime for Ctrl+C listener")?;
959    runtime.block_on(async {
960        tokio::signal::ctrl_c()
961            .await
962            .map_err(|err| anyhow!("failed to wait for Ctrl+C: {err}"))
963    })
964}
965
966fn restart_name(target: &RestartTarget) -> String {
967    match target {
968        RestartTarget::All => "all",
969        RestartTarget::Cloudflared => "cloudflared",
970        RestartTarget::Ngrok => "ngrok",
971        RestartTarget::Nats => "nats",
972        RestartTarget::Gateway => "gateway",
973        RestartTarget::Egress => "egress",
974        RestartTarget::Subscriptions => "subscriptions",
975    }
976    .to_string()
977}
978
979#[cfg(test)]
980pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
981    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
982    LOCK.get_or_init(|| std::sync::Mutex::new(()))
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn normalize_args_inserts_start_for_short_form() {
991        let args = normalize_args(vec!["--tenant".into(), "demo".into()]);
992        assert_eq!(args[0], "greentic-start");
993        assert_eq!(args[1], "start");
994        assert_eq!(args[2], "--tenant");
995    }
996
997    #[test]
998    fn normalize_args_removes_demo_prefix() {
999        let args = normalize_args(vec!["demo".into(), "start".into(), "--tenant".into()]);
1000        assert_eq!(args[0], "greentic-start");
1001        assert_eq!(args[1], "start");
1002        assert_eq!(args[2], "--tenant");
1003    }
1004
1005    #[test]
1006    fn normalize_args_keeps_explicit_stop() {
1007        let args = normalize_args(vec!["stop".into(), "--tenant".into(), "demo".into()]);
1008        assert_eq!(args[0], "greentic-start");
1009        assert_eq!(args[1], "stop");
1010        assert_eq!(args[2], "--tenant");
1011        assert_eq!(args[3], "demo");
1012    }
1013
1014    #[test]
1015    fn normalize_args_strips_only_leading_demo_prefix() {
1016        let args = normalize_args(vec![
1017            "--locale".into(),
1018            "en".into(),
1019            "demo".into(),
1020            "start".into(),
1021            "--tenant".into(),
1022            "demo".into(),
1023        ]);
1024        assert_eq!(args[0], "greentic-start");
1025        assert_eq!(args[1], "--locale");
1026        assert_eq!(args[2], "en");
1027        assert_eq!(args[3], "start");
1028        assert_eq!(args[4], "--tenant");
1029        assert_eq!(args[5], "demo");
1030    }
1031
1032    #[test]
1033    fn normalize_args_keeps_runner_binary_value_with_demo_prefix() {
1034        let args = normalize_args(vec![
1035            "demo".into(),
1036            "start".into(),
1037            "--runner-binary".into(),
1038            "/tmp/runner".into(),
1039        ]);
1040        assert_eq!(args[0], "greentic-start");
1041        assert_eq!(args[1], "start");
1042        assert_eq!(args[2], "--runner-binary");
1043        assert_eq!(args[3], "/tmp/runner");
1044    }
1045
1046    #[test]
1047    fn apply_nats_overrides_disables_nats_for_flag() {
1048        let mut config = config::DemoConfig::default();
1049        let args = StartRequest {
1050            bundle: None,
1051            tenant: None,
1052            team: None,
1053            no_nats: false,
1054            nats: NatsModeArg::Off,
1055            nats_url: None,
1056            config: None,
1057            cloudflared: CloudflaredModeArg::Off,
1058            cloudflared_binary: None,
1059            ngrok: NgrokModeArg::Off,
1060            ngrok_binary: None,
1061            runner_binary: None,
1062            restart: Vec::new(),
1063            log_dir: None,
1064            verbose: false,
1065            quiet: false,
1066            admin: false,
1067            admin_port: 9443,
1068            admin_certs_dir: None,
1069            admin_allowed_clients: Vec::new(),
1070        };
1071        apply_nats_overrides(&mut config, &args);
1072        assert!(!config.services.nats.enabled);
1073        assert!(!config.services.nats.spawn.enabled);
1074    }
1075
1076    #[test]
1077    fn apply_nats_overrides_uses_external_url_without_spawn() {
1078        let mut config = config::DemoConfig::default();
1079        let args = StartRequest {
1080            bundle: None,
1081            tenant: None,
1082            team: None,
1083            no_nats: false,
1084            nats: NatsModeArg::External,
1085            nats_url: Some("nats://127.0.0.1:5555".into()),
1086            config: None,
1087            cloudflared: CloudflaredModeArg::Off,
1088            cloudflared_binary: None,
1089            ngrok: NgrokModeArg::Off,
1090            ngrok_binary: None,
1091            runner_binary: None,
1092            restart: Vec::new(),
1093            log_dir: None,
1094            verbose: false,
1095            quiet: false,
1096            admin: false,
1097            admin_port: 9443,
1098            admin_certs_dir: None,
1099            admin_allowed_clients: Vec::new(),
1100        };
1101        apply_nats_overrides(&mut config, &args);
1102        assert!(config.services.nats.enabled);
1103        assert!(!config.services.nats.spawn.enabled);
1104        assert_eq!(config.services.nats.url, "nats://127.0.0.1:5555");
1105    }
1106
1107    #[test]
1108    fn resolve_demo_paths_prefers_bundle_greentic_demo_yaml() {
1109        let temp = tempfile::tempdir().expect("tempdir");
1110        let bundle = temp.path();
1111        std::fs::write(
1112            bundle.join("greentic.demo.yaml"),
1113            "version: \"1\"\nproject_root: \"./\"\n",
1114        )
1115        .expect("write config");
1116
1117        let paths =
1118            resolve_demo_paths(None, Some(bundle.to_string_lossy().as_ref())).expect("paths");
1119        assert_eq!(paths.root_dir, bundle);
1120        assert_eq!(paths.config_path, bundle.join("greentic.demo.yaml"));
1121        assert_eq!(paths.state_dir, bundle.join("state"));
1122        assert_eq!(paths.config_source, DemoConfigSource::LegacyFile);
1123    }
1124
1125    #[test]
1126    fn resolve_demo_paths_accepts_file_bundle_ref() {
1127        let temp = tempfile::tempdir().expect("tempdir");
1128        let bundle = temp.path();
1129        std::fs::write(
1130            bundle.join("greentic.demo.yaml"),
1131            "version: \"1\"\nproject_root: \"./\"\n",
1132        )
1133        .expect("write config");
1134        let file_ref = format!("file://{}", bundle.display());
1135
1136        let paths = resolve_demo_paths(None, Some(&file_ref)).expect("paths");
1137        assert_eq!(paths.config_path, bundle.join("greentic.demo.yaml"));
1138    }
1139
1140    #[test]
1141    fn resolve_demo_paths_accepts_normalized_bundle_root() {
1142        let temp = tempfile::tempdir().expect("tempdir");
1143        let bundle = temp.path();
1144        std::fs::write(bundle.join("bundle.yaml"), "bundle_id: demo-bundle\n").expect("bundle");
1145        std::fs::create_dir_all(bundle.join("resolved")).expect("resolved dir");
1146        std::fs::write(bundle.join("resolved/default.yaml"), "tenant: default\n")
1147            .expect("resolved output");
1148
1149        let paths =
1150            resolve_demo_paths(None, Some(bundle.to_string_lossy().as_ref())).expect("paths");
1151        assert_eq!(paths.config_path, bundle.join("bundle.yaml"));
1152        assert_eq!(paths.config_source, DemoConfigSource::NormalizedBundle);
1153    }
1154
1155    #[test]
1156    fn load_runtime_demo_config_infers_normalized_bundle_target() {
1157        let temp = tempfile::tempdir().expect("tempdir");
1158        let bundle = temp.path();
1159        std::fs::write(bundle.join("bundle.yaml"), "bundle_id: demo-bundle\n").expect("bundle");
1160        std::fs::write(
1161            bundle.join("bundle-manifest.json"),
1162            r#"{"resolved_targets":[{"tenant":"default","team":null}]}"#,
1163        )
1164        .expect("manifest");
1165        let request = StartRequest {
1166            bundle: Some(bundle.display().to_string()),
1167            tenant: None,
1168            team: None,
1169            no_nats: false,
1170            nats: NatsModeArg::Off,
1171            nats_url: None,
1172            config: None,
1173            cloudflared: CloudflaredModeArg::On,
1174            cloudflared_binary: None,
1175            ngrok: NgrokModeArg::Off,
1176            ngrok_binary: None,
1177            runner_binary: None,
1178            restart: Vec::new(),
1179            log_dir: None,
1180            verbose: false,
1181            quiet: false,
1182            admin: false,
1183            admin_port: 9443,
1184            admin_certs_dir: None,
1185            admin_allowed_clients: Vec::new(),
1186        };
1187        let paths = DemoPaths {
1188            config_path: bundle.join("bundle.yaml"),
1189            root_dir: bundle.to_path_buf(),
1190            state_dir: bundle.join("state"),
1191            config_source: DemoConfigSource::NormalizedBundle,
1192        };
1193
1194        let config = load_runtime_demo_config(&paths, &request).expect("config");
1195        assert_eq!(config.tenant, "default");
1196        assert_eq!(config.team, DEMO_DEFAULT_TEAM);
1197    }
1198
1199    #[test]
1200    fn load_runtime_demo_config_applies_cli_target_overrides() {
1201        let temp = tempfile::tempdir().expect("tempdir");
1202        let bundle = temp.path();
1203        std::fs::write(bundle.join("bundle.yaml"), "bundle_id: demo-bundle\n").expect("bundle");
1204        std::fs::create_dir_all(bundle.join("resolved")).expect("resolved dir");
1205        std::fs::write(
1206            bundle.join("resolved/default.platform.yaml"),
1207            "tenant: default\nteam: platform\n",
1208        )
1209        .expect("resolved output");
1210        let request = StartRequest {
1211            bundle: Some(bundle.display().to_string()),
1212            tenant: Some("tenant-a".to_string()),
1213            team: Some("team-b".to_string()),
1214            no_nats: false,
1215            nats: NatsModeArg::Off,
1216            nats_url: None,
1217            config: None,
1218            cloudflared: CloudflaredModeArg::On,
1219            cloudflared_binary: None,
1220            ngrok: NgrokModeArg::Off,
1221            ngrok_binary: None,
1222            runner_binary: None,
1223            restart: Vec::new(),
1224            log_dir: None,
1225            verbose: false,
1226            quiet: false,
1227            admin: false,
1228            admin_port: 9443,
1229            admin_certs_dir: None,
1230            admin_allowed_clients: Vec::new(),
1231        };
1232        let paths = DemoPaths {
1233            config_path: bundle.join("bundle.yaml"),
1234            root_dir: bundle.to_path_buf(),
1235            state_dir: bundle.join("state"),
1236            config_source: DemoConfigSource::NormalizedBundle,
1237        };
1238
1239        let config = load_runtime_demo_config(&paths, &request).expect("config");
1240        assert_eq!(config.tenant, "tenant-a");
1241        assert_eq!(config.team, "team-b");
1242    }
1243
1244    #[test]
1245    fn resolve_state_dir_uses_bundle_state_when_requested() {
1246        let temp = tempfile::tempdir().expect("tempdir");
1247        let bundle = temp.path();
1248        let state_dir =
1249            resolve_state_dir(None, Some(bundle.to_string_lossy().as_ref())).expect("state dir");
1250        assert_eq!(state_dir, bundle.join("state"));
1251    }
1252
1253    #[test]
1254    fn resolve_admin_certs_dir_prefers_bundle_local_certs() {
1255        let temp = tempfile::tempdir().expect("tempdir");
1256        let bundle = temp.path();
1257        let certs = bundle.join(".greentic").join("admin").join("certs");
1258        std::fs::create_dir_all(&certs).expect("cert dir");
1259        std::fs::write(certs.join("ca.crt"), "ca").expect("ca");
1260        std::fs::write(certs.join("server.crt"), "cert").expect("cert");
1261        std::fs::write(certs.join("server.key"), "key").expect("key");
1262
1263        let resolved = resolve_admin_certs_dir(bundle, &bundle.join("state"), None).expect("dir");
1264        assert_eq!(resolved, certs);
1265    }
1266
1267    #[test]
1268    fn resolve_admin_certs_dir_materializes_env_pems_into_state_dir() {
1269        let temp = tempfile::tempdir().expect("tempdir");
1270        let bundle = temp.path();
1271        let state_dir = bundle.join("state");
1272
1273        unsafe {
1274            std::env::set_var("GREENTIC_ADMIN_CA_PEM", "ca-pem");
1275            std::env::set_var("GREENTIC_ADMIN_SERVER_CERT_PEM", "cert-pem");
1276            std::env::set_var("GREENTIC_ADMIN_SERVER_KEY_PEM", "key-pem");
1277        }
1278
1279        let resolved = resolve_admin_certs_dir(bundle, &state_dir, None).expect("dir");
1280        assert_eq!(resolved, state_dir.join("admin").join("certs"));
1281        assert_eq!(
1282            std::fs::read_to_string(resolved.join("ca.crt")).expect("read ca"),
1283            "ca-pem"
1284        );
1285        assert_eq!(
1286            std::fs::read_to_string(resolved.join("server.crt")).expect("read cert"),
1287            "cert-pem"
1288        );
1289        assert_eq!(
1290            std::fs::read_to_string(resolved.join("server.key")).expect("read key"),
1291            "key-pem"
1292        );
1293
1294        unsafe {
1295            std::env::remove_var("GREENTIC_ADMIN_CA_PEM");
1296            std::env::remove_var("GREENTIC_ADMIN_SERVER_CERT_PEM");
1297            std::env::remove_var("GREENTIC_ADMIN_SERVER_KEY_PEM");
1298        }
1299    }
1300
1301    #[test]
1302    fn load_admin_allowed_clients_merges_env_and_registry() {
1303        let temp = tempfile::tempdir().expect("tempdir");
1304        let bundle = temp.path();
1305        let admin_dir = bundle.join(".greentic").join("admin");
1306        std::fs::create_dir_all(&admin_dir).expect("admin dir");
1307        std::fs::write(
1308            admin_dir.join("admins.json"),
1309            r#"{"admins":[{"client_cn":"bundle-admin"}]}"#,
1310        )
1311        .expect("admins");
1312
1313        unsafe {
1314            std::env::set_var("GREENTIC_ADMIN_ALLOWED_CLIENTS", "env-a, env-b");
1315        }
1316
1317        let allowed = load_admin_allowed_clients(bundle, &["explicit-a".to_string()]);
1318        assert_eq!(
1319            allowed,
1320            vec![
1321                "bundle-admin".to_string(),
1322                "env-a".to_string(),
1323                "env-b".to_string(),
1324                "explicit-a".to_string()
1325            ]
1326        );
1327
1328        unsafe {
1329            std::env::remove_var("GREENTIC_ADMIN_ALLOWED_CLIENTS");
1330        }
1331    }
1332}