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 let effective_cloudflared = match (&request.cloudflared, &request.ngrok) {
360 (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#[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
757struct ExtendedBundleResult {
759 tenant: Option<String>,
760 team: Option<String>,
761 providers: Option<std::collections::BTreeMap<String, config::DemoProviderConfig>>,
762}
763
764fn 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 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 if let Some(extended) =
814 load_extended_bundle_config(&demo_paths.config_path, &demo_paths.root_dir)?
815 {
816 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 if extended.providers.is_some() {
827 config.providers = extended.providers;
828 }
829 }
830
831 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}