use std::collections::BTreeSet;
use std::path::PathBuf;
use anyhow::{Context, anyhow};
use clap::Parser;
use clap::error::ErrorKind;
mod admin_certs;
mod admin_server;
mod bin_resolver;
mod bundle_config;
mod bundle_ref;
mod capabilities;
mod cards;
mod cli_args;
mod cloudflared;
mod component_qa_ops;
pub mod config;
mod demo_qa_bridge;
mod dependency_resolver;
mod deployment_routes;
mod dev_store_path;
mod discovery;
mod doctor;
mod domains;
mod endpoint_admit;
mod endpoint_resolver;
mod event_router;
mod extension_resolver;
pub(crate) mod flow_log;
mod gmap;
mod http_ingress;
mod http_routes;
mod identify_payload;
mod ingress;
mod ingress_dispatch;
mod ingress_types;
mod messaging_app;
mod messaging_dto;
mod messaging_egress;
mod ngrok;
pub mod notifier;
mod offers;
mod onboard;
mod operator_i18n;
mod operator_log;
#[doc(hidden)]
pub mod perf_harness;
mod port_utils;
mod post_ingress_hooks;
mod project;
mod provider_auth;
pub mod provider_config_envelope;
mod qa_persist;
mod revision_boot;
mod revision_dispatcher;
mod revision_drain;
pub mod revision_health_gate;
mod revision_pin;
mod revision_reload;
mod revision_serve;
mod revision_webhook_register;
mod rollout_telemetry;
mod runner_exec;
mod runner_host;
mod runner_integration;
pub mod runtime;
mod runtime_config;
mod runtime_refs_store;
pub mod runtime_state;
mod secret_name;
mod secret_requirements;
mod secret_value;
mod secrets_backend;
mod secrets_client;
mod secrets_gate;
mod secrets_manager;
mod secrets_setup;
mod services;
mod setup_input;
mod setup_to_formspec;
mod startup_contract;
mod state_layout;
mod static_routes;
mod subscription_updater;
mod subscriptions_universal;
pub mod supervisor;
#[cfg(test)]
mod test_fixtures;
mod timer_scheduler;
mod tunnel_prompt;
mod warmup;
mod webhook_secret_resolver;
mod webhook_updater;
#[doc(hidden)]
pub mod ws_test_support;
use cli_args::{
Cli, Command, normalize_args, restart_name, start_request_from_args, stop_request_from_args,
};
pub use cli_args::{
CloudflaredModeArg, NatsModeArg, NgrokModeArg, RestartTarget, StartRequest, StopRequest,
};
const DEMO_DEFAULT_TENANT: &str = "demo";
const DEMO_DEFAULT_TEAM: &str = "default";
pub const DEFAULT_ENV_ID: &str = "local";
pub const LEGACY_ENV_ID: &str = "dev";
pub const DISABLE_ALIAS_ENV_VAR: &str = "GREENTIC_DISABLE_DEV_ALIAS";
pub fn resolve_env(override_env: Option<&str>) -> String {
let raw = override_env
.map(|v| v.to_string())
.or_else(|| std::env::var("GREENTIC_ENV").ok())
.unwrap_or_else(|| DEFAULT_ENV_ID.to_string());
compat_alias::apply_dev_alias(&raw)
}
mod compat_alias {
use std::sync::atomic::{AtomicBool, Ordering};
use super::{DEFAULT_ENV_ID, DISABLE_ALIAS_ENV_VAR, LEGACY_ENV_ID};
static WARNED: AtomicBool = AtomicBool::new(false);
pub fn apply_dev_alias(env: &str) -> String {
if env != LEGACY_ENV_ID {
return env.to_string();
}
if alias_disabled() {
panic!(
"environment `{LEGACY_ENV_ID}` is no longer accepted (set via {DISABLE_ALIAS_ENV_VAR}=1). \
Migrate to `{DEFAULT_ENV_ID}` via `gtc op env migrate-dev {DEFAULT_ENV_ID} --check` then `--apply`, \
or pass `--env {DEFAULT_ENV_ID}` / unset $GREENTIC_ENV.",
);
}
if !WARNED.swap(true, Ordering::SeqCst) {
tracing::warn!(
target: "greentic_start::compat_alias",
legacy = LEGACY_ENV_ID,
target_env = DEFAULT_ENV_ID,
"env `{LEGACY_ENV_ID}` is deprecated; resolving as `{DEFAULT_ENV_ID}` for this process. \
Plan the migration with `gtc op env migrate-dev {DEFAULT_ENV_ID} --check`; \
set {DISABLE_ALIAS_ENV_VAR}=1 to hard-fail on `{LEGACY_ENV_ID}` in CI.",
);
}
DEFAULT_ENV_ID.to_string()
}
fn alias_disabled() -> bool {
std::env::var(DISABLE_ALIAS_ENV_VAR)
.ok()
.map(|v| {
let v = v.trim().to_ascii_lowercase();
matches!(v.as_str(), "1" | "true" | "yes" | "on")
})
.unwrap_or(false)
}
#[cfg(test)]
pub(super) fn reset_warning_latch_for_tests() {
WARNED.store(false, Ordering::SeqCst);
}
}
pub fn run_start_request(request: StartRequest) -> anyhow::Result<()> {
run_start(request)
}
pub fn run_restart_request(mut request: StartRequest) -> anyhow::Result<()> {
if request.restart.is_empty() {
request.restart.push(RestartTarget::All);
}
run_start(request)
}
pub fn run_stop_request(request: StopRequest) -> anyhow::Result<()> {
let state_dir = resolve_state_dir(request.state_dir, request.bundle.as_deref())?;
runtime::demo_down_runtime(&state_dir, &request.tenant, &request.team, false)
}
pub fn run_from_env() -> anyhow::Result<()> {
let raw_tail: Vec<String> = std::env::args().skip(1).collect();
let tunnel_explicit = raw_tail
.iter()
.any(|a| a.starts_with("--cloudflared") || a.starts_with("--ngrok"));
let args = normalize_args(raw_tail);
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(err)
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) =>
{
print!("{err}");
return Ok(());
}
Err(err) => return Err(err.into()),
};
if let Some(locale) = cli.locale.as_deref() {
operator_i18n::set_locale(locale);
}
match cli.command {
Command::Start(args) | Command::Up(args) => {
run_start_request(start_request_from_args(args, tunnel_explicit))
}
Command::Restart(args) => {
run_restart_request(start_request_from_args(args, tunnel_explicit))
}
Command::Stop(args) => run_stop_request(stop_request_from_args(args)),
Command::Warmup(args) => crate::warmup::run_warmup_request(crate::warmup::WarmupRequest {
bundle: args.bundle,
cache_dir: args.cache_dir,
strict: args.strict,
}),
Command::Doctor(args) => {
let has_errors = crate::doctor::run_doctor(args)?;
if has_errors {
std::process::exit(1);
}
Ok(())
}
}
}
fn run_start(mut request: StartRequest) -> anyhow::Result<()> {
unsafe {
std::env::set_var("GREENTIC_PROVIDER_CORE_ONLY", "0");
}
if std::env::var("GREENTIC_ENV").is_err() {
unsafe {
std::env::set_var("GREENTIC_ENV", DEFAULT_ENV_ID);
}
}
bootstrap_local_environment()?;
if request.bundle.is_none() && request.config.is_none() {
let env_id = resolve_env(None);
let rc = runtime_config::load_or_empty(&env_id)?;
let store_root = greentic_deployer::environment::LocalFsStore::default_root()
.context("cannot determine the default environment store root (no home directory)")?;
let env_dir = runtime_config::env_dir_in(&store_root, &env_id)?;
let log_level = if request.quiet {
operator_log::Level::Warn
} else if request.verbose {
operator_log::Level::Debug
} else {
operator_log::Level::Info
};
let log_dir = operator_log::init(env_dir.join("logs"), log_level)?;
let _trace_guard = init_trace_log(&log_dir);
let secrets: crate::secrets_gate::DynSecretsManager =
std::sync::Arc::new(crate::secrets_client::SecretsClient::open(&env_dir)?);
let watcher_secrets = std::sync::Arc::clone(&secrets);
let env_store = greentic_deployer::environment::LocalFsStore::new(store_root.clone());
let env_typed = greentic_types::EnvId::new(&env_id)
.with_context(|| format!("invalid environment id `{env_id}`"))?;
let environment =
greentic_deployer::environment::EnvironmentStore::load(&env_store, &env_typed)
.with_context(|| format!("loading environment `{env_id}` for bundle-less boot"))?;
let runtime_refs_store =
crate::runtime_refs_store::EnvironmentRuntimeStore::open(&env_dir, env_typed.clone())
.with_context(|| format!("opening runtime.json snapshot for env `{env_id}`"))?;
let runtime_ref_resolver: std::sync::Arc<
dyn greentic_runner_host::runtime_refs::RuntimeRefResolver,
> = std::sync::Arc::new(crate::runtime_refs_store::StartRuntimeRefResolver::new(
std::sync::Arc::clone(&runtime_refs_store),
));
let activation_rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("building runtime for revision activation")?;
let activation = activation_rt.block_on(revision_boot::activate_runtime_config(
&store_root,
&rc,
secrets,
&environment,
std::sync::Arc::clone(&runtime_ref_resolver),
))?;
let revision_boot::RuntimeConfigActivation { host, routing } = activation;
let bind_addr = revision_serve::resolve_bind_addr(Some(&environment.host_config));
let activation = std::sync::Arc::new(revision_serve::Activation {
host: std::sync::Arc::new(host),
routing: std::sync::Arc::new(routing),
});
let server = revision_serve::RevisionServer::start(revision_serve::RevisionServeConfig {
bind_addr,
activation: std::sync::Arc::clone(&activation),
})
.context("starting the revision ingress server")?;
let listen = std::net::SocketAddr::new(bind_addr.ip(), server.actual_port());
let (deployment_count, revision_count) = server.counts();
let banner = if revision_count == 0 {
format!(
"no bundles attached to env `{}` — serving probes only on http://{listen} \
(attach a bundle with `gtc op bundles add`)",
rc.env_id
)
} else {
format!(
"serving {revision_count} revision(s) for env `{}` across {deployment_count} \
deployment(s) on http://{listen}",
rc.env_id
)
};
operator_log::info(module_path!(), banner.clone());
println!("\n{banner}. Press Ctrl+C to stop.");
let public_base_url = startup_contract::resolve_public_base_url(&environment)?;
if revision_count > 0 {
let boot_activation = std::sync::Arc::clone(&activation);
let boot_url = public_base_url.clone();
let boot_env = environment;
activation_rt.spawn(async move {
revision_webhook_register::register_new_model_webhooks(
&boot_activation,
&boot_env,
boot_url.as_deref(),
)
.await;
});
}
drop(activation);
let server = std::sync::Arc::new(server);
let snapshot_store_for_watcher = std::sync::Arc::clone(&runtime_refs_store);
let watcher = revision_reload::spawn_runtime_config_watcher(
env_dir.clone(),
revision_reload::DEFAULT_DEBOUNCE,
std::time::Duration::from_secs(30),
std::sync::Arc::clone(&server),
revision_reload::default_rebuild(
store_root.clone(),
env_id.clone(),
watcher_secrets,
std::sync::Arc::clone(&runtime_ref_resolver),
activation_rt.handle().clone(),
),
revision_webhook_register::post_reload_registration(
store_root.clone(),
env_id.clone(),
activation_rt.handle().clone(),
),
move || snapshot_store_for_watcher.reload(),
)
.context("spawning runtime-config watcher")?;
if let Err(err) = activation_rt.block_on(tokio::signal::ctrl_c()) {
operator_log::warn(
module_path!(),
format!("revision serving Ctrl+C listener error: {err}"),
);
}
drop(watcher);
match std::sync::Arc::try_unwrap(server) {
Ok(server) => server.stop()?,
Err(_arc) => {
operator_log::warn(
module_path!(),
"RevisionServer Arc still has consumers at shutdown — \
skipping graceful stop(); the listener thread will be \
terminated on process exit.",
);
}
}
return Ok(());
}
let restart: BTreeSet<String> = request.restart.iter().map(restart_name).collect();
let log_level = if request.quiet {
operator_log::Level::Warn
} else if request.verbose {
operator_log::Level::Debug
} else {
operator_log::Level::Info
};
let early_log_dir = request.log_dir.clone().unwrap_or_else(|| {
request
.bundle
.as_deref()
.map(|b| PathBuf::from(b).join("logs"))
.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("logs")
})
});
let log_dir = operator_log::init(early_log_dir, log_level)?;
let _trace_guard = init_trace_log(&log_dir);
let demo_paths = match bundle_config::resolve_demo_paths(
request.config.clone(),
request.bundle.as_deref(),
) {
Ok(paths) => paths,
Err(err) => {
operator_log::error(
module_path!(),
format!("resolve_demo_paths failed: {err:#}"),
);
return Err(err);
}
};
let config_path = demo_paths.config_path.clone();
let config_dir = demo_paths.root_dir.clone();
let state_dir = demo_paths.state_dir.clone();
crate::warmup::adopt_bundle_cache_dir(&config_dir);
let resolved_log_dir = config_dir.join("logs");
if request.log_dir.is_none() && resolved_log_dir != log_dir {
operator_log::warn(
module_path!(),
format!(
"operator.log is at {} but resolved bundle log dir is {}; future logs stay at the former",
log_dir.display(),
resolved_log_dir.display()
),
);
}
match flow_log::init(&log_dir) {
Ok(path) => {
operator_log::info(
module_path!(),
format!("flow.log initialized at {}", path.display()),
);
}
Err(e) => {
operator_log::warn(module_path!(), format!("failed to init flow.log: {e}"));
}
}
let mut demo_config = bundle_config::load_runtime_demo_config(&demo_paths, &request)?;
apply_nats_overrides(&mut demo_config, &request);
let static_routes = startup_contract::inspect_bundle(&config_dir)?;
let configured_public_base_url = startup_contract::configured_public_base_url_from_env()?;
let env_store_public_base_url =
match startup_contract::configured_public_base_url_from_env_store(&resolve_env(None)) {
Ok(value) => value,
Err(err) => {
operator_log::warn(
module_path!(),
format!("failed to read env-store public_base_url, falling back: {err:#}"),
);
None
}
};
let tenant = demo_config.tenant.clone();
let team = demo_config.team.clone();
let runtime_paths =
runtime_state::RuntimePaths::new(state_dir.clone(), tenant.clone(), team.clone());
runtime_state::clear_stop_request(&runtime_paths)?;
if !request.tunnel_explicit
&& let Some(tunnel) = load_tunnel_config(&config_dir)
{
match tunnel.mode.as_deref() {
Some("cloudflared") => {
operator_log::info(
module_path!(),
"tunnel mode 'cloudflared' configured in setup answers",
);
request.cloudflared = CloudflaredModeArg::On;
request.tunnel_explicit = true;
}
Some("ngrok") => {
operator_log::info(
module_path!(),
"tunnel mode 'ngrok' configured in setup answers",
);
request.ngrok = NgrokModeArg::On;
request.tunnel_explicit = true;
}
Some("off") => {
operator_log::info(
module_path!(),
"tunnel mode 'off' configured in setup answers",
);
request.tunnel_explicit = true;
}
_ => {}
}
}
if !request.tunnel_explicit {
let has_deployer =
!greentic_setup::deployment_targets::discover_deployer_pack_candidates(&config_dir)
.unwrap_or_default()
.is_empty();
if !has_deployer {
operator_log::info(
module_path!(),
"no deployer packs detected; defaulting to cloudflared tunnel",
);
request.cloudflared = CloudflaredModeArg::On;
request.tunnel_explicit = true;
}
}
tunnel_prompt::maybe_prompt_tunnel(&mut request);
let effective_cloudflared = match (&request.cloudflared, &request.ngrok) {
(CloudflaredModeArg::On, NgrokModeArg::On) => {
operator_log::info(
module_path!(),
"ngrok enabled, disabling cloudflared (use --cloudflared on --ngrok off to override)",
);
CloudflaredModeArg::Off
}
(mode, _) => *mode,
};
let cloudflared = match effective_cloudflared {
CloudflaredModeArg::Off => None,
CloudflaredModeArg::On => {
let explicit = request.cloudflared_binary.clone();
let binary = bin_resolver::resolve_binary(
"cloudflared",
&bin_resolver::ResolveCtx {
config_dir: config_dir.clone(),
explicit_path: explicit,
},
)?;
Some(cloudflared::CloudflaredConfig {
binary,
local_port: demo_config.services.gateway.port,
extra_args: Vec::new(),
restart: restart.contains("cloudflared"),
})
}
};
let ngrok = match request.ngrok {
NgrokModeArg::Off => None,
NgrokModeArg::On => {
let explicit = request.ngrok_binary.clone();
let binary = bin_resolver::resolve_binary(
"ngrok",
&bin_resolver::ResolveCtx {
config_dir: config_dir.clone(),
explicit_path: explicit,
},
)?;
Some(ngrok::NgrokConfig {
binary,
local_port: demo_config.services.gateway.port,
extra_args: Vec::new(),
restart: restart.contains("ngrok"),
})
}
};
let handles = runtime::demo_up_services(
&config_path,
&demo_config,
&static_routes,
configured_public_base_url,
env_store_public_base_url,
cloudflared,
ngrok,
&restart,
request.runner_binary.clone(),
&log_dir,
request.verbose,
request.no_browser,
)?;
let _admin_server = if request.admin {
let resolved_certs_dir = admin_certs::resolve_admin_certs_dir(
&config_dir,
&state_dir,
request.admin_certs_dir.as_deref(),
)?;
let admin_cert_refs = admin_certs::load_admin_cert_refs();
operator_log::info(
module_path!(),
format!(
"admin certs source={} path={}",
resolved_certs_dir.source.as_str(),
resolved_certs_dir.path.display()
),
);
if !admin_cert_refs.is_empty() {
operator_log::info(
module_path!(),
format!("admin cert refs {}", admin_cert_refs.join(" ")),
);
}
let tls_config = greentic_setup::admin::AdminTlsConfig {
server_cert: resolved_certs_dir.path.join("server.crt"),
server_key: resolved_certs_dir.path.join("server.key"),
client_ca: resolved_certs_dir.path.join("ca.crt"),
allowed_clients: admin_certs::load_admin_allowed_clients(
&config_dir,
&request.admin_allowed_clients,
),
port: request.admin_port,
};
let admin_config = admin_server::AdminServerConfig {
tls_config,
bundle_root: config_dir.clone(),
runtime_paths: runtime_paths.clone(),
};
Some(
admin_server::AdminServer::start(admin_config).map_err(|err| {
anyhow!("admin mode requested but admin server failed to start: {err}")
})?,
)
} else {
None
};
operator_log::info(
module_path!(),
format!(
"demo start running config={} tenant={} team={}",
config_path.display(),
tenant,
team
),
);
println!("\nReady. Press Ctrl+C to stop.");
let shutdown_reason = wait_for_shutdown(&runtime_paths)?;
operator_log::info(
module_path!(),
format!(
"runtime shutdown requested via {}",
shutdown_reason.as_str()
),
);
if let Some(server) = _admin_server {
let _ = server.stop();
}
handles.stop()?;
runtime::demo_down_runtime(&state_dir, &tenant, &team, false)?;
let _ = runtime_state::clear_stop_request(&runtime_paths);
Ok(())
}
const NOISY_TRACE_TARGETS: &[&str] = &[
"wasmtime",
"wasmtime_wasi",
"wasi_common",
"cranelift_codegen",
"cranelift_wasm",
"regalloc2",
"h2",
"hyper",
"hyper_util",
"rustls",
"tokio_util",
"tokio_tungstenite",
"tungstenite",
"want",
"mio",
"tower",
];
fn build_trace_filter() -> tracing_subscriber::EnvFilter {
use tracing_subscriber::EnvFilter;
let base = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
NOISY_TRACE_TARGETS.iter().fold(base, |filter, target| {
match format!("{target}=warn").parse() {
Ok(directive) => filter.add_directive(directive),
Err(_) => filter,
}
})
}
fn init_trace_log(
log_dir: &std::path::Path,
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
use std::fs::OpenOptions;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let path = log_dir.join("system.log");
let file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(err) => {
operator_log::warn(
module_path!(),
format!("could not open system.log at {}: {err}", path.display()),
);
return None;
}
};
let (nb, guard) = tracing_appender::non_blocking(file);
let rust_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "<unset>".to_string());
let filter = build_trace_filter();
let layer = tracing_subscriber::fmt::layer()
.with_writer(nb)
.with_ansi(false)
.with_target(true);
match tracing_subscriber::registry()
.with(filter)
.with(layer)
.try_init()
{
Ok(()) => {
operator_log::info(
module_path!(),
format!(
"tracing subscriber writing to {} (RUST_LOG={rust_log})",
path.display()
),
);
tracing::info!(
target: "greentic_start",
rust_log = %rust_log,
"tracing subscriber installed"
);
}
Err(err) => {
operator_log::warn(
module_path!(),
format!(
"tracing subscriber try_init failed (another subscriber already installed?): {err}"
),
);
return None;
}
}
Some(guard)
}
fn bootstrap_local_environment() -> anyhow::Result<()> {
use greentic_deployer::cli::bootstrap::{LocalEnvOutcome, ensure_local_environment};
use greentic_deployer::environment::LocalFsStore;
let root = LocalFsStore::default_root()
.context("Cannot determine default environment store root (no home directory).")?;
let store = LocalFsStore::new(root.clone());
let (_env, outcome) = ensure_local_environment(&store, None)
.with_context(|| format!("Bootstrapping `local` environment at {}", root.display()))?;
if outcome == LocalEnvOutcome::Created {
operator_log::info(
module_path!(),
format!(
"bootstrapped `local` environment with default capability bindings at {}",
root.display()
),
);
}
Ok(())
}
fn apply_nats_overrides(config: &mut config::DemoConfig, args: &StartRequest) {
let nats_mode = if args.no_nats {
NatsModeArg::Off
} else {
args.nats
};
if let Some(nats_url) = args.nats_url.as_ref() {
config.services.nats.url = nats_url.clone();
}
match nats_mode {
NatsModeArg::Off => {
config.services.nats.enabled = false;
config.services.nats.spawn.enabled = false;
}
NatsModeArg::On => {
config.services.nats.enabled = true;
config.services.nats.spawn.enabled = true;
}
NatsModeArg::External => {
config.services.nats.enabled = true;
config.services.nats.spawn.enabled = false;
}
}
}
fn resolve_state_dir(state_dir: Option<PathBuf>, bundle: Option<&str>) -> anyhow::Result<PathBuf> {
if let Some(state_dir) = state_dir {
return Ok(state_dir);
}
if let Some(bundle_ref) = bundle {
let resolved = bundle_ref::resolve_bundle_ref(bundle_ref)?;
return Ok(resolved.bundle_dir.join("state"));
}
Ok(PathBuf::from("state"))
}
#[derive(serde::Deserialize)]
struct TunnelConfig {
mode: Option<String>,
}
fn load_tunnel_config(bundle_root: &std::path::Path) -> Option<TunnelConfig> {
let path = bundle_root.join(".greentic").join("tunnel.json");
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
enum ShutdownReason {
CtrlC,
AdminStop,
}
impl ShutdownReason {
fn as_str(&self) -> &'static str {
match self {
Self::CtrlC => "ctrl_c",
Self::AdminStop => "admin_stop",
}
}
}
fn wait_for_shutdown(paths: &runtime_state::RuntimePaths) -> anyhow::Result<ShutdownReason> {
let runtime =
tokio::runtime::Runtime::new().context("failed to spawn runtime for Ctrl+C listener")?;
let paths = paths.clone();
runtime.block_on(async move {
loop {
tokio::select! {
result = tokio::signal::ctrl_c() => {
result.map_err(|err| anyhow!("failed to wait for Ctrl+C: {err}"))?;
return Ok(ShutdownReason::CtrlC);
}
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => {
if runtime_state::read_stop_request(&paths)?.is_some() {
return Ok(ShutdownReason::AdminStop);
}
}
}
}
})
}
#[cfg(test)]
pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::thread;
use std::time::Duration;
#[test]
fn build_trace_filter_clamps_noisy_targets_even_when_rust_log_unset() {
let _guard = test_env_lock().lock().unwrap();
unsafe { std::env::remove_var("RUST_LOG") };
let filter = build_trace_filter();
let printed = filter.to_string();
for target in NOISY_TRACE_TARGETS {
assert!(
printed.contains(&format!("{target}=warn")),
"expected `{target}=warn` in filter, got: {printed}"
);
}
}
#[test]
fn build_trace_filter_clamps_noisy_targets_overriding_explicit_debug() {
let _guard = test_env_lock().lock().unwrap();
unsafe { std::env::set_var("RUST_LOG", "wasmtime=debug,info") };
let filter = build_trace_filter();
let printed = filter.to_string();
assert!(
printed.contains("wasmtime=warn"),
"wasmtime clamp must override RUST_LOG override, got: {printed}"
);
unsafe { std::env::remove_var("RUST_LOG") };
}
#[test]
fn apply_nats_overrides_disables_nats_for_flag() {
let mut config = config::DemoConfig::default();
let args = StartRequest {
bundle: None,
tenant: None,
team: None,
no_nats: false,
nats: NatsModeArg::Off,
nats_url: None,
config: None,
cloudflared: CloudflaredModeArg::Off,
cloudflared_binary: None,
ngrok: NgrokModeArg::Off,
ngrok_binary: None,
runner_binary: None,
restart: Vec::new(),
log_dir: None,
verbose: false,
quiet: false,
no_browser: false,
admin: false,
admin_port: 9443,
admin_certs_dir: None,
admin_allowed_clients: Vec::new(),
tunnel_explicit: true,
};
apply_nats_overrides(&mut config, &args);
assert!(!config.services.nats.enabled);
assert!(!config.services.nats.spawn.enabled);
}
#[test]
fn apply_nats_overrides_uses_external_url_without_spawn() {
let mut config = config::DemoConfig::default();
let args = StartRequest {
bundle: None,
tenant: None,
team: None,
no_nats: false,
nats: NatsModeArg::External,
nats_url: Some("nats://127.0.0.1:5555".into()),
config: None,
cloudflared: CloudflaredModeArg::Off,
cloudflared_binary: None,
ngrok: NgrokModeArg::Off,
ngrok_binary: None,
runner_binary: None,
restart: Vec::new(),
log_dir: None,
verbose: false,
quiet: false,
no_browser: false,
admin: false,
admin_port: 9443,
admin_certs_dir: None,
admin_allowed_clients: Vec::new(),
tunnel_explicit: true,
};
apply_nats_overrides(&mut config, &args);
assert!(config.services.nats.enabled);
assert!(!config.services.nats.spawn.enabled);
assert_eq!(config.services.nats.url, "nats://127.0.0.1:5555");
}
#[test]
fn resolve_state_dir_uses_bundle_state_when_requested() {
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path();
let state_dir =
resolve_state_dir(None, Some(bundle.to_string_lossy().as_ref())).expect("state dir");
assert_eq!(state_dir, bundle.join("state"));
}
fn make_start_request(bundle: &Path) -> StartRequest {
StartRequest {
bundle: Some(bundle.display().to_string()),
tenant: None,
team: None,
no_nats: false,
nats: NatsModeArg::Off,
nats_url: None,
config: None,
cloudflared: CloudflaredModeArg::Off,
cloudflared_binary: None,
ngrok: NgrokModeArg::Off,
ngrok_binary: None,
runner_binary: None,
restart: Vec::new(),
log_dir: None,
verbose: false,
quiet: false,
no_browser: false,
admin: false,
admin_port: 9443,
admin_certs_dir: None,
admin_allowed_clients: Vec::new(),
tunnel_explicit: true,
}
}
fn write_demo_bundle(bundle: &Path) {
std::fs::create_dir_all(bundle).expect("bundle dir");
std::fs::write(
bundle.join("greentic.demo.yaml"),
"tenant: demo\nteam: default\n",
)
.expect("write demo config");
}
struct HomeOverride {
prev: Option<std::ffi::OsString>,
}
impl HomeOverride {
fn set(home: &Path) -> Self {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home);
}
Self { prev }
}
}
impl Drop for HomeOverride {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn request_runtime_stop(bundle: &Path) -> thread::JoinHandle<()> {
let runtime_paths =
runtime_state::RuntimePaths::new(bundle.join("state"), "demo", "default");
thread::spawn(move || {
thread::sleep(Duration::from_millis(350));
runtime_state::write_stop_request(
&runtime_paths,
&runtime_state::StopRequest {
requested_by: "test".to_string(),
reason: Some("coverage".to_string()),
},
)
.expect("write stop request");
})
}
#[test]
fn run_start_request_embedded_mode_stops_cleanly() {
let _env_guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
crate::operator_log::reset_for_tests();
let temp = tempfile::tempdir().expect("tempdir");
let _home = HomeOverride::set(temp.path());
let bundle = temp.path().join("bundle");
write_demo_bundle(&bundle);
let stop_thread = request_runtime_stop(&bundle);
let request = make_start_request(&bundle);
run_start_request(request).expect("start request");
stop_thread.join().expect("join stop thread");
let paths = runtime_state::RuntimePaths::new(bundle.join("state"), "demo", "default");
assert!(paths.service_manifest_path().exists());
assert!(
runtime_state::read_stop_request(&paths)
.expect("read stop")
.is_none()
);
}
#[test]
fn run_restart_request_embedded_mode_stops_cleanly() {
let _env_guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
crate::operator_log::reset_for_tests();
let temp = tempfile::tempdir().expect("tempdir");
let _home = HomeOverride::set(temp.path());
let bundle = temp.path().join("bundle");
write_demo_bundle(&bundle);
let stop_thread = request_runtime_stop(&bundle);
let mut request = make_start_request(&bundle);
request.verbose = true;
run_restart_request(request).expect("restart request");
stop_thread.join().expect("join stop thread");
let paths = runtime_state::RuntimePaths::new(bundle.join("state"), "demo", "default");
assert!(paths.service_manifest_path().exists());
assert!(
runtime_state::read_stop_request(&paths)
.expect("read stop")
.is_none()
);
}
#[test]
fn run_start_request_quiet_mode_returns_bundle_errors() {
let _env_guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
crate::operator_log::reset_for_tests();
let temp = tempfile::tempdir().expect("tempdir");
let _home = HomeOverride::set(temp.path());
let missing_bundle = temp.path().join("missing-bundle");
let mut request = make_start_request(&missing_bundle);
request.quiet = true;
let err = run_start_request(request).expect_err("missing bundle should error");
let message = err.to_string();
assert!(
message.contains("bundle config not found")
|| message.contains("bundle path does not exist")
|| message.contains("unsupported bundle reference"),
"unexpected error: {message}"
);
}
#[test]
fn auto_enables_cloudflared_when_no_deployer_packs() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("packs")).expect("packs dir");
let candidates =
greentic_setup::deployment_targets::discover_deployer_pack_candidates(dir.path())
.unwrap_or_default();
assert!(
candidates.is_empty(),
"empty bundle should have no deployer"
);
}
#[test]
fn detects_deployer_pack_when_present() {
let dir = tempfile::tempdir().expect("tempdir");
let deployer_dir = dir.path().join("providers").join("deployer");
std::fs::create_dir_all(&deployer_dir).expect("deployer dir");
std::fs::write(deployer_dir.join("terraform.gtpack"), b"fake").expect("write pack");
let candidates =
greentic_setup::deployment_targets::discover_deployer_pack_candidates(dir.path())
.unwrap_or_default();
assert!(
!candidates.is_empty(),
"bundle with terraform.gtpack should detect deployer"
);
}
#[test]
fn bootstrap_creates_local_env_under_default_root() {
let _env_guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().expect("tempdir");
let _home = HomeOverride::set(temp.path());
super::bootstrap_local_environment().expect("first bootstrap");
let env_file = temp
.path()
.join(".greentic")
.join("environments")
.join("local")
.join("environment.json");
assert!(env_file.exists(), "expected env file at {env_file:?}");
}
#[test]
fn bootstrap_is_idempotent_across_calls() {
let _env_guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().expect("tempdir");
let _home = HomeOverride::set(temp.path());
super::bootstrap_local_environment().expect("first bootstrap");
super::bootstrap_local_environment().expect("second bootstrap");
let env_file = temp
.path()
.join(".greentic")
.join("environments")
.join("local")
.join("environment.json");
assert!(env_file.exists());
}
struct EnvVarsOverride {
prev_env: Option<std::ffi::OsString>,
prev_disable: Option<std::ffi::OsString>,
}
impl EnvVarsOverride {
fn clean() -> Self {
let prev_env = std::env::var_os("GREENTIC_ENV");
let prev_disable = std::env::var_os(DISABLE_ALIAS_ENV_VAR);
unsafe {
std::env::remove_var("GREENTIC_ENV");
std::env::remove_var(DISABLE_ALIAS_ENV_VAR);
}
super::compat_alias::reset_warning_latch_for_tests();
Self {
prev_env,
prev_disable,
}
}
}
impl Drop for EnvVarsOverride {
fn drop(&mut self) {
unsafe {
match self.prev_env.take() {
Some(v) => std::env::set_var("GREENTIC_ENV", v),
None => std::env::remove_var("GREENTIC_ENV"),
}
match self.prev_disable.take() {
Some(v) => std::env::set_var(DISABLE_ALIAS_ENV_VAR, v),
None => std::env::remove_var(DISABLE_ALIAS_ENV_VAR),
}
}
}
}
fn set_env_var(key: &str, value: &str) {
unsafe {
std::env::set_var(key, value);
}
}
#[test]
fn resolve_env_returns_local_by_default() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
assert_eq!(resolve_env(None), "local");
}
#[test]
fn resolve_env_passes_through_non_legacy_override() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
assert_eq!(resolve_env(Some("staging")), "staging");
assert_eq!(resolve_env(Some("prod")), "prod");
assert_eq!(resolve_env(Some("local")), "local");
}
#[test]
fn resolve_env_remaps_dev_override_to_local() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
assert_eq!(resolve_env(Some("dev")), "local");
}
#[test]
fn resolve_env_remaps_dev_env_var_to_local() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
set_env_var("GREENTIC_ENV", "dev");
assert_eq!(resolve_env(None), "local");
}
#[test]
fn alias_warning_latches_once_until_reset() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
compat_alias::reset_warning_latch_for_tests();
assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
}
#[test]
fn disable_alias_env_var_panics_on_dev() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
set_env_var(DISABLE_ALIAS_ENV_VAR, "1");
let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
assert!(
result.is_err(),
"resolve_env should panic when alias is disabled and input is `dev`"
);
}
#[test]
fn disable_alias_accepts_truthy_strings() {
for value in ["1", "true", "TRUE", "yes", "YES", "on", " true "] {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
set_env_var(DISABLE_ALIAS_ENV_VAR, value);
let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
assert!(
result.is_err(),
"DISABLE value `{value}` should hard-fail on dev resolution"
);
}
}
#[test]
fn disable_alias_does_not_panic_on_non_legacy_values() {
let _guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let _env = EnvVarsOverride::clean();
set_env_var(DISABLE_ALIAS_ENV_VAR, "1");
assert_eq!(resolve_env(Some("local")), "local");
assert_eq!(resolve_env(Some("staging")), "staging");
assert_eq!(resolve_env(None), "local");
}
}