#[path = "../dist.rs"]
mod dist;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Arg, ArgAction, ArgMatches, Command};
use directories::BaseDirs;
use greentic_distributor_client::{DistClient, DistOptions, save_login_default};
use greentic_i18n::{normalize_locale, select_locale_with_sources};
use greentic_start::{
CloudflaredModeArg, NatsModeArg, NgrokModeArg, RestartTarget, StartRequest, StopRequest,
run_start_request, run_stop_request,
};
use greentic_types::decode_pack_manifest;
use rcgen::{
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa,
KeyPair, KeyUsagePurpose, SanType,
};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_yaml::Value as YamlValue;
use sha2::{Digest, Sha256};
use tempfile::TempDir;
use zip::ZipArchive;
const DEV_BIN: &str = "greentic-dev";
const OP_BIN: &str = "greentic-operator";
const BUNDLE_BIN: &str = "greentic-bundle";
const DEPLOYER_BIN: &str = "greentic-deployer";
const SETUP_BIN: &str = "greentic-setup";
const DEFAULT_AWS_OPERATOR_IMAGE: &str = "ghcr.io/greenticai/greentic-start-distroless@sha256:a7f4741a1206900b73a77c5e40860c2695206274374546dd3bb9cab8e752f79b";
const DEFAULT_GCP_OPERATOR_IMAGE: &str = "europe-west1-docker.pkg.dev/x-plateau-483512-p6/greentic-images/greentic-start-distroless@sha256:555fb6ebdac836c16c5c11fce0f4080a0d7ccda03abd9e89bb9d561280ca67db";
const DEFAULT_AZURE_OPERATOR_IMAGE: &str = "ghcr.io/greenticai/greentic-start-distroless@sha256:a7f4741a1206900b73a77c5e40860c2695206274374546dd3bb9cab8e752f79b";
const DEFAULT_OPERATOR_IMAGE_DIGEST: &str =
"sha256:a7f4741a1206900b73a77c5e40860c2695206274374546dd3bb9cab8e752f79b";
const EMBEDDED_TERRAFORM_GTPACK: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/deployer/terraform.gtpack"
));
const LOCALES_JSON: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/i18n/locales.json"
));
include!(concat!(env!("OUT_DIR"), "/embedded_i18n.rs"));
fn main() {
let raw_args: Vec<String> = env::args().collect();
let exit_code = run(raw_args);
std::process::exit(exit_code);
}
fn run(raw_args: Vec<String>) -> i32 {
let i18n = i18n();
let cli_locale = locale_from_args(&raw_args);
let locale = detect_locale(&raw_args, i18n.default_locale());
let raw_passthrough = parse_raw_passthrough(&raw_args);
if let Some((binary, args)) =
passthrough_help_request(raw_passthrough.as_ref(), &cli_locale, &locale)
{
let debug = raw_args.iter().any(|arg| arg == "--debug-router");
return passthrough(binary, &args, debug, &locale);
}
let cli = build_cli(&locale);
let matches = match cli.try_get_matches_from(raw_args) {
Ok(matches) => matches,
Err(err) => {
let _ = err.print();
return err.exit_code();
}
};
let debug = matches.get_flag("debug-router");
match matches.subcommand() {
Some(("version", _)) => {
println!("gtc {}", env!("CARGO_PKG_VERSION"));
0
}
Some(("doctor", _)) => run_doctor(&locale),
Some(("install", sub_matches)) => run_install(sub_matches, debug, &locale),
Some(("add-admin", sub_matches)) => run_add_admin(sub_matches, &locale),
Some(("remove-admin", sub_matches)) => run_remove_admin(sub_matches, &locale),
Some(("start", sub_matches)) => run_start(sub_matches, debug, &locale),
Some(("stop", sub_matches)) => run_stop(sub_matches, debug, &locale),
Some(("dev", sub_matches)) => {
let tail = collect_tail(sub_matches);
let (binary, args) =
route_passthrough_subcommand("dev", &tail, &locale).expect("dev route");
passthrough(binary, &args, debug, &locale)
}
Some(("op", sub_matches)) => {
let tail = collect_tail(sub_matches);
let (binary, args) =
route_passthrough_subcommand("op", &tail, &locale).expect("op route");
passthrough(binary, &args, debug, &locale)
}
Some(("wizard", sub_matches)) => {
let tail = collect_tail(sub_matches);
let (binary, args) =
route_passthrough_subcommand("wizard", &tail, &locale).expect("wizard route");
passthrough(binary, &args, debug, &locale)
}
Some(("setup", sub_matches)) => {
let tail = collect_tail(sub_matches);
let (binary, args) =
route_passthrough_subcommand("setup", &tail, &locale).expect("setup route");
passthrough(binary, &args, debug, &locale)
}
_ => 2,
}
}
fn build_cli(locale: &str) -> Command {
let cmd_args = passthrough_args();
Command::new(leak_str(t(locale, "gtc.app.name").into_owned()))
.version(env!("CARGO_PKG_VERSION"))
.about(t(locale, "gtc.app.about").into_owned())
.arg(
Arg::new("locale")
.long("locale")
.value_name("BCP47")
.num_args(1)
.global(true)
.help(t(locale, "gtc.arg.locale.help").into_owned()),
)
.arg(
Arg::new("debug-router")
.long("debug-router")
.action(ArgAction::SetTrue)
.global(true)
.help(t(locale, "gtc.arg.debug_router.help").into_owned()),
)
.subcommand(Command::new("version").about(t(locale, "gtc.cmd.version.about").into_owned()))
.subcommand(Command::new("doctor").about(t(locale, "gtc.cmd.doctor.about").into_owned()))
.subcommand(
Command::new("install")
.about(t(locale, "gtc.cmd.install.about").into_owned())
.arg(
Arg::new("tenant")
.long("tenant")
.value_name("TENANT")
.num_args(1)
.help(t(locale, "gtc.arg.tenant.help").into_owned()),
)
.arg(
Arg::new("key")
.long("key")
.value_name("KEY")
.num_args(1)
.help(t(locale, "gtc.arg.key.help").into_owned()),
),
)
.subcommand(
Command::new("add-admin")
.about("Register an admin client certificate identity for a local bundle.")
.arg(
Arg::new("bundle-ref")
.value_name("BUNDLE_REF")
.required(true)
.help("Local bundle directory to update."),
)
.arg(
Arg::new("cn")
.long("cn")
.value_name("CLIENT_CN")
.required(true)
.num_args(1)
.help("Client certificate Common Name allowed to access the admin API."),
)
.arg(
Arg::new("name")
.long("name")
.value_name("ADMIN_NAME")
.num_args(1)
.help("Optional human-readable admin label."),
)
.arg(
Arg::new("public-key-file")
.long("public-key-file")
.value_name("PATH")
.required(true)
.num_args(1)
.help("PEM/OpenSSH public key file for this admin."),
),
)
.subcommand(
Command::new("remove-admin")
.about("Remove an admin client certificate identity from a local bundle.")
.arg(
Arg::new("bundle-ref")
.value_name("BUNDLE_REF")
.required(true)
.help("Local bundle directory to update."),
)
.arg(
Arg::new("cn")
.long("cn")
.value_name("CLIENT_CN")
.num_args(1)
.conflicts_with("name")
.help("Client certificate Common Name to remove."),
)
.arg(
Arg::new("name")
.long("name")
.value_name("ADMIN_NAME")
.num_args(1)
.conflicts_with("cn")
.help("Admin label to remove."),
),
)
.subcommand(
Command::new("start")
.about(t_or(
locale,
"gtc.cmd.start.about",
"Start a bundle from local or remote reference.",
))
.arg(
Arg::new("bundle-ref")
.value_name("BUNDLE_REF")
.required(true)
.help(t_or(
locale,
"gtc.arg.bundle_ref.help",
"Bundle path/ref: local path, file://, oci://, repo://, store://",
)),
)
.arg(
Arg::new("deploy-bundle-source")
.long("deploy-bundle-source")
.value_name("BUNDLE_SOURCE")
.num_args(1)
.help("Override the remote bundle source passed to cloud deployers (for example https://.../bundle.gtbundle)."),
)
.arg(cmd_args.clone()),
)
.subcommand(
Command::new("stop")
.about(t_or(
locale,
"gtc.cmd.stop.about",
"Stop a bundle runtime or destroy a deployed environment.",
))
.arg(
Arg::new("bundle-ref")
.value_name("BUNDLE_REF")
.required(true)
.help(t_or(
locale,
"gtc.arg.bundle_ref.help",
"Bundle path/ref: local path, file://, oci://, repo://, store://",
)),
)
.arg(cmd_args.clone()),
)
.subcommand(
Command::new("dev")
.about(t(locale, "gtc.cmd.dev.about").into_owned())
.arg(cmd_args.clone()),
)
.subcommand(
Command::new("op")
.about(t(locale, "gtc.cmd.op.about").into_owned())
.arg(cmd_args.clone()),
)
.subcommand(
Command::new("wizard")
.about(t(locale, "gtc.cmd.wizard.about").into_owned())
.arg(cmd_args.clone()),
)
.subcommand(
Command::new("setup")
.about(t(locale, "gtc.cmd.setup.about").into_owned())
.arg(cmd_args),
)
}
fn passthrough_args() -> Arg {
Arg::new("args")
.num_args(0..)
.trailing_var_arg(true)
.allow_hyphen_values(true)
}
fn collect_tail(matches: &ArgMatches) -> Vec<String> {
matches
.get_many::<String>("args")
.map(|vals| vals.cloned().collect())
.unwrap_or_default()
}
#[derive(Debug, Clone)]
struct RawPassthrough {
subcommand: String,
tail: Vec<String>,
}
fn parse_raw_passthrough(raw_args: &[String]) -> Option<RawPassthrough> {
let mut iter = raw_args.iter().skip(1).peekable();
while let Some(arg) = iter.next() {
if arg == "--locale" {
iter.next();
continue;
}
if arg == "--debug-router" {
continue;
}
if arg.starts_with("--locale=") {
continue;
}
if arg.starts_with('-') {
continue;
}
let subcommand = arg.to_string();
let tail = iter.cloned().collect();
return Some(RawPassthrough { subcommand, tail });
}
None
}
fn passthrough_help_request(
raw: Option<&RawPassthrough>,
_cli_locale: &Option<String>,
locale: &str,
) -> Option<(&'static str, Vec<String>)> {
let raw = raw?;
if !raw.tail.iter().any(|arg| arg == "--help" || arg == "-h") {
return None;
}
route_passthrough_subcommand(&raw.subcommand, &raw.tail, locale)
}
fn route_passthrough_subcommand(
subcommand: &str,
tail: &[String],
locale: &str,
) -> Option<(&'static str, Vec<String>)> {
match subcommand {
"dev" => Some((DEV_BIN, tail.to_vec())),
"op" => Some((OP_BIN, rewrite_legacy_op_args(tail))),
"setup" => Some((SETUP_BIN, tail.to_vec())),
"wizard" => Some((DEV_BIN, build_wizard_args(tail, locale))),
_ => None,
}
}
fn rewrite_legacy_op_args(args: &[String]) -> Vec<String> {
let Some(first) = args.first() else {
return args.to_vec();
};
match first.as_str() {
"setup" => {
let mut out = vec!["demo".to_string(), "setup".to_string()];
out.extend_from_slice(&args[1..]);
ensure_flag_value(&mut out, "tenant", "default");
ensure_flag_value(&mut out, "team", "default");
out
}
"start" => {
let mut out = vec!["demo".to_string(), "start".to_string()];
out.extend_from_slice(&args[1..]);
ensure_flag_value(&mut out, "tenant", "default");
ensure_flag_value(&mut out, "team", "default");
ensure_flag_value(&mut out, "cloudflared", "off");
out
}
_ => args.to_vec(),
}
}
fn build_wizard_args(args: &[String], locale: &str) -> Vec<String> {
let mut forwarded = vec!["wizard".to_string()];
if !has_flag(args, "locale") {
ensure_flag_value(&mut forwarded, "locale", locale);
}
forwarded.extend_from_slice(args);
forwarded
}
fn ensure_flag_value(args: &mut Vec<String>, flag: &str, value: &str) {
if has_flag(args, flag) {
return;
}
args.push(format!("--{flag}"));
if !value.is_empty() {
args.push(value.to_string());
}
}
fn has_flag(args: &[String], flag: &str) -> bool {
let long = format!("--{flag}");
let with_eq = format!("{long}=");
args.iter()
.any(|arg| arg == &long || arg.starts_with(&with_eq))
}
fn detect_locale(raw_args: &[String], default_locale: &str) -> String {
let cli_locale = locale_from_args(raw_args);
let env_locale = env::var("GTC_LOCALE").ok();
let selected = select_locale_with_sources(
cli_locale.as_deref(),
Some(default_locale),
env_locale.as_deref(),
None,
);
i18n().normalize_or_default(&selected)
}
fn locale_from_args(raw_args: &[String]) -> Option<String> {
let mut iter = raw_args.iter().skip(1);
while let Some(arg) = iter.next() {
if arg == "--locale" {
return iter.next().cloned();
}
if let Some(value) = arg.strip_prefix("--locale=") {
return Some(value.to_string());
}
}
None
}
struct StartBundleResolution {
bundle_dir: PathBuf,
deployment_key: String,
deploy_artifact: Option<PathBuf>,
_hold: Option<TempDir>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartTarget {
Runtime,
SingleVm,
Aws,
Gcp,
Azure,
}
impl StartTarget {
fn as_str(self) -> &'static str {
match self {
StartTarget::Runtime => "runtime",
StartTarget::SingleVm => "single-vm",
StartTarget::Aws => "aws",
StartTarget::Gcp => "gcp",
StartTarget::Azure => "azure",
}
}
}
#[derive(Debug, Deserialize)]
struct DeploymentTargetsDocument {
targets: Vec<DeploymentTargetRecord>,
}
#[derive(Debug, Deserialize)]
struct DeploymentTargetRecord {
target: String,
provider_pack: Option<String>,
default: Option<bool>,
}
#[derive(Debug)]
struct StartCliOptions {
start_args: Vec<String>,
explicit_target: Option<StartTarget>,
environment: Option<String>,
provider_pack: Option<PathBuf>,
app_pack: Option<PathBuf>,
deploy_bundle_source: Option<String>,
}
#[derive(Debug)]
struct StopCliOptions {
stop_args: Vec<String>,
explicit_target: Option<StartTarget>,
environment: Option<String>,
provider_pack: Option<PathBuf>,
app_pack: Option<PathBuf>,
destroy: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct StartDeploymentState {
target: String,
bundle_fingerprint: String,
bundle_ref: String,
deployed_at_epoch_s: u64,
artifact_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
struct AdminRegistryDocument {
admins: Vec<AdminRegistryEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
struct AdminRegistryEntry {
name: Option<String>,
client_cn: String,
public_key: String,
added_at_epoch_s: u64,
}
fn run_start(sub_matches: &ArgMatches, debug: bool, locale: &str) -> i32 {
let Some(bundle_ref) = sub_matches.get_one::<String>("bundle-ref") else {
eprintln!(
"{}",
t_or(
locale,
"gtc.start.err.bundle_required",
"bundle ref is required"
)
);
return 2;
};
let tail = collect_tail(sub_matches);
let cli_options = match parse_start_cli_options(&tail) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.invalid_args",
"invalid start arguments"
)
);
return 2;
}
};
let resolved = match resolve_bundle_reference(bundle_ref, locale) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.resolve_failed",
"failed to resolve bundle"
)
);
return 1;
}
};
let mut request =
match parse_start_request(&cli_options.start_args, resolved.bundle_dir.clone()) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.invalid_args",
"invalid start arguments"
)
);
return 2;
}
};
if request.admin {
match ensure_admin_certs_ready(&resolved.bundle_dir, request.admin_certs_dir.as_deref()) {
Ok(cert_dir) => request.admin_certs_dir = Some(cert_dir),
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.admin_certs_failed",
"failed to prepare admin certificates"
)
);
return 1;
}
}
}
let target =
match select_start_target(&resolved.bundle_dir, cli_options.explicit_target, locale) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.target_select_failed",
"failed to choose deployment target"
)
);
return 2;
}
};
println!("Selected deployment target: {}", target.as_str());
println!("Bundle source: {}", bundle_ref);
println!("Resolved bundle dir: {}", resolved.bundle_dir.display());
if target != StartTarget::Runtime {
println!("Deployment mode: deploy via {} target", target.as_str());
let deploy_result = ensure_bundle_deployed(
bundle_ref,
&resolved,
&request,
&cli_options,
target,
debug,
locale,
);
match deploy_result {
Ok(()) => return 0,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.start.err.deploy_failed",
"failed to deploy bundle before start"
)
);
return 1;
}
}
}
println!("Deployment mode: local runtime");
println!(
"Starting tenant={} team={}",
request.tenant.as_deref().unwrap_or("default"),
request.team.as_deref().unwrap_or("default")
);
if debug {
eprintln!(
"{} gtc-start-lib bundle={:?} tenant={:?} team={:?}",
t(locale, "gtc.debug.exec"),
request.bundle,
request.tenant,
request.team
);
}
match run_start_request(request) {
Ok(()) => 0,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(locale, "gtc.start.err.run_failed", "failed to start bundle")
);
1
}
}
}
fn run_add_admin(sub_matches: &ArgMatches, _locale: &str) -> i32 {
let Some(bundle_ref) = sub_matches.get_one::<String>("bundle-ref") else {
eprintln!("missing bundle ref");
return 2;
};
let Some(client_cn) = sub_matches.get_one::<String>("cn") else {
eprintln!("missing --cn");
return 2;
};
let Some(public_key_file) = sub_matches.get_one::<String>("public-key-file") else {
eprintln!("missing --public-key-file");
return 2;
};
let bundle_dir = match resolve_local_mutable_bundle_dir(bundle_ref) {
Ok(path) => path,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
let public_key = match fs::read_to_string(public_key_file) {
Ok(value) => value.trim().to_string(),
Err(err) => {
eprintln!("failed to read public key file {}: {err}", public_key_file);
return 1;
}
};
if public_key.is_empty() {
eprintln!("public key file is empty: {}", public_key_file);
return 1;
}
let mut registry = match load_admin_registry(&bundle_dir) {
Ok(doc) => doc,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
let name = sub_matches.get_one::<String>("name").cloned();
upsert_admin_registry_entry(&mut registry, name.clone(), client_cn.clone(), public_key);
if let Err(err) = save_admin_registry(&bundle_dir, ®istry) {
eprintln!("{err}");
return 1;
}
println!(
"admin added: cn={} name={} bundle={}",
client_cn,
name.as_deref().unwrap_or(""),
bundle_dir.display()
);
0
}
fn run_remove_admin(sub_matches: &ArgMatches, _locale: &str) -> i32 {
let Some(bundle_ref) = sub_matches.get_one::<String>("bundle-ref") else {
eprintln!("missing bundle ref");
return 2;
};
let selector_cn = sub_matches.get_one::<String>("cn").cloned();
let selector_name = sub_matches.get_one::<String>("name").cloned();
if selector_cn.is_none() && selector_name.is_none() {
eprintln!("either --cn or --name is required");
return 2;
}
let bundle_dir = match resolve_local_mutable_bundle_dir(bundle_ref) {
Ok(path) => path,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
let mut registry = match load_admin_registry(&bundle_dir) {
Ok(doc) => doc,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
let removed = remove_admin_registry_entry(
&mut registry,
selector_cn.as_deref(),
selector_name.as_deref(),
);
if !removed {
eprintln!("no matching admin entry found");
return 1;
}
if let Err(err) = save_admin_registry(&bundle_dir, ®istry) {
eprintln!("{err}");
return 1;
}
println!(
"admin removed: cn={} name={} bundle={}",
selector_cn.as_deref().unwrap_or(""),
selector_name.as_deref().unwrap_or(""),
bundle_dir.display()
);
0
}
fn run_stop(sub_matches: &ArgMatches, debug: bool, locale: &str) -> i32 {
let Some(bundle_ref) = sub_matches.get_one::<String>("bundle-ref") else {
eprintln!(
"{}",
t_or(
locale,
"gtc.stop.err.bundle_required",
"bundle ref is required"
)
);
return 2;
};
let tail = collect_tail(sub_matches);
let cli_options = match parse_stop_cli_options(&tail) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.stop.err.invalid_args",
"invalid stop arguments"
)
);
return 2;
}
};
let resolved = match resolve_bundle_reference(bundle_ref, locale) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.stop.err.resolve_failed",
"failed to resolve bundle"
)
);
return 1;
}
};
let request = match parse_stop_request(&cli_options.stop_args, resolved.bundle_dir.clone()) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.stop.err.invalid_args",
"invalid stop arguments"
)
);
return 2;
}
};
let target =
match select_start_target(&resolved.bundle_dir, cli_options.explicit_target, locale) {
Ok(value) => value,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.stop.err.target_select_failed",
"failed to choose deployment target"
)
);
return 2;
}
};
match target {
StartTarget::Runtime => {
if cli_options.destroy {
eprintln!(
"{}",
t_or(
locale,
"gtc.stop.err.runtime_destroy_unsupported",
"--destroy is not supported for runtime target"
)
);
return 2;
}
if debug {
eprintln!(
"{} gtc-stop-lib bundle={:?} tenant={} team={}",
t(locale, "gtc.debug.exec"),
request.bundle,
request.tenant,
request.team
);
}
match run_stop_request(request) {
Ok(()) => 0,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(locale, "gtc.stop.err.run_failed", "failed to stop bundle")
);
1
}
}
}
_ => {
if !cli_options.destroy {
eprintln!(
"{}",
t_or(
locale,
"gtc.stop.err.destroy_required",
"deployed targets currently require --destroy; stop without destroy is not implemented"
)
);
return 2;
}
match destroy_bundle_deployment(
bundle_ref,
&resolved,
&request,
&cli_options,
target,
debug,
locale,
) {
Ok(()) => 0,
Err(err) => {
eprintln!(
"{}: {err}",
t_or(
locale,
"gtc.stop.err.destroy_failed",
"failed to destroy deployed bundle"
)
);
1
}
}
}
}
}
fn ensure_bundle_deployed(
bundle_ref: &str,
resolved: &StartBundleResolution,
request: &StartRequest,
cli_options: &StartCliOptions,
target: StartTarget,
debug: bool,
locale: &str,
) -> Result<(), String> {
let fingerprint = fingerprint_bundle_dir(&resolved.bundle_dir)?;
let state_path = deployment_state_path(&resolved.deployment_key, target)?;
let previous_state = load_deployment_state(&state_path)?;
let deploy_needed = previous_state
.as_ref()
.map(|state| normalize_bundle_fingerprint(&state.bundle_fingerprint) != fingerprint)
.unwrap_or(true);
match target {
StartTarget::SingleVm => {
println!("Preparing deployable artifact for target: single-vm");
let artifact_path = prepare_deployable_bundle_artifact(resolved, debug, locale)?;
println!("Deployable artifact: {}", artifact_path.display());
let spec_path = write_single_vm_spec(bundle_ref, resolved, request, &artifact_path)?;
println!("Single-vm deployment spec: {}", spec_path.display());
let current_status = read_single_vm_status(&spec_path, debug, locale)?;
let status_applied = current_status
.as_ref()
.and_then(|value| value.get("status"))
.and_then(Value::as_str)
.map(|value| value == "applied")
.unwrap_or(false);
if status_applied && !deploy_needed {
println!("single-vm deployment already up-to-date");
return Ok(());
}
println!("Applying single-vm deployment...");
run_single_vm_apply(&spec_path, debug, locale)?;
let state = StartDeploymentState {
target: target.as_str().to_string(),
bundle_fingerprint: fingerprint,
bundle_ref: bundle_ref.to_string(),
deployed_at_epoch_s: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| err.to_string())?
.as_secs(),
artifact_path: Some(artifact_path.display().to_string()),
};
save_deployment_state(&state_path, &state)?;
Ok(())
}
StartTarget::Aws | StartTarget::Gcp | StartTarget::Azure => {
println!("Applying cloud deployment target: {}", target.as_str());
// For cloud targets, local deployment state is not authoritative
// enough to prove the remote infrastructure still exists. Re-apply
// on each start so the deployer reconciles remote state.
run_multi_target_deployer_apply(
bundle_ref,
resolved,
request,
cli_options,
target,
debug,
locale,
)?;
let state = StartDeploymentState {
target: target.as_str().to_string(),
bundle_fingerprint: fingerprint,
bundle_ref: bundle_ref.to_string(),
deployed_at_epoch_s: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| err.to_string())?
.as_secs(),
artifact_path: None,
};
save_deployment_state(&state_path, &state)?;
Ok(())
}
StartTarget::Runtime => Ok(()),
}
}
fn prepare_deployable_bundle_artifact(
resolved: &StartBundleResolution,
debug: bool,
locale: &str,
) -> Result<PathBuf, String> {
if let Some(path) = resolved.deploy_artifact.as_ref() {
return Ok(path.clone());
}
let artifact_root = deployment_artifacts_root()?;
fs::create_dir_all(&artifact_root).map_err(|err| {
format!(
"failed to create deployment artifact root {}: {err}",
artifact_root.display()
)
})?;
let out_path = artifact_root.join(format!("{}.gtbundle", resolved.deployment_key));
let args = vec![
"bundle".to_string(),
"build".to_string(),
"--bundle".to_string(),
resolved.bundle_dir.display().to_string(),
"--out".to_string(),
out_path.display().to_string(),
];
run_binary_checked(SETUP_BIN, &args, debug, locale, "bundle build")?;
Ok(out_path)
}
fn sha256_file(path: &Path) -> Result<String, String> {
let bytes = fs::read(path).map_err(|err| {
format!(
"failed to read artifact {} for sha256: {err}",
path.display()
)
})?;
let mut hasher = Sha256::new();
hasher.update(bytes);
Ok(format!("sha256:{:x}", hasher.finalize()))
}
fn resolve_remote_deploy_bundle_source_override() -> Option<String> {
std::env::var("GREENTIC_DEPLOY_BUNDLE_SOURCE")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn deployment_artifacts_root() -> Result<PathBuf, String> {
let base = BaseDirs::new()
.ok_or_else(|| "failed to resolve base directories for deployment artifacts".to_string())?;
Ok(base
.data_local_dir()
.join("greentic")
.join("gtc")
.join("bundles"))
}
fn deployment_state_path(deployment_key: &str, target: StartTarget) -> Result<PathBuf, String> {
let base = BaseDirs::new()
.ok_or_else(|| "failed to resolve base directories for deployment state".to_string())?;
Ok(base
.state_dir()
.unwrap_or_else(|| base.data_local_dir())
.join("greentic")
.join("gtc")
.join("deployments")
.join(format!("{deployment_key}-{}.json", target.as_str())))
}
fn load_deployment_state(path: &Path) -> Result<Option<StartDeploymentState>, String> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path)
.map_err(|err| format!("failed to read deployment state {}: {err}", path.display()))?;
let state = serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse deployment state {}: {err}", path.display()))?;
Ok(Some(state))
}
fn save_deployment_state(path: &Path, state: &StartDeploymentState) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create deployment state directory {}: {err}",
parent.display()
)
})?;
}
let raw = serde_json::to_vec_pretty(state)
.map_err(|err| format!("failed to serialize deployment state: {err}"))?;
fs::write(path, raw)
.map_err(|err| format!("failed to write deployment state {}: {err}", path.display()))
}
fn admin_registry_path(bundle_dir: &Path) -> PathBuf {
bundle_dir
.join(".greentic")
.join("admin")
.join("admins.json")
}
fn load_admin_registry(bundle_dir: &Path) -> Result<AdminRegistryDocument, String> {
let path = admin_registry_path(bundle_dir);
if !path.exists() {
return Ok(AdminRegistryDocument { admins: Vec::new() });
}
let raw = fs::read_to_string(&path)
.map_err(|err| format!("failed to read admin registry {}: {err}", path.display()))?;
serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse admin registry {}: {err}", path.display()))
}
fn save_admin_registry(bundle_dir: &Path, registry: &AdminRegistryDocument) -> Result<(), String> {
let path = admin_registry_path(bundle_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create admin registry dir {}: {err}",
parent.display()
)
})?;
}
let raw = serde_json::to_vec_pretty(registry)
.map_err(|err| format!("failed to serialize admin registry: {err}"))?;
fs::write(&path, raw)
.map_err(|err| format!("failed to write admin registry {}: {err}", path.display()))
}
fn upsert_admin_registry_entry(
registry: &mut AdminRegistryDocument,
name: Option<String>,
client_cn: String,
public_key: String,
) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_secs())
.unwrap_or(0);
if let Some(existing) = registry
.admins
.iter_mut()
.find(|entry| entry.client_cn == client_cn)
{
existing.name = name;
existing.public_key = public_key;
existing.added_at_epoch_s = now;
return;
}
registry.admins.push(AdminRegistryEntry {
name,
client_cn,
public_key,
added_at_epoch_s: now,
});
registry
.admins
.sort_by(|left, right| left.client_cn.cmp(&right.client_cn));
}
fn remove_admin_registry_entry(
registry: &mut AdminRegistryDocument,
client_cn: Option<&str>,
name: Option<&str>,
) -> bool {
let before = registry.admins.len();
registry.admins.retain(|entry| {
if let Some(client_cn) = client_cn {
return entry.client_cn != client_cn;
}
if let Some(name) = name {
return entry.name.as_deref() != Some(name);
}
true
});
before != registry.admins.len()
}
fn write_single_vm_spec(
bundle_ref: &str,
resolved: &StartBundleResolution,
request: &StartRequest,
artifact_path: &Path,
) -> Result<PathBuf, String> {
let cert_dir = resolve_admin_cert_dir(&resolved.bundle_dir);
let deployment_name = deployment_name(bundle_ref, request);
let state_root = deployment_runtime_root(&resolved.deployment_key)?;
let spec_dir = state_root.join("spec");
fs::create_dir_all(&spec_dir).map_err(|err| {
format!(
"failed to create spec directory {}: {err}",
spec_dir.display()
)
})?;
let spec_path = spec_dir.join("single-vm.deployment.yaml");
let spec = format!(
"apiVersion: greentic.ai/v1alpha1\nkind: Deployment\nmetadata:\n name: {name}\nspec:\n target: single-vm\n bundle:\n source: {bundle}\n format: squashfs\n runtime:\n image: {image}\n arch: x86_64\n admin:\n bind: 127.0.0.1:8433\n mtls:\n caFile: {ca}\n certFile: {cert}\n keyFile: {key}\n storage:\n stateDir: {state_dir}\n cacheDir: {cache_dir}\n logDir: {log_dir}\n tempDir: {temp_dir}\n service:\n manager: systemd\n user: greentic\n group: greentic\n health:\n readinessPath: /ready\n livenessPath: /health\n startupTimeoutSeconds: 120\n rollout:\n strategy: recreate\n",
name = deployment_name,
bundle = yaml_string(&format!("file://{}", artifact_path.display())),
image = yaml_string("ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless"),
ca = yaml_string(&cert_dir.join("ca.crt").display().to_string()),
cert = yaml_string(&cert_dir.join("server.crt").display().to_string()),
key = yaml_string(&cert_dir.join("server.key").display().to_string()),
state_dir = yaml_string(&state_root.join("state").display().to_string()),
cache_dir = yaml_string(&state_root.join("cache").display().to_string()),
log_dir = yaml_string(&state_root.join("log").display().to_string()),
temp_dir = yaml_string(&state_root.join("tmp").display().to_string()),
);
fs::write(&spec_path, spec).map_err(|err| {
format!(
"failed to write deployment spec {}: {err}",
spec_path.display()
)
})?;
Ok(spec_path)
}
fn resolve_admin_cert_dir(bundle_dir: &Path) -> PathBuf {
for candidate in [
bundle_dir.join(".greentic").join("admin").join("certs"),
bundle_dir.join("certs"),
] {
if candidate.join("ca.crt").exists()
&& candidate.join("server.crt").exists()
&& candidate.join("server.key").exists()
{
return candidate;
}
}
PathBuf::from("/etc/greentic/admin")
}
fn ensure_admin_certs_ready(
bundle_dir: &Path,
explicit_dir: Option<&Path>,
) -> Result<PathBuf, String> {
if let Some(explicit_dir) = explicit_dir {
ensure_admin_cert_dir_contents(explicit_dir)?;
return Ok(explicit_dir.to_path_buf());
}
let bundle_local = bundle_dir.join(".greentic").join("admin").join("certs");
if has_admin_server_certs(&bundle_local) {
ensure_admin_cert_dir_contents(&bundle_local)?;
return Ok(bundle_local);
}
generate_dev_admin_cert_bundle(&bundle_local)?;
Ok(bundle_local)
}
fn has_admin_server_certs(cert_dir: &Path) -> bool {
cert_dir.join("ca.crt").exists()
&& cert_dir.join("server.crt").exists()
&& cert_dir.join("server.key").exists()
}
fn ensure_admin_cert_dir_contents(cert_dir: &Path) -> Result<(), String> {
for required in ["ca.crt", "server.crt", "server.key"] {
let path = cert_dir.join(required);
if !path.exists() {
return Err(format!(
"required admin TLS file missing: {}",
path.display()
));
}
}
Ok(())
}
fn generate_dev_admin_cert_bundle(cert_dir: &Path) -> Result<(), String> {
fs::create_dir_all(cert_dir).map_err(|err| {
format!(
"failed to create admin cert dir {}: {err}",
cert_dir.display()
)
})?;
let mut ca_params = CertificateParams::new(Vec::<String>::new())
.map_err(|err| format!("failed to create admin CA params: {err}"))?;
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(DnType::CommonName, "greentic-admin-ca");
ca_params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::CrlSign,
];
let ca_key = KeyPair::generate().map_err(|err| format!("failed to generate CA key: {err}"))?;
let ca_issuer = CertifiedIssuer::self_signed(ca_params, ca_key)
.map_err(|err| format!("failed to generate CA certificate: {err}"))?;
let mut server_params = CertificateParams::new(vec!["localhost".to_string()])
.map_err(|err| format!("failed to create admin server cert params: {err}"))?;
server_params
.distinguished_name
.push(DnType::CommonName, "greentic-admin-server");
server_params.subject_alt_names.push(SanType::IpAddress(
"127.0.0.1".parse().expect("static localhost ip"),
));
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let server_key =
KeyPair::generate().map_err(|err| format!("failed to generate server key: {err}"))?;
let server_cert = server_params
.signed_by(&server_key, &*ca_issuer)
.map_err(|err| format!("failed to generate server certificate: {err}"))?;
let mut client_params = CertificateParams::new(Vec::<String>::new())
.map_err(|err| format!("failed to create admin client cert params: {err}"))?;
client_params
.distinguished_name
.push(DnType::CommonName, "local-admin");
client_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
client_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let client_key =
KeyPair::generate().map_err(|err| format!("failed to generate client key: {err}"))?;
let client_cert = client_params
.signed_by(&client_key, &*ca_issuer)
.map_err(|err| format!("failed to generate client certificate: {err}"))?;
fs::write(cert_dir.join("ca.crt"), ca_issuer.pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("ca.crt").display()
)
})?;
fs::write(cert_dir.join("ca.key"), ca_issuer.key().serialize_pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("ca.key").display()
)
})?;
fs::write(cert_dir.join("server.crt"), server_cert.pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("server.crt").display()
)
})?;
fs::write(cert_dir.join("server.key"), server_key.serialize_pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("server.key").display()
)
})?;
fs::write(cert_dir.join("client.crt"), client_cert.pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("client.crt").display()
)
})?;
fs::write(cert_dir.join("client.key"), client_key.serialize_pem()).map_err(|err| {
format!(
"failed to write {}: {err}",
cert_dir.join("client.key").display()
)
})?;
Ok(())
}
fn run_multi_target_deployer_apply(
_bundle_ref: &str,
resolved: &StartBundleResolution,
request: &StartRequest,
cli_options: &StartCliOptions,
target: StartTarget,
debug: bool,
locale: &str,
) -> Result<(), String> {
let bundle_artifact = prepare_deployable_bundle_artifact(resolved, debug, locale)?;
let bundle_digest = sha256_file(&bundle_artifact)?;
let remote_override = cli_options
.deploy_bundle_source
.clone()
.or_else(resolve_remote_deploy_bundle_source_override);
validate_cloud_deploy_inputs(
target,
remote_override.as_deref(),
&resolved.bundle_dir,
locale,
)?;
let deploy_bundle_source = remote_override
.clone()
.unwrap_or_else(|| bundle_artifact.display().to_string());
let app_pack =
resolve_deploy_app_pack_path(&resolved.bundle_dir, cli_options.app_pack.as_ref())?;
let provider_pack = resolve_target_provider_pack(
&resolved.bundle_dir,
target,
cli_options.provider_pack.as_ref(),
)?;
let tenant = request.tenant.clone().unwrap_or_else(|| "demo".to_string());
let target_name = target.as_str().to_string();
println!("Deployment artifact: {}", bundle_artifact.display());
println!("Deployment bundle source: {deploy_bundle_source}");
println!("Deployment bundle digest: {bundle_digest}");
if remote_override.is_none() {
println!(
"Note: no GREENTIC_DEPLOY_BUNDLE_SOURCE override set; cloud deploy will use the local artifact path above."
);
}
print_cloud_deploy_contract_hint(target);
let mut args = vec![
target_name,
"apply".to_string(),
"--tenant".to_string(),
tenant,
"--bundle-pack".to_string(),
app_pack.display().to_string(),
"--provider-pack".to_string(),
provider_pack.display().to_string(),
"--bundle-source".to_string(),
deploy_bundle_source.clone(),
"--bundle-digest".to_string(),
bundle_digest,
"--execute".to_string(),
"--output".to_string(),
"json".to_string(),
];
append_bundle_registry_args(&mut args, &deploy_bundle_source)?;
if let Some(environment) = cli_options.environment.as_deref() {
args.push("--environment".to_string());
args.push(environment.to_string());
}
run_binary_checked_with_target(
DEPLOYER_BIN,
&args,
debug,
locale,
"multi-target deploy apply",
Some(target),
)
}
fn print_cloud_deploy_contract_hint(target: StartTarget) {
println!("Cloud deploy contract:");
println!(" required remote bundle source:");
println!(" --deploy-bundle-source https://.../bundle.gtbundle");
match target {
StartTarget::Aws => {
println!(" required external Terraform vars:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND");
println!(" optional Terraform vars:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE");
println!(" default: {DEFAULT_AWS_OPERATOR_IMAGE}");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
println!(" fallback default: {DEFAULT_OPERATOR_IMAGE_DIGEST}");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_DNS_NAME (personalized mode only)");
println!(" internal AWS bootstrap now handles:");
println!(" admin TLS server secrets");
}
StartTarget::Gcp => {
println!(" optional Terraform vars:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE");
println!(" default: {DEFAULT_GCP_OPERATOR_IMAGE}");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
println!(" fallback default: {DEFAULT_OPERATOR_IMAGE_DIGEST}");
println!(" additional target-specific Terraform vars may still be required via:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_*");
}
StartTarget::Azure => {
println!(" optional Terraform vars:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE");
println!(" default: {DEFAULT_AZURE_OPERATOR_IMAGE}");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
println!(" fallback default: {DEFAULT_OPERATOR_IMAGE_DIGEST}");
println!(" additional target-specific Terraform vars may still be required via:");
println!(" GREENTIC_DEPLOY_TERRAFORM_VAR_*");
}
StartTarget::SingleVm | StartTarget::Runtime => {}
}
}
fn default_operator_image_for_target(target: StartTarget) -> Option<&'static str> {
match target {
StartTarget::Aws => Some(DEFAULT_AWS_OPERATOR_IMAGE),
StartTarget::Gcp => Some(DEFAULT_GCP_OPERATOR_IMAGE),
StartTarget::Azure => Some(DEFAULT_AZURE_OPERATOR_IMAGE),
StartTarget::SingleVm | StartTarget::Runtime => None,
}
}
fn validate_cloud_deploy_inputs(
target: StartTarget,
remote_bundle_source: Option<&str>,
bundle_dir: &Path,
locale: &str,
) -> Result<(), String> {
require_tool_in_path(
"terraform",
"install terraform and make sure it is available in PATH",
)?;
validate_public_base_url_for_static_routes(bundle_dir)?;
match target {
StartTarget::Aws => {
ensure_cloud_credentials(target, locale)?;
ensure_target_terraform_inputs(target)?;
let remote_bundle_source = remote_bundle_source.ok_or_else(|| {
"aws deploy requires a remote bundle source; pass --deploy-bundle-source https://.../bundle.gtbundle or set GREENTIC_DEPLOY_BUNDLE_SOURCE".to_string()
})?;
if !is_remote_bundle_source(remote_bundle_source) {
return Err(format!(
"aws deploy requires a remote bundle source, got local path: {remote_bundle_source}"
));
}
validate_bundle_registry_mapping_env(remote_bundle_source)?;
Ok(())
}
StartTarget::Gcp | StartTarget::Azure => {
ensure_cloud_credentials(target, locale)?;
ensure_target_terraform_inputs(target)?;
let remote_bundle_source = remote_bundle_source.ok_or_else(|| {
format!(
"{} deploy requires a remote bundle source; pass --deploy-bundle-source https://.../bundle.gtbundle or set GREENTIC_DEPLOY_BUNDLE_SOURCE",
target.as_str()
)
})?;
if !is_remote_bundle_source(remote_bundle_source) {
return Err(format!(
"{} deploy requires a remote bundle source, got local path: {remote_bundle_source}",
target.as_str()
));
}
validate_bundle_registry_mapping_env(remote_bundle_source)?;
Ok(())
}
StartTarget::SingleVm | StartTarget::Runtime => Ok(()),
}
}
fn validate_public_base_url_for_static_routes(bundle_dir: &Path) -> Result<(), String> {
if !bundle_declares_static_routes(bundle_dir)? {
return Ok(());
}
Ok(())
}
fn bundle_declares_static_routes(bundle_dir: &Path) -> Result<bool, String> {
for root in [bundle_dir.join("providers"), bundle_dir.join("packs")] {
if !root.exists() {
continue;
}
if dir_declares_static_routes(&root)? {
return Ok(true);
}
}
Ok(false)
}
fn dir_declares_static_routes(root: &Path) -> Result<bool, String> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries =
fs::read_dir(&dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|err| format!("failed to read dir entry: {err}"))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|err| format!("failed to stat {}: {err}", path.display()))?;
if file_type.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
continue;
}
if pack_declares_static_routes(&path)? {
return Ok(true);
}
}
}
Ok(false)
}
fn pack_declares_static_routes(path: &Path) -> Result<bool, String> {
const EXT_STATIC_ROUTES_V1: &str = "greentic.static-routes.v1";
let file =
fs::File::open(path).map_err(|err| format!("failed to open {}: {err}", path.display()))?;
let mut archive = ZipArchive::new(file)
.map_err(|err| format!("failed to open zip archive {}: {err}", path.display()))?;
let mut manifest_entry = archive
.by_name("manifest.cbor")
.map_err(|err| format!("failed to open manifest.cbor in {}: {err}", path.display()))?;
let mut bytes = Vec::new();
manifest_entry
.read_to_end(&mut bytes)
.map_err(|err| format!("failed to read manifest.cbor in {}: {err}", path.display()))?;
let manifest = decode_pack_manifest(&bytes).map_err(|err| {
format!(
"failed to decode pack manifest in {}: {err}",
path.display()
)
})?;
Ok(manifest
.extensions
.as_ref()
.is_some_and(|extensions| extensions.contains_key(EXT_STATIC_ROUTES_V1)))
}
fn validate_bundle_registry_mapping_env(bundle_source: &str) -> Result<(), String> {
if bundle_source.starts_with("repo://") {
require_env_var("GREENTIC_REPO_REGISTRY_BASE")?;
}
if bundle_source.starts_with("store://") {
require_env_var("GREENTIC_STORE_REGISTRY_BASE")?;
}
Ok(())
}
fn append_bundle_registry_args(args: &mut Vec<String>, bundle_source: &str) -> Result<(), String> {
if bundle_source.starts_with("repo://") {
let value = env::var("GREENTIC_REPO_REGISTRY_BASE").map_err(|_| {
"missing required environment variable GREENTIC_REPO_REGISTRY_BASE".to_string()
})?;
if value.trim().is_empty() {
return Err("GREENTIC_REPO_REGISTRY_BASE must not be empty".to_string());
}
args.push("--repo-registry-base".to_string());
args.push(value);
}
if bundle_source.starts_with("store://") {
let value = env::var("GREENTIC_STORE_REGISTRY_BASE").map_err(|_| {
"missing required environment variable GREENTIC_STORE_REGISTRY_BASE".to_string()
})?;
if value.trim().is_empty() {
return Err("GREENTIC_STORE_REGISTRY_BASE must not be empty".to_string());
}
args.push("--store-registry-base".to_string());
args.push(value);
}
Ok(())
}
fn is_remote_bundle_source(value: &str) -> bool {
matches_remote_bundle_ref(value)
}
fn matches_remote_bundle_ref(value: &str) -> bool {
value.starts_with("http://")
|| value.starts_with("https://")
|| value.starts_with("oci://")
|| value.starts_with("repo://")
|| value.starts_with("store://")
}
fn require_env_var(name: &str) -> Result<(), String> {
match env::var(name) {
Ok(value) if !value.trim().is_empty() => Ok(()),
_ => Err(format!("missing required environment variable: {name}")),
}
}
fn missing_cloud_credentials_error(names: &[&str], help: &str) -> String {
format!(
"missing cloud credentials; {}. Expected one of: {}",
help,
names.join(", ")
)
}
fn ensure_cloud_credentials(target: StartTarget, locale: &str) -> Result<(), String> {
let (names, help) = match target {
StartTarget::Aws => (
&[
"AWS_ACCESS_KEY_ID",
"AWS_PROFILE",
"AWS_DEFAULT_PROFILE",
"AWS_WEB_IDENTITY_TOKEN_FILE",
][..],
"set AWS credentials (for example AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or AWS_PROFILE)",
),
StartTarget::Azure => (
&[
"ARM_CLIENT_ID",
"ARM_USE_OIDC",
"AZURE_CLIENT_ID",
"AZURE_TENANT_ID",
"AZURE_SUBSCRIPTION_ID",
][..],
"set Azure credentials (for example ARM_CLIENT_ID/ARM_TENANT_ID/ARM_SUBSCRIPTION_ID or the corresponding AZURE_* variables)",
),
StartTarget::Gcp => (
&[
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_OAUTH_ACCESS_TOKEN",
"CLOUDSDK_AUTH_ACCESS_TOKEN",
][..],
"set GCP credentials (for example GOOGLE_APPLICATION_CREDENTIALS or CLOUDSDK_AUTH_ACCESS_TOKEN)",
),
StartTarget::SingleVm | StartTarget::Runtime => return Ok(()),
};
if cloud_credentials_satisfied(target) {
return Ok(());
}
if !can_prompt_interactively() {
return Err(missing_cloud_credentials_error(names, help));
}
let _ = locale;
println!(
"Cloud credentials for {} are missing. gtc can collect them for this run.",
target.as_str()
);
match target {
StartTarget::Aws => prompt_aws_credentials()?,
StartTarget::Azure => prompt_azure_credentials()?,
StartTarget::Gcp => prompt_gcp_credentials()?,
StartTarget::SingleVm | StartTarget::Runtime => {}
}
if cloud_credentials_satisfied(target) {
Ok(())
} else {
Err(missing_cloud_credentials_error(names, help))
}
}
fn ensure_target_terraform_inputs(target: StartTarget) -> Result<(), String> {
let requirements: &[(&str, bool, Option<&str>)] = match target {
StartTarget::Aws => &[(
"GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND",
true,
None,
)],
StartTarget::Gcp => &[
("GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID", true, None),
(
"GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_REGION",
true,
Some("us-central1"),
),
],
StartTarget::Azure => &[
(
"GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID",
true,
None,
),
(
"GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_LOCATION",
true,
Some("westeurope"),
),
],
StartTarget::SingleVm | StartTarget::Runtime => &[],
};
if requirements.is_empty() {
return Ok(());
}
let missing: Vec<_> = requirements
.iter()
.filter(|(name, required, _)| *required && !env_var_present(name))
.copied()
.collect();
if missing.is_empty() {
return Ok(());
}
if !can_prompt_interactively() {
return Err(format!(
"missing required deployment configuration: {}",
missing
.iter()
.map(|(name, _, _)| *name)
.collect::<Vec<_>>()
.join(", ")
));
}
println!(
"Additional {} deployment inputs are required for this run.",
target.as_str()
);
for (name, _, default) in missing {
let prompt = match name {
"GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND" => {
"Terraform remote state backend:"
}
"GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID" => "GCP project ID:",
"GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_REGION" => "GCP region:",
"GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID" => "Azure Key Vault resource ID:",
"GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_LOCATION" => "Azure location:",
_ => name,
};
let value = prompt_value_with_default(prompt, default)?;
unsafe {
env::set_var(name, value);
}
}
Ok(())
}
fn cloud_credentials_satisfied(target: StartTarget) -> bool {
match target {
StartTarget::Aws => {
env_var_present("AWS_PROFILE")
|| env_var_present("AWS_DEFAULT_PROFILE")
|| env_var_present("AWS_WEB_IDENTITY_TOKEN_FILE")
|| (env_var_present("AWS_ACCESS_KEY_ID")
&& env_var_present("AWS_SECRET_ACCESS_KEY"))
}
StartTarget::Azure => {
(env_var_present("ARM_CLIENT_ID")
&& env_var_present("ARM_TENANT_ID")
&& env_var_present("ARM_SUBSCRIPTION_ID")
&& (env_var_present("ARM_CLIENT_SECRET") || env_var_present("ARM_USE_OIDC")))
|| (env_var_present("AZURE_CLIENT_ID")
&& env_var_present("AZURE_TENANT_ID")
&& env_var_present("AZURE_SUBSCRIPTION_ID"))
}
StartTarget::Gcp => {
env_var_present("GOOGLE_APPLICATION_CREDENTIALS")
|| env_var_present("GOOGLE_OAUTH_ACCESS_TOKEN")
|| env_var_present("CLOUDSDK_AUTH_ACCESS_TOKEN")
}
StartTarget::SingleVm | StartTarget::Runtime => true,
}
}
fn env_var_present(name: &str) -> bool {
env::var(name)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
}
fn can_prompt_interactively() -> bool {
if cfg!(test) {
return false;
}
io::stdin().is_terminal() && io::stdout().is_terminal()
}
fn prompt_aws_credentials() -> Result<(), String> {
let mode = prompt_choice(
"Select AWS credential input mode:",
&[
"Access key pair",
"AWS profile",
"Web identity token file",
"Abort",
],
)?;
match mode {
0 => {
let access_key_id = prompt_non_empty("AWS access key ID:")?;
let secret_access_key = prompt_secret("AWS secret access key:")?;
let session_token = prompt_optional_secret("AWS session token (optional):")?;
let default_region = prompt_optional("AWS default region (optional):")?;
unsafe {
env::set_var("AWS_ACCESS_KEY_ID", access_key_id);
env::set_var("AWS_SECRET_ACCESS_KEY", secret_access_key);
if let Some(value) = session_token {
env::set_var("AWS_SESSION_TOKEN", value);
}
if let Some(value) = default_region {
env::set_var("AWS_DEFAULT_REGION", value);
}
}
}
1 => {
let profile = prompt_non_empty("AWS profile:")?;
let default_region = prompt_optional("AWS default region (optional):")?;
unsafe {
env::set_var("AWS_PROFILE", profile);
if let Some(value) = default_region {
env::set_var("AWS_DEFAULT_REGION", value);
}
}
}
2 => {
let token_file = prompt_non_empty("AWS web identity token file:")?;
let role_arn = prompt_optional("AWS role ARN (optional):")?;
unsafe {
env::set_var("AWS_WEB_IDENTITY_TOKEN_FILE", token_file);
if let Some(value) = role_arn {
env::set_var("AWS_ROLE_ARN", value);
}
}
}
_ => return Err("cloud deploy aborted before AWS credentials were configured".to_string()),
}
Ok(())
}
fn prompt_azure_credentials() -> Result<(), String> {
let mode = prompt_choice(
"Select Azure credential input mode:",
&["ARM service principal", "Azure OIDC", "Abort"],
)?;
match mode {
0 => {
let subscription_id = prompt_non_empty("Azure subscription ID:")?;
let tenant_id = prompt_non_empty("Azure tenant ID:")?;
let client_id = prompt_non_empty("Azure client ID:")?;
let client_secret = prompt_secret("Azure client secret:")?;
unsafe {
env::set_var("ARM_SUBSCRIPTION_ID", subscription_id);
env::set_var("ARM_TENANT_ID", tenant_id);
env::set_var("ARM_CLIENT_ID", client_id);
env::set_var("ARM_CLIENT_SECRET", client_secret);
}
}
1 => {
let subscription_id = prompt_non_empty("Azure subscription ID:")?;
let tenant_id = prompt_non_empty("Azure tenant ID:")?;
let client_id = prompt_non_empty("Azure client ID:")?;
unsafe {
env::set_var("ARM_SUBSCRIPTION_ID", subscription_id);
env::set_var("ARM_TENANT_ID", tenant_id);
env::set_var("ARM_CLIENT_ID", client_id);
env::set_var("ARM_USE_OIDC", "true");
}
}
_ => {
return Err(
"cloud deploy aborted before Azure credentials were configured".to_string(),
);
}
}
Ok(())
}
fn prompt_gcp_credentials() -> Result<(), String> {
let mode = prompt_choice(
"Select GCP credential input mode:",
&["Service account credentials file", "Access token", "Abort"],
)?;
match mode {
0 => {
let credentials_file = prompt_non_empty("GOOGLE_APPLICATION_CREDENTIALS path:")?;
unsafe {
env::set_var("GOOGLE_APPLICATION_CREDENTIALS", credentials_file);
}
}
1 => {
let access_token = prompt_secret("GCP access token:")?;
unsafe {
env::set_var("CLOUDSDK_AUTH_ACCESS_TOKEN", access_token);
}
}
_ => return Err("cloud deploy aborted before GCP credentials were configured".to_string()),
}
Ok(())
}
fn prompt_choice(prompt: &str, options: &[&str]) -> Result<usize, String> {
println!("{prompt}");
for (idx, option) in options.iter().enumerate() {
println!("{} ) {}", idx + 1, option);
}
print!("> ");
io::stdout().flush().map_err(|err| err.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|err| err.to_string())?;
let choice = input
.trim()
.parse::<usize>()
.map_err(|_| "invalid selection".to_string())?;
let idx = choice.saturating_sub(1);
if idx < options.len() {
Ok(idx)
} else {
Err("invalid selection".to_string())
}
}
fn prompt_non_empty(prompt: &str) -> Result<String, String> {
loop {
let value = prompt_optional(prompt)?;
if let Some(value) = value {
return Ok(value);
}
println!("A value is required.");
}
}
fn prompt_optional(prompt: &str) -> Result<Option<String>, String> {
print!("{prompt} ");
io::stdout().flush().map_err(|err| err.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|err| err.to_string())?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn prompt_value_with_default(prompt: &str, default: Option<&str>) -> Result<String, String> {
loop {
match default {
Some(default) => {
print!("{prompt} [{default}] ");
}
None => {
print!("{prompt} ");
}
}
io::stdout().flush().map_err(|err| err.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|err| err.to_string())?;
let trimmed = input.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
if let Some(default) = default {
return Ok(default.to_string());
}
println!("A value is required.");
}
}
fn prompt_secret(prompt: &str) -> Result<String, String> {
loop {
let value = rpassword::prompt_password(prompt).map_err(|err| err.to_string())?;
if !value.trim().is_empty() {
return Ok(value);
}
println!("A value is required.");
}
}
fn prompt_optional_secret(prompt: &str) -> Result<Option<String>, String> {
let value = rpassword::prompt_password(prompt).map_err(|err| err.to_string())?;
if value.trim().is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn require_tool_in_path(binary: &str, help: &str) -> Result<(), String> {
if binary_in_path(binary) {
Ok(())
} else {
Err(format!(
"required tool `{binary}` not found in PATH; {help}"
))
}
}
fn binary_in_path(binary: &str) -> bool {
env::var_os("PATH")
.map(|path| {
env::split_paths(&path).any(|dir| resolve_binary_in_dir(&dir, binary).is_some())
})
.unwrap_or(false)
}
fn destroy_bundle_deployment(
bundle_ref: &str,
resolved: &StartBundleResolution,
request: &StopRequest,
cli_options: &StopCliOptions,
target: StartTarget,
debug: bool,
locale: &str,
) -> Result<(), String> {
match target {
StartTarget::SingleVm => {
let artifact_path =
load_or_prepare_single_vm_artifact(resolved, request, debug, locale)?;
let start_request = stop_request_to_start_request(request, resolved, &artifact_path);
let spec_path =
write_single_vm_spec(bundle_ref, resolved, &start_request, &artifact_path)?;
run_single_vm_destroy(&spec_path, debug, locale)?;
remove_deployment_state_file(&resolved.deployment_key, target)?;
Ok(())
}
StartTarget::Aws | StartTarget::Gcp | StartTarget::Azure => {
run_multi_target_deployer_destroy(
resolved,
request,
cli_options,
target,
debug,
locale,
)?;
remove_deployment_state_file(&resolved.deployment_key, target)?;
Ok(())
}
StartTarget::Runtime => Err("runtime target cannot be destroyed via deployer".to_string()),
}
}
fn stop_request_to_start_request(
request: &StopRequest,
resolved: &StartBundleResolution,
artifact_path: &Path,
) -> StartRequest {
StartRequest {
bundle: Some(resolved.bundle_dir.display().to_string()),
tenant: Some(request.tenant.clone()),
team: Some(request.team.clone()),
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: Some(artifact_path.to_path_buf()),
restart: Vec::new(),
log_dir: None,
verbose: false,
quiet: false,
admin: false,
admin_port: 8443,
admin_certs_dir: None,
admin_allowed_clients: Vec::new(),
}
}
fn load_or_prepare_single_vm_artifact(
resolved: &StartBundleResolution,
request: &StopRequest,
debug: bool,
locale: &str,
) -> Result<PathBuf, String> {
let state_path = deployment_state_path(&resolved.deployment_key, StartTarget::SingleVm)?;
if let Some(state) = load_deployment_state(&state_path)?
&& let Some(path) = state.artifact_path
{
let artifact = PathBuf::from(path);
if artifact.exists() {
return Ok(artifact);
}
}
let _ = request;
prepare_deployable_bundle_artifact(resolved, debug, locale)
}
fn run_multi_target_deployer_destroy(
resolved: &StartBundleResolution,
request: &StopRequest,
cli_options: &StopCliOptions,
target: StartTarget,
debug: bool,
locale: &str,
) -> Result<(), String> {
let app_pack =
resolve_deploy_app_pack_path(&resolved.bundle_dir, cli_options.app_pack.as_ref())?;
let provider_pack = resolve_target_provider_pack(
&resolved.bundle_dir,
target,
cli_options.provider_pack.as_ref(),
)?;
let mut args = vec![
target.as_str().to_string(),
"destroy".to_string(),
"--tenant".to_string(),
request.tenant.clone(),
"--bundle-pack".to_string(),
app_pack.display().to_string(),
"--provider-pack".to_string(),
provider_pack.display().to_string(),
"--execute".to_string(),
"--output".to_string(),
"json".to_string(),
];
if let Some(environment) = cli_options.environment.as_deref() {
args.push("--environment".to_string());
args.push(environment.to_string());
}
run_binary_checked_with_target(
DEPLOYER_BIN,
&args,
debug,
locale,
"multi-target deploy destroy",
Some(target),
)
}
fn run_single_vm_destroy(spec_path: &Path, debug: bool, locale: &str) -> Result<(), String> {
let args = vec![
"single-vm".to_string(),
"destroy".to_string(),
"--spec".to_string(),
spec_path.display().to_string(),
"--execute".to_string(),
"--output".to_string(),
"json".to_string(),
];
run_binary_checked(DEPLOYER_BIN, &args, debug, locale, "single-vm destroy")
}
fn remove_deployment_state_file(deployment_key: &str, target: StartTarget) -> Result<(), String> {
let path = deployment_state_path(deployment_key, target)?;
if !path.exists() {
return Ok(());
}
fs::remove_file(&path).map_err(|err| {
format!(
"failed to remove deployment state {}: {err}",
path.display()
)
})
}
fn deployment_runtime_root(deployment_key: &str) -> Result<PathBuf, String> {
let base = BaseDirs::new()
.ok_or_else(|| "failed to resolve base directories for deployment runtime".to_string())?;
Ok(base
.state_dir()
.unwrap_or_else(|| base.data_local_dir())
.join("greentic")
.join("gtc")
.join("single-vm")
.join(deployment_key))
}
fn yaml_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn deployment_name(bundle_ref: &str, request: &StartRequest) -> String {
let mut parts = Vec::new();
if let Some(tenant) = request.tenant.as_deref() {
parts.push(tenant.to_string());
}
if let Some(team) = request.team.as_deref() {
parts.push(team.to_string());
}
parts.push(sanitize_identifier(bundle_ref));
let joined = parts.join("-");
truncate_identifier(&joined, 63)
}
fn read_single_vm_status(
spec_path: &Path,
debug: bool,
locale: &str,
) -> Result<Option<Value>, String> {
let args = vec![
"single-vm".to_string(),
"status".to_string(),
"--spec".to_string(),
spec_path.display().to_string(),
"--output".to_string(),
"json".to_string(),
];
let output = run_binary_capture(DEPLOYER_BIN, &args, debug, locale)?;
if output.trim().is_empty() {
return Ok(None);
}
let parsed = serde_json::from_str(&output)
.map_err(|err| format!("failed to parse deployer status output as JSON: {err}"))?;
Ok(Some(parsed))
}
fn run_single_vm_apply(spec_path: &Path, debug: bool, locale: &str) -> Result<(), String> {
let args = vec![
"single-vm".to_string(),
"apply".to_string(),
"--spec".to_string(),
spec_path.display().to_string(),
"--execute".to_string(),
"--output".to_string(),
"json".to_string(),
];
run_binary_checked(DEPLOYER_BIN, &args, debug, locale, "single-vm apply")
}
fn run_binary_checked(
binary: &str,
args: &[String],
debug: bool,
locale: &str,
operation: &str,
) -> Result<(), String> {
run_binary_checked_with_target(binary, args, debug, locale, operation, None)
}
fn run_binary_checked_with_target(
binary: &str,
args: &[String],
debug: bool,
locale: &str,
operation: &str,
target: Option<StartTarget>,
) -> Result<(), String> {
let status = run_binary_status_with_target(binary, args, debug, locale, target)?;
if status.success() {
return Ok(());
}
Err(format!(
"{operation} failed via {binary} with status {}",
status.code().unwrap_or(1)
))
}
fn run_binary_capture(
binary: &str,
args: &[String],
debug: bool,
locale: &str,
) -> Result<String, String> {
run_binary_capture_with_target(binary, args, debug, locale, None)
}
fn run_binary_capture_with_target(
binary: &str,
args: &[String],
debug: bool,
locale: &str,
target: Option<StartTarget>,
) -> Result<String, String> {
if debug {
eprintln!("{} {} {:?}", t(locale, "gtc.debug.exec"), binary, args);
}
let command = resolve_binary_command(binary);
let mut process = ProcessCommand::new(&command);
process.args(args).env("GREENTIC_LOCALE", locale);
apply_default_deploy_env_for_target(&mut process, target);
let output = process
.output()
.map_err(|err| format!("failed to execute {binary}: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"{binary} exited with status {}",
output.status.code().unwrap_or(1)
));
}
return Err(stderr);
}
String::from_utf8(output.stdout).map_err(|err| format!("invalid UTF-8 from {binary}: {err}"))
}
fn run_binary_status_with_target(
binary: &str,
args: &[String],
debug: bool,
locale: &str,
target: Option<StartTarget>,
) -> Result<std::process::ExitStatus, String> {
if debug {
eprintln!("{} {} {:?}", t(locale, "gtc.debug.exec"), binary, args);
}
let command = resolve_binary_command(binary);
let mut process = ProcessCommand::new(&command);
process
.args(args)
.env("GREENTIC_LOCALE", locale)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
apply_default_deploy_env_for_target(&mut process, target);
process
.status()
.map_err(|err| format!("failed to execute {binary}: {err}"))
}
fn apply_default_deploy_env_for_target(process: &mut ProcessCommand, target: Option<StartTarget>) {
if env::var_os("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE").is_none()
&& let Some(target) = target
&& let Some(image) = default_operator_image_for_target(target)
{
process.env("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE", image);
}
if env::var_os("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST").is_none() {
process.env(
"GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST",
DEFAULT_OPERATOR_IMAGE_DIGEST,
);
}
}
fn fingerprint_bundle_dir(bundle_dir: &Path) -> Result<String, String> {
let mut files = Vec::new();
collect_bundle_entries(bundle_dir, bundle_dir, &mut files)?;
files.sort();
Ok(normalize_bundle_fingerprint(&files.join("\n")))
}
fn collect_bundle_entries(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
for entry in fs::read_dir(dir)
.map_err(|err| format!("failed to read bundle directory {}: {err}", dir.display()))?
{
let entry = entry.map_err(|err| err.to_string())?;
let path = entry.path();
let relative = path
.strip_prefix(root)
.map_err(|err| err.to_string())?
.display()
.to_string();
if path.is_dir() {
out.push(format!("dir:{relative}"));
collect_bundle_entries(root, &path, out)?;
continue;
}
let metadata = entry.metadata().map_err(|err| err.to_string())?;
let modified = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_secs())
.unwrap_or(0);
out.push(format!("file:{relative}:{}:{modified}", metadata.len()));
}
Ok(())
}
fn normalize_bundle_fingerprint(raw: &str) -> String {
raw.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
if let Some(path) = line.strip_prefix("dir:") {
if should_ignore_fingerprint_path(Path::new(path)) {
return None;
}
return Some(format!("dir:{path}"));
}
if let Some(rest) = line.strip_prefix("file:") {
let mut parts = rest.splitn(3, ':');
let path = parts.next().unwrap_or_default();
let size = parts.next().unwrap_or_default();
if should_ignore_fingerprint_path(Path::new(path)) {
return None;
}
return Some(format!("file:{path}:{size}"));
}
None
})
.collect::<Vec<_>>()
.join("\n")
}
fn should_ignore_fingerprint_path(path: &Path) -> bool {
let mut components = path.components();
let first = components.next();
let second = components.next();
matches!(
(first, second),
(
Some(Component::Normal(first)),
Some(Component::Normal(second))
) if (first == ".greentic" && second == "dev")
|| (first == "state"
&& (second == "logs"
|| second == "pids"
|| second == "runtime"
|| second == "runs"))
) || matches!(first, Some(Component::Normal(first)) if first == "logs")
}
fn sanitize_identifier(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_matches('-').to_string()
}
fn truncate_identifier(value: &str, limit: usize) -> String {
if value.len() <= limit {
return value.to_string();
}
value.chars().take(limit).collect()
}
fn parse_start_request(tail: &[String], bundle_dir: PathBuf) -> Result<StartRequest, String> {
let mut request = StartRequest {
bundle: Some(bundle_dir.display().to_string()),
tenant: None,
team: None,
no_nats: false,
nats: NatsModeArg::Off,
nats_url: None,
config: None,
cloudflared: CloudflaredModeArg::On,
cloudflared_binary: None,
ngrok: NgrokModeArg::Off,
ngrok_binary: None,
runner_binary: None,
restart: Vec::new(),
log_dir: None,
verbose: false,
quiet: false,
admin: false,
admin_port: 8443,
admin_certs_dir: None,
admin_allowed_clients: Vec::new(),
};
let mut idx = 0usize;
while idx < tail.len() {
let arg = &tail[idx];
match arg.as_str() {
"--tenant" => {
idx += 1;
request.tenant = Some(required_value(tail, idx, "--tenant")?);
}
"--team" => {
idx += 1;
request.team = Some(required_value(tail, idx, "--team")?);
}
"--no-nats" => request.no_nats = true,
"--nats" => {
idx += 1;
request.nats = parse_nats_mode(&required_value(tail, idx, "--nats")?)?;
}
"--nats-url" => {
idx += 1;
request.nats_url = Some(required_value(tail, idx, "--nats-url")?);
}
"--config" => {
idx += 1;
request.config = Some(PathBuf::from(required_value(tail, idx, "--config")?));
}
"--cloudflared" => {
idx += 1;
request.cloudflared =
parse_cloudflared_mode(&required_value(tail, idx, "--cloudflared")?)?;
}
"--cloudflared-binary" => {
idx += 1;
request.cloudflared_binary = Some(PathBuf::from(required_value(
tail,
idx,
"--cloudflared-binary",
)?));
}
"--ngrok" => {
idx += 1;
request.ngrok = parse_ngrok_mode(&required_value(tail, idx, "--ngrok")?)?;
}
"--ngrok-binary" => {
idx += 1;
request.ngrok_binary =
Some(PathBuf::from(required_value(tail, idx, "--ngrok-binary")?));
}
"--runner-binary" => {
idx += 1;
request.runner_binary =
Some(PathBuf::from(required_value(tail, idx, "--runner-binary")?));
}
"--restart" => {
idx += 1;
let value = required_value(tail, idx, "--restart")?;
for part in value.split(',').filter(|part| !part.is_empty()) {
request.restart.push(parse_restart_target(part)?);
}
}
"--log-dir" => {
idx += 1;
request.log_dir = Some(PathBuf::from(required_value(tail, idx, "--log-dir")?));
}
"--admin" => request.admin = true,
"--admin-port" => {
idx += 1;
request.admin_port = required_value(tail, idx, "--admin-port")?
.parse()
.map_err(|_| "invalid --admin-port".to_string())?;
}
"--admin-certs-dir" => {
idx += 1;
request.admin_certs_dir = Some(PathBuf::from(required_value(
tail,
idx,
"--admin-certs-dir",
)?));
}
"--admin-allowed-clients" => {
idx += 1;
let value = required_value(tail, idx, "--admin-allowed-clients")?;
request.admin_allowed_clients.extend(
value
.split(',')
.filter(|part| !part.is_empty())
.map(|part| part.to_string()),
);
}
"--verbose" => request.verbose = true,
"--quiet" => request.quiet = true,
"--bundle" => {
return Err(
"--bundle is managed by gtc start; pass the bundle ref as the main argument"
.to_string(),
);
}
other => {
if let Some(value) = other.strip_prefix("--tenant=") {
request.tenant = Some(value.to_string());
} else if let Some(value) = other.strip_prefix("--team=") {
request.team = Some(value.to_string());
} else if let Some(value) = other.strip_prefix("--nats=") {
request.nats = parse_nats_mode(value)?;
} else if let Some(value) = other.strip_prefix("--nats-url=") {
request.nats_url = Some(value.to_string());
} else if let Some(value) = other.strip_prefix("--config=") {
request.config = Some(PathBuf::from(value));
} else if let Some(value) = other.strip_prefix("--cloudflared=") {
request.cloudflared = parse_cloudflared_mode(value)?;
} else if let Some(value) = other.strip_prefix("--cloudflared-binary=") {
request.cloudflared_binary = Some(PathBuf::from(value));
} else if let Some(value) = other.strip_prefix("--ngrok=") {
request.ngrok = parse_ngrok_mode(value)?;
} else if let Some(value) = other.strip_prefix("--ngrok-binary=") {
request.ngrok_binary = Some(PathBuf::from(value));
} else if let Some(value) = other.strip_prefix("--runner-binary=") {
request.runner_binary = Some(PathBuf::from(value));
} else if let Some(value) = other.strip_prefix("--restart=") {
for part in value.split(',').filter(|part| !part.is_empty()) {
request.restart.push(parse_restart_target(part)?);
}
} else if let Some(value) = other.strip_prefix("--log-dir=") {
request.log_dir = Some(PathBuf::from(value));
} else if other == "--admin" {
request.admin = true;
} else if let Some(value) = other.strip_prefix("--admin-port=") {
request.admin_port = value
.parse()
.map_err(|_| "invalid --admin-port value".to_string())?;
} else if let Some(value) = other.strip_prefix("--admin-certs-dir=") {
request.admin_certs_dir = Some(PathBuf::from(value));
} else if let Some(value) = other.strip_prefix("--admin-allowed-clients=") {
request.admin_allowed_clients.extend(
value
.split(',')
.filter(|part| !part.is_empty())
.map(|part| part.to_string()),
);
} else if other.starts_with("--bundle=") {
return Err(
"--bundle is managed by gtc start; pass the bundle ref as the main argument"
.to_string(),
);
} else {
return Err(format!("unsupported start argument: {other}"));
}
}
}
idx += 1;
}
Ok(request)
}
fn parse_stop_request(tail: &[String], bundle_dir: PathBuf) -> Result<StopRequest, String> {
let mut request = StopRequest {
bundle: Some(bundle_dir.display().to_string()),
state_dir: None,
tenant: "demo".to_string(),
team: "default".to_string(),
};
let mut idx = 0usize;
while idx < tail.len() {
let arg = &tail[idx];
match arg.as_str() {
"--tenant" => {
idx += 1;
request.tenant = required_value(tail, idx, "--tenant")?;
}
"--team" => {
idx += 1;
request.team = required_value(tail, idx, "--team")?;
}
"--state-dir" => {
idx += 1;
request.state_dir = Some(PathBuf::from(required_value(tail, idx, "--state-dir")?));
}
"--bundle" => {
return Err(
"--bundle is managed by gtc stop; pass the bundle ref as the main argument"
.to_string(),
);
}
other => {
if let Some(value) = other.strip_prefix("--tenant=") {
request.tenant = value.to_string();
} else if let Some(value) = other.strip_prefix("--team=") {
request.team = value.to_string();
} else if let Some(value) = other.strip_prefix("--state-dir=") {
request.state_dir = Some(PathBuf::from(value));
} else if other.starts_with("--bundle=") {
return Err(
"--bundle is managed by gtc stop; pass the bundle ref as the main argument"
.to_string(),
);
} else {
return Err(format!("unsupported stop argument: {other}"));
}
}
}
idx += 1;
}
Ok(request)
}
fn parse_start_cli_options(tail: &[String]) -> Result<StartCliOptions, String> {
let mut start_args = Vec::new();
let mut explicit_target = None;
let mut environment = None;
let mut provider_pack = None;
let mut app_pack = None;
let mut deploy_bundle_source = None;
let mut idx = 0usize;
while idx < tail.len() {
let arg = &tail[idx];
match arg.as_str() {
"--target" => {
idx += 1;
explicit_target =
Some(parse_start_target(&required_value(tail, idx, "--target")?)?);
}
"--environment" => {
idx += 1;
environment = Some(required_value(tail, idx, "--environment")?);
}
"--provider-pack" => {
idx += 1;
provider_pack = Some(PathBuf::from(required_value(tail, idx, "--provider-pack")?));
}
"--app-pack" => {
idx += 1;
app_pack = Some(PathBuf::from(required_value(tail, idx, "--app-pack")?));
}
"--deploy-bundle-source" => {
idx += 1;
deploy_bundle_source = Some(required_value(tail, idx, "--deploy-bundle-source")?);
}
_ => {
if let Some(value) = arg.strip_prefix("--target=") {
explicit_target = Some(parse_start_target(value)?);
} else if let Some(value) = arg.strip_prefix("--environment=") {
environment = Some(value.to_string());
} else if let Some(value) = arg.strip_prefix("--provider-pack=") {
provider_pack = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--app-pack=") {
app_pack = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--deploy-bundle-source=") {
deploy_bundle_source = Some(value.to_string());
} else {
start_args.push(arg.clone());
}
}
}
idx += 1;
}
Ok(StartCliOptions {
start_args,
explicit_target,
environment,
provider_pack,
app_pack,
deploy_bundle_source,
})
}
fn parse_stop_cli_options(tail: &[String]) -> Result<StopCliOptions, String> {
let mut stop_args = Vec::new();
let mut explicit_target = None;
let mut environment = None;
let mut provider_pack = None;
let mut app_pack = None;
let mut destroy = false;
let mut idx = 0usize;
while idx < tail.len() {
let arg = &tail[idx];
match arg.as_str() {
"--target" => {
idx += 1;
explicit_target =
Some(parse_start_target(&required_value(tail, idx, "--target")?)?);
}
"--environment" => {
idx += 1;
environment = Some(required_value(tail, idx, "--environment")?);
}
"--provider-pack" => {
idx += 1;
provider_pack = Some(PathBuf::from(required_value(tail, idx, "--provider-pack")?));
}
"--app-pack" => {
idx += 1;
app_pack = Some(PathBuf::from(required_value(tail, idx, "--app-pack")?));
}
"--destroy" => destroy = true,
_ => {
if let Some(value) = arg.strip_prefix("--target=") {
explicit_target = Some(parse_start_target(value)?);
} else if let Some(value) = arg.strip_prefix("--environment=") {
environment = Some(value.to_string());
} else if let Some(value) = arg.strip_prefix("--provider-pack=") {
provider_pack = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--app-pack=") {
app_pack = Some(PathBuf::from(value));
} else {
stop_args.push(arg.clone());
}
}
}
idx += 1;
}
Ok(StopCliOptions {
stop_args,
explicit_target,
environment,
provider_pack,
app_pack,
destroy,
})
}
fn parse_start_target(value: &str) -> Result<StartTarget, String> {
match value.trim() {
"runtime" | "local" => Ok(StartTarget::Runtime),
"single-vm" | "single_vm" => Ok(StartTarget::SingleVm),
"aws" => Ok(StartTarget::Aws),
"gcp" => Ok(StartTarget::Gcp),
"azure" => Ok(StartTarget::Azure),
other => Err(format!(
"unsupported --target value {other}; expected runtime, single-vm, aws, gcp, or azure"
)),
}
}
fn select_start_target(
bundle_dir: &Path,
explicit_target: Option<StartTarget>,
locale: &str,
) -> Result<StartTarget, String> {
if let Some(target) = explicit_target {
return Ok(target);
}
if let Some(default_target) = load_default_deployment_target(bundle_dir)? {
return Ok(default_target);
}
let mut deploy_targets = detect_bundle_deployment_targets(bundle_dir)?;
deploy_targets.sort_by_key(|value| match value {
StartTarget::Aws => 0,
StartTarget::Gcp => 1,
StartTarget::Azure => 2,
StartTarget::SingleVm => 3,
StartTarget::Runtime => 4,
});
deploy_targets.dedup();
if deploy_targets.is_empty() {
return Ok(StartTarget::Runtime);
}
if deploy_targets.len() == 1 {
return Ok(deploy_targets[0]);
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(format!(
"multiple start targets are available ({}); rerun with --target",
deploy_targets
.iter()
.map(|value| value.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
prompt_start_target(&deploy_targets, locale)
}
fn detect_bundle_deployment_targets(bundle_dir: &Path) -> Result<Vec<StartTarget>, String> {
if let Some(explicit_targets) = load_explicit_deployment_targets(bundle_dir)? {
return Ok(explicit_targets);
}
Ok(Vec::new())
}
fn load_explicit_deployment_targets(bundle_dir: &Path) -> Result<Option<Vec<StartTarget>>, String> {
let Some(doc) = load_deployment_targets_document(bundle_dir)? else {
return Ok(None);
};
let mut targets = Vec::new();
for record in doc.targets {
let target = parse_start_target(&record.target)?;
targets.push(target);
}
Ok(Some(targets))
}
fn load_default_deployment_target(bundle_dir: &Path) -> Result<Option<StartTarget>, String> {
let Some(doc) = load_deployment_targets_document(bundle_dir)? else {
return Ok(None);
};
for record in doc.targets {
if record.default.unwrap_or(false) {
return Ok(Some(parse_start_target(&record.target)?));
}
}
Ok(None)
}
fn load_deployment_targets_document(
bundle_dir: &Path,
) -> Result<Option<DeploymentTargetsDocument>, String> {
let path = bundle_dir.join(".greentic").join("deployment-targets.json");
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path)
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
let doc: DeploymentTargetsDocument = serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
Ok(Some(doc))
}
fn prompt_start_target(targets: &[StartTarget], locale: &str) -> Result<StartTarget, String> {
println!(
"{}",
t_or(
locale,
"gtc.start.prompt.target",
"Select start/deployment target:"
)
);
for (idx, target) in targets.iter().enumerate() {
println!("{} ) {}", idx + 1, target.as_str());
}
print!("> ");
io::stdout().flush().map_err(|err| err.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|err| err.to_string())?;
let choice = input
.trim()
.parse::<usize>()
.map_err(|_| "invalid target selection".to_string())?;
targets
.get(choice.saturating_sub(1))
.copied()
.ok_or_else(|| "invalid target selection".to_string())
}
fn resolve_target_provider_pack(
bundle_dir: &Path,
target: StartTarget,
override_path: Option<&PathBuf>,
) -> Result<PathBuf, String> {
if let Some(path) = override_path {
return Ok(path.clone());
}
if let Some(path) = resolve_target_provider_pack_from_metadata(bundle_dir, target)? {
return Ok(path);
}
if let Some(path) = resolve_canonical_target_provider_pack(target) {
return Ok(path);
}
Err(format!(
"no deployer provider pack found for target {}; define deployment_targets metadata or install greentic-deployer with dist packs",
target.as_str(),
))
}
fn resolve_canonical_target_provider_pack(target: StartTarget) -> Option<PathBuf> {
let filename = canonical_target_provider_pack_filename(target)?;
let deployer_bin = resolve_companion_binary(DEPLOYER_BIN)?;
resolve_canonical_target_provider_pack_from(Some(deployer_bin.as_path()), filename)
}
fn resolve_canonical_target_provider_pack_from(
deployer_bin: Option<&Path>,
filename: &str,
) -> Option<PathBuf> {
let deployer_bin = deployer_bin?;
let exe_dir = deployer_bin.parent()?;
let mut candidates = Vec::new();
candidates.push(exe_dir.join("dist").join(filename));
if let Some(repo_dir) = exe_dir.parent().and_then(Path::parent) {
candidates.push(repo_dir.join("dist").join(filename));
}
candidates.into_iter().find(|candidate| candidate.is_file())
}
fn canonical_target_provider_pack_filename(target: StartTarget) -> Option<&'static str> {
match target {
StartTarget::Aws | StartTarget::Gcp | StartTarget::Azure => Some("terraform.gtpack"),
StartTarget::Runtime | StartTarget::SingleVm => None,
}
}
fn resolve_target_provider_pack_from_metadata(
bundle_dir: &Path,
target: StartTarget,
) -> Result<Option<PathBuf>, String> {
let Some(doc) = load_deployment_targets_document(bundle_dir)? else {
return Ok(None);
};
for record in doc.targets {
let parsed_target = parse_start_target(&record.target)?;
if parsed_target != target {
continue;
}
let Some(provider_pack) = record.provider_pack else {
return Ok(None);
};
let candidate = bundle_dir.join(provider_pack);
if candidate.exists() {
return Ok(Some(candidate));
}
return Ok(None);
}
Ok(None)
}
fn resolve_deploy_app_pack_path(
bundle_dir: &Path,
override_path: Option<&PathBuf>,
) -> Result<PathBuf, String> {
if let Some(path) = override_path {
return Ok(path.clone());
}
if let Some(path) = resolve_app_pack_path_from_bundle_metadata(bundle_dir)? {
return Ok(path);
}
let default_pack_ref = bundle_dir.join("default.gtpack");
if default_pack_ref.exists() {
let raw = fs::read_to_string(&default_pack_ref).map_err(|err| {
format!(
"failed to read default pack reference {}: {err}",
default_pack_ref.display()
)
})?;
let pack_ref = raw.trim();
if !pack_ref.is_empty() {
let candidate = bundle_dir.join(pack_ref);
if candidate.exists() {
return Ok(candidate);
}
}
}
let packs_dir = bundle_dir.join("packs");
if !packs_dir.exists() {
return Err(format!(
"bundle has no packs directory: {}",
packs_dir.display()
));
}
let mut candidates = Vec::new();
for entry in fs::read_dir(&packs_dir)
.map_err(|err| format!("failed to read {}: {err}", packs_dir.display()))?
{
let entry = entry.map_err(|err| err.to_string())?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) == Some("gtpack") || path.is_dir() {
candidates.push(path);
}
}
candidates.sort();
match candidates.len() {
0 => Err(format!("no app pack found under {}", packs_dir.display())),
1 => Ok(candidates.remove(0)),
_ => Err(
"cloud deployment requires a canonical app pack; set bundle.yaml app_packs, add default.gtpack, or pass --app-pack explicitly"
.to_string(),
),
}
}
fn resolve_app_pack_path_from_bundle_metadata(
bundle_dir: &Path,
) -> Result<Option<PathBuf>, String> {
let bundle_path = bundle_dir.join("bundle.yaml");
if !bundle_path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&bundle_path)
.map_err(|err| format!("failed to read {}: {err}", bundle_path.display()))?;
let doc: YamlValue = serde_yaml::from_str(&raw)
.map_err(|err| format!("failed to parse {}: {err}", bundle_path.display()))?;
let Some(app_packs) = doc.get("app_packs").and_then(YamlValue::as_sequence) else {
return Ok(None);
};
let Some(reference) = app_packs.first().and_then(YamlValue::as_str) else {
return Ok(None);
};
let candidate = if reference.contains("://") {
if let Some(file_name) = reference
.split('?')
.next()
.and_then(|value| value.rsplit('/').next())
.filter(|value| !value.is_empty())
{
let bundled = bundle_dir.join("packs").join(file_name);
if bundled.exists() {
bundled
} else {
PathBuf::from(reference)
}
} else {
PathBuf::from(reference)
}
} else if Path::new(reference).is_absolute() {
if let Some(file_name) = Path::new(reference).file_name() {
let bundled = bundle_dir.join("packs").join(file_name);
if bundled.exists() {
bundled
} else {
PathBuf::from(reference)
}
} else {
PathBuf::from(reference)
}
} else {
bundle_dir.join(reference)
};
if candidate.exists() {
return Ok(Some(candidate));
}
Ok(None)
}
fn required_value(args: &[String], idx: usize, flag: &str) -> Result<String, String> {
args.get(idx)
.cloned()
.ok_or_else(|| format!("missing value for {flag}"))
}
fn parse_nats_mode(value: &str) -> Result<NatsModeArg, String> {
match value {
"off" => Ok(NatsModeArg::Off),
"on" => Ok(NatsModeArg::On),
"external" => Ok(NatsModeArg::External),
other => Err(format!("unsupported --nats value: {other}")),
}
}
fn parse_cloudflared_mode(value: &str) -> Result<CloudflaredModeArg, String> {
match value {
"on" => Ok(CloudflaredModeArg::On),
"off" => Ok(CloudflaredModeArg::Off),
other => Err(format!("unsupported --cloudflared value: {other}")),
}
}
fn parse_ngrok_mode(value: &str) -> Result<NgrokModeArg, String> {
match value {
"on" => Ok(NgrokModeArg::On),
"off" => Ok(NgrokModeArg::Off),
other => Err(format!("unsupported --ngrok value: {other}")),
}
}
fn parse_restart_target(value: &str) -> Result<RestartTarget, String> {
match value {
"all" => Ok(RestartTarget::All),
"cloudflared" => Ok(RestartTarget::Cloudflared),
"ngrok" => Ok(RestartTarget::Ngrok),
"nats" => Ok(RestartTarget::Nats),
"gateway" => Ok(RestartTarget::Gateway),
"egress" => Ok(RestartTarget::Egress),
"subscriptions" => Ok(RestartTarget::Subscriptions),
other => Err(format!("unsupported --restart target: {other}")),
}
}
fn resolve_bundle_reference(
reference: &str,
locale: &str,
) -> Result<StartBundleResolution, String> {
let trimmed = reference.trim();
if trimmed.is_empty() {
return Err("bundle reference is empty".to_string());
}
if let Some(path) = parse_local_bundle_ref(trimmed) {
return resolve_local_bundle_path(path);
}
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
let fetched = download_https_bundle_to_tempfile(trimmed, locale)?;
return resolve_archive_bundle_path(fetched, sanitize_identifier(trimmed));
}
let mapped = map_remote_bundle_ref(trimmed)?;
let fetched = dist::pull_oci_reference_to_tempfile(&mapped, None)
.map_err(|e| format!("failed to fetch remote bundle {trimmed}: {e}"))?;
resolve_archive_bundle_path(fetched, sanitize_identifier(&mapped))
}
fn resolve_local_mutable_bundle_dir(reference: &str) -> Result<PathBuf, String> {
let Some(path) = parse_local_bundle_ref(reference) else {
return Err("admin registry updates require a local bundle directory path".to_string());
};
if !path.exists() {
return Err(format!("bundle path does not exist: {}", path.display()));
}
if !path.is_dir() {
return Err(format!(
"admin registry updates require a local bundle directory, got: {}",
path.display()
));
}
Ok(path)
}
fn parse_local_bundle_ref(reference: &str) -> Option<PathBuf> {
if let Some(rest) = reference.strip_prefix("file://") {
if rest.trim().is_empty() {
return None;
}
return Some(PathBuf::from(rest));
}
if reference.contains("://") {
return None;
}
Some(PathBuf::from(reference))
}
fn resolve_local_bundle_path(path: PathBuf) -> Result<StartBundleResolution, String> {
if !path.exists() {
return Err(format!("bundle path does not exist: {}", path.display()));
}
let deployment_key = deployment_key_for_path(&path);
if path.is_dir() {
return Ok(StartBundleResolution {
bundle_dir: path,
deployment_key,
deploy_artifact: None,
_hold: None,
});
}
resolve_archive_bundle_path(path, deployment_key)
}
fn deployment_key_for_path(path: &Path) -> String {
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string();
sanitize_identifier(&canonical)
}
fn resolve_archive_bundle_path(
archive_path: PathBuf,
deployment_key: String,
) -> Result<StartBundleResolution, String> {
if !archive_path.is_file() {
return Err(format!(
"bundle artifact is not a file: {}",
archive_path.display()
));
}
let temp = tempfile::tempdir().map_err(|e| e.to_string())?;
let staging = temp.path().join("staging");
fs::create_dir_all(&staging).map_err(|e| e.to_string())?;
let file_name = archive_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("bundle.bin")
.to_string();
fs::copy(&archive_path, staging.join(file_name)).map_err(|e| e.to_string())?;
let extracted = temp.path().join("bundle");
fs::create_dir_all(&extracted).map_err(|e| e.to_string())?;
expand_into_target(&staging, &extracted)?;
let bundle_dir = detect_bundle_root(&extracted);
Ok(StartBundleResolution {
bundle_dir,
deployment_key,
deploy_artifact: Some(archive_path),
_hold: Some(temp),
})
}
fn detect_bundle_root(extracted_root: &Path) -> PathBuf {
if is_runtime_bundle_root(extracted_root) {
return extracted_root.to_path_buf();
}
let mut dirs = Vec::new();
if let Ok(entries) = fs::read_dir(extracted_root) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
}
}
}
if dirs.len() == 1 && is_runtime_bundle_root(&dirs[0]) {
return dirs.remove(0);
}
extracted_root.to_path_buf()
}
fn is_runtime_bundle_root(path: &Path) -> bool {
path.join("greentic.demo.yaml").exists()
|| path.join("greentic.operator.yaml").exists()
|| path.join("demo").join("demo.yaml").exists()
|| (path.join("bundle.yaml").exists()
&& (path.join("bundle-manifest.json").exists() || path.join("resolved").is_dir()))
}
fn map_remote_bundle_ref(reference: &str) -> Result<String, String> {
if let Some(rest) = reference.strip_prefix("oci://") {
return Ok(rest.to_string());
}
if let Some(rest) = reference.strip_prefix("repo://") {
return map_registry_target(rest, env::var("GREENTIC_REPO_REGISTRY_BASE").ok()).ok_or_else(
|| {
format!(
"repo:// reference {reference} requires GREENTIC_REPO_REGISTRY_BASE to map to OCI"
)
},
);
}
if let Some(rest) = reference.strip_prefix("store://") {
return map_registry_target(rest, env::var("GREENTIC_STORE_REGISTRY_BASE").ok()).ok_or_else(
|| {
format!(
"store:// reference {reference} requires GREENTIC_STORE_REGISTRY_BASE to map to OCI"
)
},
);
}
Err(format!(
"unsupported bundle scheme for {reference}; expected local path, file://, http(s)://, oci://, repo://, or store://"
))
}
fn download_https_bundle_to_tempfile(url: &str, locale: &str) -> Result<PathBuf, String> {
let bytes = fetch_https_bytes(url, "", locale, "application/octet-stream")?;
let temp = tempfile::tempdir().map_err(|e| e.to_string())?;
let file_name = url_file_name(url).unwrap_or_else(|| "bundle.gtbundle".to_string());
let path = temp.path().join(file_name);
fs::write(&path, bytes).map_err(|e| e.to_string())?;
if path.extension().and_then(|value| value.to_str()) != Some("gtbundle") {
return Err(format!(
"remote bundle URL must point to a .gtbundle archive: {url}"
));
}
let persisted = temp.keep();
Ok(persisted.join(
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("bundle.gtbundle"),
))
}
fn map_registry_target(target: &str, base: Option<String>) -> Option<String> {
if target.contains('/') && (target.contains('@') || target.contains(':')) {
return Some(target.to_string());
}
let base = base?;
let normalized_base = base.trim_end_matches('/');
let normalized_target = target.trim_start_matches('/');
Some(format!("{normalized_base}/{normalized_target}"))
}
fn run_install(sub_matches: &ArgMatches, debug: bool, locale: &str) -> i32 {
println!("{}", t(locale, "gtc.install.public_mode"));
let preflight_status = ensure_install_prereqs(debug, locale);
if preflight_status != 0 {
return preflight_status;
}
let public_args = vec!["install".to_string(), "tools".to_string()];
let public_status = passthrough(DEV_BIN, &public_args, debug, locale);
if public_status != 0 {
return public_status;
}
if let Err(err) = ensure_deployer_dist_pack(debug) {
eprintln!(
"{}: {err}",
tf(
locale,
"gtc.install.item_fail",
&[("kind", "asset"), ("name", "terraform.gtpack")]
)
);
return 1;
}
let tenant = sub_matches
.get_one::<String>("tenant")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let Some(tenant) = tenant else {
return 0;
};
println!(
"{}",
tf(
locale,
"gtc.install.tenant_mode",
&[("tenant", tenant.as_str())]
)
);
let cli_key = sub_matches.get_one::<String>("key").cloned();
let key = match resolve_tenant_key(cli_key, &tenant, locale) {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
let manifest_url = match resolve_tenant_manifest_url(&tenant, &key, locale) {
Ok(url) => url,
Err(err) => {
eprintln!("{}: {err}", t(locale, "gtc.err.pull_failed"));
return 1;
}
};
let manifest_bytes = match fetch_download_bytes_with_auth(&manifest_url, &key, locale) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!(
"{}: {err}",
tf(
locale,
"gtc.err.pull_failed",
&[("oci", manifest_url.as_str())]
)
);
return 1;
}
};
let manifest: TenantInstallManifest = match serde_json::from_slice(&manifest_bytes) {
Ok(manifest) => manifest,
Err(err) => {
eprintln!("{}: {err}", t(locale, "gtc.err.invalid_manifest"));
return 1;
}
};
if manifest.schema_version != "1" {
eprintln!(
"{}: unsupported schema_version '{}'",
t(locale, "gtc.err.invalid_manifest"),
manifest.schema_version
);
return 1;
}
if manifest.tenant != tenant {
eprintln!(
"{}: tenant '{}' does not match requested tenant '{}'",
t(locale, "gtc.err.invalid_manifest"),
manifest.tenant,
tenant
);
return 1;
}
let cargo_bin_dir = match resolve_cargo_bin_dir() {
Ok(path) => path,
Err(err) => {
eprintln!("{}: {err}", t(locale, "gtc.err.install_dir"));
return 1;
}
};
let artifacts_root = match resolve_artifacts_root() {
Ok(path) => path,
Err(err) => {
eprintln!("{}: {err}", t(locale, "gtc.err.install_dir"));
return 1;
}
};
let mut any_failed = false;
let current_os = match current_install_os() {
Ok(value) => value,
Err(code) => return code,
};
let current_arch = match current_install_arch() {
Ok(value) => value,
Err(code) => return code,
};
for tool in manifest.tools {
let result = install_tenant_tool_reference(
&tool,
&tenant,
&key,
¤t_os,
¤t_arch,
&cargo_bin_dir,
locale,
);
match result {
Ok(()) => {
println!(
"{}",
tf(
locale,
"gtc.install.item_ok",
&[("kind", "tool"), ("name", tool.id.as_str())]
)
);
}
Err(err) => {
any_failed = true;
eprintln!(
"{}: {err}",
tf(
locale,
"gtc.install.item_fail",
&[("kind", "tool"), ("name", tool.id.as_str())]
)
);
}
}
}
for doc in manifest.docs {
let result = install_tenant_doc_reference(&doc, &tenant, &key, &artifacts_root, locale);
match result {
Ok(paths) => {
println!(
"{}",
tf(
locale,
"gtc.install.item_ok",
&[("kind", "doc"), ("name", doc.id.as_str())]
)
);
for path in paths {
println!(" -> {}", path.display());
}
}
Err(err) => {
any_failed = true;
eprintln!(
"{}: {err}",
tf(
locale,
"gtc.install.item_fail",
&[("kind", "doc"), ("name", doc.id.as_str())]
)
);
}
}
}
for asset in manifest.store_assets {
let result = install_store_asset_reference(&asset, &tenant, &key, &artifacts_root, locale);
match result {
Ok(paths) => {
println!(
"{}",
tf(
locale,
"gtc.install.item_ok",
&[("kind", "store asset"), ("name", asset.id.as_str())]
)
);
for path in paths {
println!(" -> {}", path.display());
}
}
Err(err) => {
any_failed = true;
eprintln!(
"{}: {err}",
tf(
locale,
"gtc.install.item_fail",
&[("kind", "store asset"), ("name", asset.id.as_str())]
)
);
}
}
}
if any_failed {
eprintln!("{}", t(locale, "gtc.install.summary_failed"));
1
} else {
println!("{}", t(locale, "gtc.install.summary_ok"));
0
}
}
fn ensure_install_prereqs(debug: bool, locale: &str) -> i32 {
let installed_binstall = detect_binstall_version(debug, locale);
let latest_binstall = latest_binstall_version(debug, locale);
let needs_binstall_install = match (installed_binstall.as_deref(), latest_binstall.as_deref()) {
(Some(installed), Some(latest)) => semver_compare(installed, latest).is_lt(),
(None, _) => true,
(Some(_), None) => true,
};
if needs_binstall_install {
let install_binstall_args = vec![
"install".to_string(),
"cargo-binstall".to_string(),
"--locked".to_string(),
];
let status = run_cargo(&install_binstall_args, debug, locale);
if status != 0 {
return status;
}
}
for package in [DEV_BIN, OP_BIN, BUNDLE_BIN, SETUP_BIN, DEPLOYER_BIN] {
let binstall_args = vec![
"binstall".to_string(),
"-y".to_string(),
"--version".to_string(),
"0.4".to_string(),
package.to_string(),
];
let status = run_cargo(&binstall_args, debug, locale);
if status != 0 {
return status;
}
}
0
}
fn ensure_deployer_dist_pack(debug: bool) -> Result<(), String> {
let cargo_bin_dir = resolve_cargo_bin_dir()?;
let dist_dir = cargo_bin_dir.join("dist");
let target = dist_dir.join("terraform.gtpack");
fs::create_dir_all(&dist_dir).map_err(|e| e.to_string())?;
let needs_write = match fs::read(&target) {
Ok(existing) => existing != EMBEDDED_TERRAFORM_GTPACK,
Err(_) => true,
};
if !needs_write {
return Ok(());
}
fs::write(&target, EMBEDDED_TERRAFORM_GTPACK).map_err(|e| e.to_string())?;
if debug {
eprintln!("updated deployer pack at {}", target.display());
}
Ok(())
}
fn detect_binstall_version(debug: bool, locale: &str) -> Option<String> {
let output = run_cargo_capture(&["binstall", "--version"], debug, locale)?;
if !output.status.success() {
return None;
}
parse_first_semver(&String::from_utf8_lossy(&output.stdout))
.or_else(|| parse_first_semver(&String::from_utf8_lossy(&output.stderr)))
}
fn latest_binstall_version(debug: bool, locale: &str) -> Option<String> {
let output = run_cargo_capture(&["search", "cargo-binstall", "--limit", "1"], debug, locale)?;
if !output.status.success() {
return None;
}
parse_first_semver(&String::from_utf8_lossy(&output.stdout))
}
fn parse_first_semver(text: &str) -> Option<String> {
for token in text.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-')) {
if token.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
&& token.split('.').count() >= 2
&& token
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
{
return Some(token.to_string());
}
}
None
}
fn semver_compare(a: &str, b: &str) -> std::cmp::Ordering {
let pa = parse_numeric_version(a);
let pb = parse_numeric_version(b);
let max = pa.len().max(pb.len());
for i in 0..max {
let av = *pa.get(i).unwrap_or(&0);
let bv = *pb.get(i).unwrap_or(&0);
match av.cmp(&bv) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
}
std::cmp::Ordering::Equal
}
fn parse_numeric_version(raw: &str) -> Vec<u64> {
raw.split('.')
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
fn run_cargo_capture(args: &[&str], debug: bool, locale: &str) -> Option<std::process::Output> {
if debug {
eprintln!("{} cargo {:?}", t(locale, "gtc.debug.exec"), args);
}
ProcessCommand::new("cargo")
.args(args)
.env("GREENTIC_LOCALE", locale)
.output()
.ok()
}
fn run_cargo(args: &[String], debug: bool, locale: &str) -> i32 {
if debug {
eprintln!("{} cargo {:?}", t(locale, "gtc.debug.exec"), args);
}
match ProcessCommand::new("cargo")
.args(args)
.env("GREENTIC_LOCALE", locale)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(status) => status.code().unwrap_or(1),
Err(err) => {
eprintln!("{}: {err}", t(locale, "gtc.err.exec_failed"));
1
}
}
}
fn install_tenant_tool_reference(
tool_ref: &TenantManifestReference,
_tenant: &str,
key: &str,
current_os: &str,
current_arch: &str,
cargo_bin_dir: &Path,
locale: &str,
) -> Result<(), String> {
let tool: ToolManifest = fetch_json_with_auth(&tool_ref.url, key, locale)?;
let target = tool
.install
.targets
.iter()
.find(|target| target.os == current_os && target.arch == current_arch)
.ok_or_else(|| {
format!(
"no install target for tool '{}' on {current_os}/{current_arch}",
tool.id
)
})?;
let temp = tempfile::tempdir().map_err(|e| e.to_string())?;
let staged = temp.path().join("staged");
fs::create_dir_all(&staged).map_err(|e| e.to_string())?;
download_url_into_dir(
&target.url,
key,
&staged,
Some(&tool.install.binary_name),
locale,
)?;
install_tool_artifact(&staged, cargo_bin_dir, &tool.install.binary_name)
}
fn install_tenant_doc_reference(
doc_ref: &TenantManifestReference,
_tenant: &str,
key: &str,
artifacts_root: &Path,
locale: &str,
) -> Result<Vec<PathBuf>, String> {
let manifest: DocManifest = fetch_json_with_auth(&doc_ref.url, key, locale)?;
let mut installed = Vec::new();
for doc in manifest.entries()? {
if doc.download_file_name.contains('/')
|| doc.download_file_name.contains('\\')
|| doc.download_file_name.is_empty()
{
return Err(format!(
"invalid doc file name '{}'",
doc.download_file_name
));
}
let docs_root = artifacts_root.join("docs");
fs::create_dir_all(&docs_root).map_err(|e| e.to_string())?;
let target = safe_join(&docs_root, Path::new(&doc.default_relative_path))?
.join(&doc.download_file_name);
download_url_to_path(&doc.source.url, key, &target, locale)?;
installed.push(target);
}
Ok(installed)
}
fn install_store_asset_reference(
asset_ref: &TenantManifestReference,
tenant: &str,
key: &str,
artifacts_root: &Path,
locale: &str,
) -> Result<Vec<PathBuf>, String> {
let manifest: StoreAssetManifest = fetch_json_with_auth(&asset_ref.url, key, locale)?;
let mut installed = Vec::new();
for item in manifest.items {
let resolved = rewrite_store_tenant_placeholder(&item, tenant);
installed.push(install_store_asset_item(
&resolved,
tenant,
key,
artifacts_root,
locale,
)?);
}
Ok(installed)
}
fn install_store_asset_item(
store_url: &str,
tenant: &str,
key: &str,
artifacts_root: &Path,
locale: &str,
) -> Result<PathBuf, String> {
save_store_login(tenant, key)?;
let client = DistClient::new(DistOptions::default());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to build tokio runtime: {e}"))?;
let artifact = rt
.block_on(client.download_store_artifact(store_url))
.map_err(|e| format!("{}: {e}", t(locale, "gtc.err.pull_failed")))?;
let file_name = store_asset_file_name(store_url)
.ok_or_else(|| format!("unable to derive filename from {store_url}"))?;
let target = store_asset_target_path(artifacts_root, &file_name)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&target, artifact.bytes)
.map_err(|e| format!("failed to write {}: {e}", target.display()))?;
Ok(target)
}
fn download_url_into_dir(
url: &str,
key: &str,
target_dir: &Path,
fallback_name: Option<&str>,
locale: &str,
) -> Result<PathBuf, String> {
let file_name = url_file_name(url)
.filter(|value| !value.is_empty())
.or_else(|| fallback_name.map(|value| value.to_string()))
.ok_or_else(|| format!("unable to derive file name from {url}"))?;
let target = target_dir.join(file_name);
download_url_to_path(url, key, &target, locale)?;
Ok(target)
}
fn download_url_to_path(url: &str, key: &str, target: &Path, locale: &str) -> Result<(), String> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let bytes = fetch_download_bytes_with_auth(url, key, locale)?;
let mut file = fs::File::create(target).map_err(|e| e.to_string())?;
file.write_all(&bytes).map_err(|e| e.to_string())?;
Ok(())
}
fn fetch_json_with_auth<T: serde::de::DeserializeOwned>(
url: &str,
key: &str,
locale: &str,
) -> Result<T, String> {
let bytes = fetch_json_bytes_with_auth(url, key, locale)?;
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
fn fetch_json_bytes_with_auth(url: &str, key: &str, locale: &str) -> Result<Vec<u8>, String> {
if let Some(path) = file_url_path(url) {
return fs::read(&path).map_err(|e| format!("failed to read {}: {e}", path.display()));
}
match url.split_once("://").map(|(scheme, _)| scheme) {
Some("http") | Some("https") => {
if let Some(asset_url) = resolve_github_release_asset_api_url(url, key, locale)? {
fetch_asset_bytes(&asset_url, key, locale)
} else {
fetch_https_json_or_file_bytes(url, key, locale)
}
}
_ => Err(format!("unsupported download URL scheme for {url}")),
}
}
fn fetch_download_bytes_with_auth(url: &str, key: &str, locale: &str) -> Result<Vec<u8>, String> {
if let Some(path) = file_url_path(url) {
return fs::read(&path).map_err(|e| format!("failed to read {}: {e}", path.display()));
}
match url.split_once("://").map(|(scheme, _)| scheme) {
Some("http") | Some("https") => {
if let Some(asset_url) = resolve_github_release_asset_api_url(url, key, locale)? {
fetch_asset_bytes(&asset_url, key, locale)
} else {
fetch_https_bytes(url, key, locale, "application/octet-stream")
}
}
_ => Err(format!("unsupported download URL scheme for {url}")),
}
}
fn fetch_https_json_or_file_bytes(url: &str, key: &str, locale: &str) -> Result<Vec<u8>, String> {
fetch_https_bytes(url, key, locale, "application/vnd.github+json")
}
fn fetch_asset_bytes(url: &str, key: &str, locale: &str) -> Result<Vec<u8>, String> {
fetch_https_bytes(url, key, locale, "application/octet-stream")
}
fn fetch_https_bytes(url: &str, key: &str, locale: &str, accept: &str) -> Result<Vec<u8>, String> {
let client = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("failed to create HTTP client: {e}"))?;
let mut current = reqwest::Url::parse(url).map_err(|e| format!("invalid URL {url}: {e}"))?;
for _ in 0..10 {
let response = client
.get(current.clone())
.header("Accept", accept)
.header("Authorization", format!("Bearer {key}"))
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", format!("gtc/{}", env!("CARGO_PKG_VERSION")))
.send()
.map_err(|e| format!("{}: {e}", t(locale, "gtc.err.pull_failed")))?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.ok_or_else(|| format!("redirect missing Location header for {}", current))?
.to_str()
.map_err(|e| format!("invalid redirect Location for {}: {e}", current))?;
current = current
.join(location)
.map_err(|e| format!("invalid redirect target {location}: {e}"))?;
continue;
}
if !response.status().is_success() {
return Err(format!(
"{}: HTTP {} for {}",
t(locale, "gtc.err.pull_failed"),
response.status(),
current
));
}
return response
.bytes()
.map(|bytes| bytes.to_vec())
.map_err(|e| e.to_string());
}
Err(format!("too many redirects while fetching {url}"))
}
fn save_store_login(tenant: &str, token: &str) -> Result<(), String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to build tokio runtime: {e}"))?;
rt.block_on(save_login_default(tenant, token))
.map_err(|e| e.to_string())
}
fn rewrite_store_tenant_placeholder(url: &str, tenant: &str) -> String {
url.replace("/{tenant}/", &format!("/{tenant}/"))
}
fn resolve_tenant_manifest_url(tenant: &str, key: &str, locale: &str) -> Result<String, String> {
if let Ok(template) = env::var("GTC_TENANT_MANIFEST_URL_TEMPLATE") {
return Ok(template.replace("{tenant}", tenant));
}
let release = fetch_github_release("greentic-biz", "customers-tools", "latest", key, locale)?;
let asset_name = format!("{tenant}.json");
release
.assets
.into_iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.url)
.ok_or_else(|| format!("tenant manifest asset '{asset_name}' not found in latest release"))
}
fn resolve_github_release_asset_api_url(
url: &str,
key: &str,
locale: &str,
) -> Result<Option<String>, String> {
let parsed = match reqwest::Url::parse(url) {
Ok(parsed) => parsed,
Err(_) => return Ok(None),
};
if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") {
return Ok(None);
}
let segments = match parsed.path_segments() {
Some(segments) => segments.collect::<Vec<_>>(),
None => return Ok(None),
};
if segments.len() != 6 || segments[2] != "releases" {
return Ok(None);
}
let owner = segments[0];
let repo = segments[1];
let asset_name = segments[5];
let tag = match (segments[3], segments[4]) {
("latest", "download") => "latest",
("download", tag) => tag,
_ => return Ok(None),
};
let release = fetch_github_release(owner, repo, tag, key, locale)?;
Ok(release
.assets
.into_iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.url))
}
fn fetch_github_release(
owner: &str,
repo: &str,
tag: &str,
key: &str,
locale: &str,
) -> Result<GithubRelease, String> {
let url = if tag == "latest" {
format!("https://api.github.com/repos/{owner}/{repo}/releases/latest")
} else {
format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}")
};
let bytes = fetch_https_json_or_file_bytes(&url, key, locale)?;
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
fn store_asset_file_name(store_url: &str) -> Option<String> {
let trimmed = store_url.trim_start_matches("store://");
let last = trimmed.rsplit('/').next()?;
Some(last.split(':').next().unwrap_or(last).to_string())
}
fn store_asset_target_path(artifacts_root: &Path, file_name: &str) -> Result<PathBuf, String> {
let rel = if file_name.ends_with(".gtpack") {
PathBuf::from("packs").join(file_name)
} else if file_name.ends_with(".gtbundle") {
PathBuf::from("bundles").join(file_name)
} else if file_name.ends_with(".wasm") {
PathBuf::from("components").join(file_name)
} else {
PathBuf::from("store_assets").join(file_name)
};
safe_join(artifacts_root, &rel)
}
fn url_file_name(url: &str) -> Option<String> {
let trimmed = url.trim_end_matches('/');
trimmed
.rsplit('/')
.next()
.map(|segment| segment.split('?').next().unwrap_or(segment))
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
}
fn current_install_os() -> Result<String, i32> {
match env::consts::OS {
"linux" | "macos" | "windows" => Ok(env::consts::OS.to_string()),
other => {
eprintln!("unsupported install OS '{other}'");
Err(1)
}
}
}
fn current_install_arch() -> Result<String, i32> {
if let Some(runtime) = detect_runtime_install_arch()
&& let Some(normalized) = normalize_install_arch(&runtime)
{
return Ok(normalized.to_string());
}
if let Some(normalized) = normalize_install_arch(env::consts::ARCH) {
return Ok(normalized.to_string());
}
eprintln!(
"unsupported install architecture '{}' (runtime) / '{}' (build)",
detect_runtime_install_arch().unwrap_or_else(|| "unknown".to_string()),
env::consts::ARCH
);
Err(1)
}
fn detect_runtime_install_arch() -> Option<String> {
if cfg!(target_os = "macos")
&& let Some(value) = query_command_trimmed("sysctl", &["-n", "hw.optional.arm64"])
{
match value.as_str() {
"1" => return Some("arm64".to_string()),
"0" => return Some("x86_64".to_string()),
_ => {}
}
}
if cfg!(windows) {
return env::var("PROCESSOR_ARCHITEW6432")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| env::var("PROCESSOR_ARCHITECTURE").ok())
.map(|v| v.trim().to_string());
}
query_command_trimmed("uname", &["-m"])
}
fn query_command_trimmed(command: &str, args: &[&str]) -> Option<String> {
ProcessCommand::new(command)
.args(args)
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn file_url_path(url: &str) -> Option<PathBuf> {
let path = url.strip_prefix("file://")?;
if path.is_empty() {
return None;
}
Some(PathBuf::from(path))
}
fn normalize_install_arch(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"x86_64" | "amd64" => Some("x86_64"),
"aarch64" | "arm64" => Some("aarch64"),
_ => None,
}
}
fn install_tool_artifact(
staged: &Path,
cargo_bin_dir: &Path,
fallback_name: &str,
) -> Result<(), String> {
fs::create_dir_all(cargo_bin_dir).map_err(|e| e.to_string())?;
let expanded = tempfile::tempdir().map_err(|e| e.to_string())?;
expand_into_target(staged, expanded.path())?;
let mut candidates = gather_tool_candidates(expanded.path())?;
if candidates.is_empty() {
let fallback =
find_first_file(expanded.path())?.ok_or_else(|| "no tool binary found".to_string())?;
candidates.push((fallback_name.to_string(), fallback));
}
for (name_hint, source) in candidates {
let file_name = source
.file_name()
.and_then(|v| v.to_str())
.map(|v| v.to_string())
.filter(|v| !v.is_empty())
.unwrap_or(name_hint);
let target = cargo_bin_dir.join(file_name);
fs::copy(&source, &target).map_err(|e| e.to_string())?;
set_executable_if_unix(&target)?;
}
Ok(())
}
fn gather_tool_candidates(root: &Path) -> Result<Vec<(String, PathBuf)>, String> {
let files = list_files_recursive(root)?;
let mut out = Vec::new();
for file in files {
let rel = file.strip_prefix(root).unwrap_or(&file);
let in_bin_dir = rel.components().any(|c| c.as_os_str() == "bin");
let file_name = match file.file_name().and_then(|v| v.to_str()) {
Some(v) => v,
None => continue,
};
let looks_tool_name = file_name == "gtc"
|| file_name.starts_with("greentic-")
|| file_name.ends_with(".exe")
|| file_name.ends_with(".cmd")
|| file_name.ends_with(".bat");
if in_bin_dir || looks_tool_name {
out.push((file_name.to_string(), file));
}
}
Ok(out)
}
fn find_first_file(root: &Path) -> Result<Option<PathBuf>, String> {
let mut files = list_files_recursive(root)?;
files.sort();
Ok(files.into_iter().next())
}
fn list_files_recursive(root: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
recurse_files(root, &mut out)?;
Ok(out)
}
fn recurse_files(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in fs::read_dir(root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
recurse_files(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
fn expand_into_target(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
fs::create_dir_all(target_dir).map_err(|e| e.to_string())?;
let files = list_files_recursive(source_dir)?;
for file in files {
let data = fs::read(&file).map_err(|e| e.to_string())?;
if looks_like_squashfs(&data) {
extract_squashfs_file(&file, target_dir)?;
continue;
}
if looks_like_zip(&data) {
extract_zip_bytes(&data, target_dir)?;
continue;
}
if looks_like_gzip(&data) && extract_targz_bytes(&data, target_dir).is_ok() {
continue;
}
if extract_tar_bytes(&data, target_dir).is_ok() {
continue;
}
let name = file
.file_name()
.ok_or_else(|| "invalid filename".to_string())?;
fs::copy(&file, target_dir.join(name)).map_err(|e| e.to_string())?;
}
Ok(())
}
fn looks_like_zip(data: &[u8]) -> bool {
data.len() >= 4 && &data[0..4] == b"PK\x03\x04"
}
fn looks_like_gzip(data: &[u8]) -> bool {
data.len() >= 2 && data[0] == 0x1F && data[1] == 0x8B
}
fn looks_like_squashfs(data: &[u8]) -> bool {
data.len() >= 4 && &data[0..4] == b"hsqs"
}
fn extract_squashfs_file(path: &Path, out_dir: &Path) -> Result<(), String> {
let output = ProcessCommand::new("unsquashfs")
.arg("-no-progress")
.arg("-dest")
.arg(out_dir)
.arg(path)
.output()
.map_err(|e| format!("failed to run unsquashfs for {}: {e}", path.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(format!(
"failed to extract squashfs bundle {}: {}{}{}",
path.display(),
stdout.trim(),
if !stdout.trim().is_empty() && !stderr.trim().is_empty() {
" "
} else {
""
},
stderr.trim()
))
}
fn extract_zip_bytes(data: &[u8], out_dir: &Path) -> Result<(), String> {
let cursor = std::io::Cursor::new(data);
let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let Some(path) = entry.enclosed_name().map(|p| p.to_path_buf()) else {
continue;
};
let target = safe_join(out_dir, &path)?;
if entry.is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut out = fs::File::create(&target).map_err(|e| e.to_string())?;
std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?;
set_executable_if_unix(&target)?;
}
Ok(())
}
fn extract_targz_bytes(data: &[u8], out_dir: &Path) -> Result<(), String> {
let cursor = std::io::Cursor::new(data);
let decoder = flate2::read::GzDecoder::new(cursor);
let mut archive = tar::Archive::new(decoder);
extract_tar_archive(&mut archive, out_dir)
}
fn extract_tar_bytes(data: &[u8], out_dir: &Path) -> Result<(), String> {
let cursor = std::io::Cursor::new(data);
let mut archive = tar::Archive::new(cursor);
extract_tar_archive(&mut archive, out_dir)
}
fn extract_tar_archive<R: Read>(
archive: &mut tar::Archive<R>,
out_dir: &Path,
) -> Result<(), String> {
for entry in archive.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
let path = entry.path().map_err(|e| e.to_string())?.to_path_buf();
let target = safe_join(out_dir, &path)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
entry.unpack(&target).map_err(|e| e.to_string())?;
if target.is_file() {
set_executable_if_unix(&target)?;
}
}
Ok(())
}
fn safe_join(base: &Path, rel: &Path) -> Result<PathBuf, String> {
let mut clean = PathBuf::new();
for comp in rel.components() {
match comp {
Component::Normal(v) => clean.push(v),
Component::CurDir => {}
_ => return Err("archive entry has unsafe path".to_string()),
}
}
Ok(base.join(clean))
}
#[cfg(unix)]
fn set_executable_if_unix(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path).map_err(|e| e.to_string())?;
let mut perms = metadata.permissions();
let mode = perms.mode();
perms.set_mode(mode | 0o755);
fs::set_permissions(path, perms).map_err(|e| e.to_string())
}
#[cfg(not(unix))]
fn set_executable_if_unix(_path: &Path) -> Result<(), String> {
Ok(())
}
fn resolve_cargo_bin_dir() -> Result<PathBuf, String> {
if let Ok(cargo_home) = env::var("CARGO_HOME")
&& !cargo_home.trim().is_empty()
{
return Ok(PathBuf::from(cargo_home).join("bin"));
}
let base = BaseDirs::new().ok_or_else(|| "failed to resolve home directory".to_string())?;
Ok(base.home_dir().join(".cargo").join("bin"))
}
fn resolve_artifacts_root() -> Result<PathBuf, String> {
let base = BaseDirs::new().ok_or_else(|| "failed to resolve home directory".to_string())?;
Ok(base.home_dir().join(".greentic").join("artifacts"))
}
fn resolve_tenant_key(
cli_key: Option<String>,
tenant: &str,
locale: &str,
) -> Result<String, String> {
if let Some(key) = cli_key
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
return Ok(key);
}
let env_name = tenant_env_var_name(tenant);
if let Ok(key) = env::var(&env_name)
&& !key.trim().is_empty()
{
println!(
"{}",
tf(
locale,
"gtc.install.using_env_key",
&[("env", env_name.as_str())]
)
);
return Ok(key.trim().to_string());
}
let prompt = tf(locale, "gtc.install.prompt_key", &[("tenant", tenant)]);
loop {
let key = rpassword::prompt_password(&prompt).map_err(|e| e.to_string())?;
if !key.trim().is_empty() {
return Ok(key);
}
eprintln!("{}", t(locale, "gtc.err.key_required"));
}
}
fn tenant_env_var_name(tenant: &str) -> String {
let mut normalized = String::with_capacity(tenant.len());
let mut prev_us = false;
for ch in tenant.chars() {
let upper = ch.to_ascii_uppercase();
if upper.is_ascii_alphanumeric() {
normalized.push(upper);
prev_us = false;
} else if !prev_us {
normalized.push('_');
prev_us = true;
}
}
let trimmed = normalized.trim_matches('_').to_string();
format!("GREENTIC_{}_KEY", trimmed)
}
fn passthrough(binary: &str, args: &[String], debug: bool, locale: &str) -> i32 {
if debug {
eprintln!("{} {} {:?}", t(locale, "gtc.debug.exec"), binary, args);
}
let command = resolve_binary_command(binary);
match ProcessCommand::new(&command)
.args(args)
.env("GREENTIC_LOCALE", locale)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(status) => status.code().unwrap_or(1),
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
match binary {
DEV_BIN => print_missing_dev_message(locale),
OP_BIN => print_missing_op_message(locale),
SETUP_BIN => eprintln!("{}", t(locale, "gtc.err.bin_missing_setup")),
_ => eprintln!("{}", t(locale, "gtc.err.exec_failed")),
}
} else {
eprintln!("{}: {err}", t(locale, "gtc.err.exec_failed"));
}
1
}
}
}
fn run_doctor(locale: &str) -> i32 {
let mut failed = false;
for binary in [DEV_BIN, OP_BIN, BUNDLE_BIN, SETUP_BIN, DEPLOYER_BIN] {
let command = resolve_binary_command(binary);
match ProcessCommand::new(&command).arg("--version").output() {
Ok(output) => {
let status_label = if output.status.success() {
t(locale, "gtc.doctor.ok")
} else {
t(locale, "gtc.doctor.warn")
};
let version = first_non_empty_line(&String::from_utf8_lossy(&output.stdout))
.or_else(|| first_non_empty_line(&String::from_utf8_lossy(&output.stderr)))
.unwrap_or_else(|| t(locale, "gtc.doctor.version_unavailable").into_owned());
println!("{binary}: {status_label} ({version}) [{}]", command);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
failed = true;
println!(
"{binary}: {} [{}]",
t(locale, "gtc.doctor.missing"),
command
);
match binary {
DEV_BIN => print_missing_dev_message(locale),
OP_BIN => print_missing_op_message(locale),
BUNDLE_BIN => eprintln!(
"{}",
t_or(
locale,
"gtc.err.bin_missing_bundle",
"greentic-bundle not found in PATH. Install with: cargo install greentic-bundle",
)
),
SETUP_BIN => eprintln!("{}", t(locale, "gtc.err.bin_missing_setup")),
DEPLOYER_BIN => eprintln!(
"{}",
t_or(
locale,
"gtc.err.bin_missing_deployer",
"greentic-deployer not found in PATH. Install with: cargo install greentic-deployer",
)
),
_ => {}
}
}
Err(err) => {
failed = true;
println!(
"{binary}: {} ({err}) [{}]",
t(locale, "gtc.doctor.missing"),
command
);
}
}
}
if failed { 1 } else { 0 }
}
fn first_non_empty_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
fn print_missing_dev_message(locale: &str) {
eprintln!("{}", t(locale, "gtc.err.bin_missing_dev"));
eprintln!(
"{}",
t_or(
locale,
"gtc.err.bin_missing_dev_install_hint",
"Run `gtc install` first."
)
);
}
fn print_missing_op_message(locale: &str) {
eprintln!("{}", t(locale, "gtc.err.bin_missing_op"));
eprintln!(
"{}",
t_or(
locale,
"gtc.err.bin_missing_op_install_hint",
"Run `gtc install` first."
)
);
}
fn resolve_binary_command(binary: &str) -> String {
if let Some(path) = resolve_companion_binary(binary) {
return path.display().to_string();
}
match binary {
DEV_BIN => env::var("GREENTIC_DEV_BIN").unwrap_or_else(|_| binary.to_string()),
_ => binary.to_string(),
}
}
fn resolve_companion_binary(binary: &str) -> Option<PathBuf> {
resolve_companion_binary_from(env::current_exe().ok().as_deref(), binary)
}
fn resolve_companion_binary_from(current_exe: Option<&Path>, binary: &str) -> Option<PathBuf> {
let env_override = companion_binary_env_override(binary);
if let Some(path) = env_override {
return Some(PathBuf::from(path));
}
if let Some(current_exe) = current_exe {
let exe_dir = current_exe.parent()?;
if let Some(sibling) = resolve_binary_in_dir(exe_dir, binary) {
return Some(sibling);
}
if let Some(workspace_candidate) = resolve_workspace_local_binary(current_exe, binary) {
return Some(workspace_candidate);
}
}
if let Ok(cargo_bin_dir) = resolve_cargo_bin_dir()
&& let Some(cargo_candidate) = resolve_binary_in_dir(&cargo_bin_dir, binary)
{
return Some(cargo_candidate);
}
None
}
fn resolve_binary_in_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
binary_file_candidates(dir, binary)
.into_iter()
.find(|candidate| candidate.is_file())
}
fn binary_file_candidates(dir: &Path, binary: &str) -> Vec<PathBuf> {
let mut candidates = vec![dir.join(binary)];
let exe_suffix = env::consts::EXE_SUFFIX;
if !exe_suffix.is_empty() && !binary.ends_with(exe_suffix) {
candidates.push(dir.join(format!("{binary}{exe_suffix}")));
}
candidates
}
fn resolve_workspace_local_binary(current_exe: &Path, binary: &str) -> Option<PathBuf> {
let repo_dir = current_exe.parent()?.parent()?.parent()?;
let workspace_root = repo_dir.parent()?;
let repo_name = match binary {
DEV_BIN => "greentic-dev",
OP_BIN => "greentic-operator",
BUNDLE_BIN => "greentic-bundle",
DEPLOYER_BIN => "greentic-deployer",
SETUP_BIN => "greentic-setup",
_ => return None,
};
let candidate = workspace_root
.join(repo_name)
.join("target")
.join("debug")
.as_path()
.to_path_buf();
resolve_binary_in_dir(&candidate, binary)
}
fn companion_binary_env_override(binary: &str) -> Option<std::ffi::OsString> {
match binary {
DEV_BIN => env::var_os("GREENTIC_DEV_BIN"),
OP_BIN => env::var_os("GREENTIC_OPERATOR_BIN"),
BUNDLE_BIN => env::var_os("GREENTIC_BUNDLE_BIN"),
DEPLOYER_BIN => env::var_os("GREENTIC_DEPLOYER_BIN"),
SETUP_BIN => env::var_os("GREENTIC_SETUP_BIN"),
_ => None,
}
}
fn t(locale: &str, key: &'static str) -> Cow<'static, str> {
Cow::Owned(i18n().translate(locale, key))
}
fn t_or(locale: &str, key: &'static str, fallback: &'static str) -> String {
let value = t(locale, key).into_owned();
if value == key {
fallback.to_string()
} else {
value
}
}
fn tf(locale: &str, key: &'static str, replacements: &[(&str, &str)]) -> String {
let mut value = t(locale, key).into_owned();
for (name, replace) in replacements {
let token = format!("{{{name}}}");
value = value.replace(&token, replace);
}
value
}
fn leak_str(value: String) -> &'static str {
Box::leak(value.into_boxed_str())
}
fn i18n() -> &'static I18nCatalog {
static CATALOG: OnceLock<I18nCatalog> = OnceLock::new();
CATALOG.get_or_init(I18nCatalog::load)
}
#[derive(Debug)]
struct I18nCatalog {
default_locale: String,
supported: HashSet<String>,
dictionaries: HashMap<String, HashMap<String, String>>,
}
impl I18nCatalog {
fn load() -> Self {
let locales: Value = serde_json::from_str(LOCALES_JSON).expect("valid locales.json");
let default_locale = locales
.get("default")
.and_then(Value::as_str)
.unwrap_or("en")
.to_string();
let supported = locales
.get("supported")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(normalize_locale)
.collect::<HashSet<_>>()
})
.unwrap_or_else(|| {
let mut set = HashSet::new();
set.insert(normalize_locale(&default_locale));
set
});
let mut dictionaries = HashMap::new();
for (locale, raw_json) in EMBEDDED_LOCALES {
match parse_flat_json_map(raw_json) {
Ok(map) => {
let normalized_key = normalize_locale(locale);
// Don't overwrite existing entries - prefer the primary locale (e.g., "en" over "en-GB")
// since EMBEDDED_LOCALES is sorted alphabetically, "en" comes before "en-GB"
dictionaries.entry(normalized_key).or_insert(map);
}
Err(_e) => {
// Silently skip invalid JSON files
}
}
}
Self {
default_locale,
supported,
dictionaries,
}
}
fn default_locale(&self) -> &str {
&self.default_locale
}
fn normalize_or_default(&self, locale: &str) -> String {
let normalized = normalize_locale(locale);
if self.supported.contains(&normalized) {
return normalized;
}
normalize_locale(&self.default_locale)
}
fn translate(&self, locale: &str, key: &str) -> String {
let normalized = self.normalize_or_default(locale);
if let Some(text) = self
.dictionaries
.get(&normalized)
.and_then(|map| map.get(key))
.cloned()
{
return text;
}
let default = normalize_locale(&self.default_locale);
self.dictionaries
.get(&default)
.and_then(|map| map.get(key))
.cloned()
.unwrap_or_else(|| key.to_string())
}
}
fn parse_flat_json_map(input: &str) -> Result<HashMap<String, String>, String> {
let value: Value = serde_json::from_str(input).map_err(|err| err.to_string())?;
let obj = value
.as_object()
.ok_or_else(|| "JSON root must be an object".to_string())?;
let mut map = HashMap::with_capacity(obj.len());
for (k, v) in obj {
let s = v
.as_str()
.ok_or_else(|| format!("translation value for '{k}' must be a string"))?;
map.insert(k.clone(), s.to_string());
}
Ok(map)
}
#[derive(Debug, Deserialize)]
struct TenantInstallManifest {
#[serde(rename = "$schema")]
#[allow(dead_code)]
schema: Option<String>,
schema_version: String,
tenant: String,
tools: Vec<TenantManifestReference>,
docs: Vec<TenantManifestReference>,
#[serde(default)]
store_assets: Vec<TenantManifestReference>,
}
#[derive(Debug, Deserialize)]
struct TenantManifestReference {
id: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct ToolManifest {
#[allow(dead_code)]
schema_version: String,
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
description: String,
install: ToolInstallManifest,
#[allow(dead_code)]
docs: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ToolInstallManifest {
#[serde(rename = "type")]
#[allow(dead_code)]
kind: String,
binary_name: String,
targets: Vec<ToolInstallTarget>,
}
#[derive(Debug, Deserialize)]
struct ToolInstallTarget {
os: String,
arch: String,
url: String,
#[allow(dead_code)]
sha256: String,
}
#[derive(Debug, Deserialize)]
struct DocManifest {
#[allow(dead_code)]
schema_version: String,
#[allow(dead_code)]
id: String,
title: Option<String>,
source: Option<DocSource>,
download_file_name: Option<String>,
default_relative_path: Option<String>,
docs: Option<Vec<DocManifestEntry>>,
}
#[derive(Debug, Deserialize, Clone)]
struct DocManifestEntry {
#[allow(dead_code)]
title: String,
source: DocSource,
download_file_name: String,
default_relative_path: String,
}
#[derive(Debug, Deserialize, Clone)]
struct DocSource {
#[serde(rename = "type")]
#[allow(dead_code)]
kind: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct StoreAssetManifest {
#[allow(dead_code)]
schema_version: String,
items: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct GithubRelease {
assets: Vec<GithubReleaseAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAsset {
url: String,
name: String,
}
impl DocManifest {
fn entries(self) -> Result<Vec<DocManifestEntry>, String> {
if let Some(entries) = self.docs {
return Ok(entries);
}
match (
self.title,
self.source,
self.download_file_name,
self.default_relative_path,
) {
(Some(title), Some(source), Some(download_file_name), Some(default_relative_path)) => {
Ok(vec![DocManifestEntry {
title,
source,
download_file_name,
default_relative_path,
}])
}
_ => Err("doc manifest must contain either top-level doc fields or docs[]".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::{
AdminRegistryDocument, DEFAULT_AWS_OPERATOR_IMAGE, DEFAULT_AZURE_OPERATOR_IMAGE,
DEFAULT_GCP_OPERATOR_IMAGE, DEFAULT_OPERATOR_IMAGE_DIGEST, DEV_BIN, StartBundleResolution,
StartTarget, admin_registry_path, apply_default_deploy_env_for_target, build_wizard_args,
collect_tail, default_operator_image_for_target, detect_bundle_root, detect_locale,
ensure_admin_certs_ready, fingerprint_bundle_dir, locale_from_args,
normalize_bundle_fingerprint, normalize_install_arch, parse_start_cli_options,
parse_start_request, parse_stop_cli_options, parse_stop_request,
remove_admin_registry_entry, resolve_admin_cert_dir,
resolve_canonical_target_provider_pack_from, resolve_companion_binary_from,
resolve_deploy_app_pack_path, resolve_local_mutable_bundle_dir,
resolve_target_provider_pack, resolve_tenant_key, rewrite_store_tenant_placeholder,
route_passthrough_subcommand, save_admin_registry, select_start_target,
tenant_env_var_name, upsert_admin_registry_entry, validate_cloud_deploy_inputs,
write_single_vm_spec,
};
use clap::{Arg, ArgMatches, Command};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::sync::{Mutex, OnceLock};
use tempfile::{TempDir, tempdir};
#[test]
fn locale_arg_is_detected_from_equals_flag() {
let args = vec!["gtc".to_string(), "--locale=nl-NL".to_string()];
assert_eq!(locale_from_args(&args), Some("nl-NL".to_string()));
}
#[test]
fn locale_arg_is_detected_from_split_flag() {
let args = vec![
"gtc".to_string(),
"--locale".to_string(),
"en-US".to_string(),
];
assert_eq!(locale_from_args(&args), Some("en-US".to_string()));
}
#[test]
fn unsupported_locale_falls_back_to_en() {
let args = vec!["gtc".to_string(), "--locale".to_string(), "xx".to_string()];
assert_eq!(detect_locale(&args, "en"), "en");
}
#[test]
fn collect_tail_reads_passthrough_args() {
let matches: ArgMatches = Command::new("test")
.arg(
Arg::new("args")
.num_args(0..)
.trailing_var_arg(true)
.allow_hyphen_values(true),
)
.try_get_matches_from(["test", "--a", "b"])
.expect("matches");
assert_eq!(
collect_tail(&matches),
vec!["--a".to_string(), "b".to_string()]
);
}
#[test]
fn tenant_env_var_name_normalization_matches_contract() {
assert_eq!(tenant_env_var_name("acme"), "GREENTIC_ACME_KEY");
assert_eq!(tenant_env_var_name("acme-dev"), "GREENTIC_ACME_DEV_KEY");
assert_eq!(
tenant_env_var_name("Acme.Dev-01"),
"GREENTIC_ACME_DEV_01_KEY"
);
}
#[test]
fn key_resolution_prefers_cli_then_env() {
let tenant = "acme";
let locale = "en";
let env_name = tenant_env_var_name(tenant);
unsafe {
std::env::set_var(&env_name, "env-token");
}
let from_cli = resolve_tenant_key(Some("cli-token".to_string()), tenant, locale).unwrap();
assert_eq!(from_cli, "cli-token");
let from_env = resolve_tenant_key(None, tenant, locale).unwrap();
assert_eq!(from_env, "env-token");
unsafe {
std::env::remove_var(&env_name);
}
}
#[test]
fn build_wizard_args_prepends_wizard_and_locale() {
let args = build_wizard_args(&["--answers".to_string(), "a.json".to_string()], "en");
assert_eq!(
args,
vec![
"wizard".to_string(),
"--locale".to_string(),
"en".to_string(),
"--answers".to_string(),
"a.json".to_string()
]
);
}
#[test]
fn build_wizard_args_preserves_explicit_locale() {
let args = build_wizard_args(
&[
"--locale".to_string(),
"fr".to_string(),
"--answers".to_string(),
"a.json".to_string(),
],
"en",
);
assert_eq!(
args,
vec![
"wizard".to_string(),
"--locale".to_string(),
"fr".to_string(),
"--answers".to_string(),
"a.json".to_string()
]
);
}
#[test]
fn route_passthrough_subcommand_routes_wizard_to_greentic_dev() {
let tail = vec!["--help".to_string()];
let (binary, args) =
route_passthrough_subcommand("wizard", &tail, "en").expect("wizard route");
assert_eq!(binary, DEV_BIN);
assert_eq!(
args,
vec![
"wizard".to_string(),
"--locale".to_string(),
"en".to_string(),
"--help".to_string()
]
);
}
#[test]
fn parse_start_request_maps_common_flags() {
let request = parse_start_request(
&[
"--tenant".to_string(),
"demo".to_string(),
"--team=default".to_string(),
"--nats".to_string(),
"off".to_string(),
"--cloudflared=off".to_string(),
"--ngrok".to_string(),
"off".to_string(),
"--runner-binary".to_string(),
"/tmp/runner".to_string(),
],
PathBuf::from("/tmp/bundle"),
)
.expect("request");
assert_eq!(request.bundle.as_deref(), Some("/tmp/bundle"));
assert_eq!(request.tenant.as_deref(), Some("demo"));
assert_eq!(request.team.as_deref(), Some("default"));
assert_eq!(
request.runner_binary.as_deref(),
Some(Path::new("/tmp/runner"))
);
}
#[test]
fn parse_start_request_maps_admin_flags() {
let request = parse_start_request(
&[
"--admin".to_string(),
"--admin-port".to_string(),
"9443".to_string(),
"--admin-certs-dir=/tmp/admin-certs".to_string(),
"--admin-allowed-clients".to_string(),
"CN=alice,CN=bob".to_string(),
],
PathBuf::from("/tmp/bundle"),
)
.expect("request");
assert!(request.admin);
assert_eq!(request.admin_port, 9443);
assert_eq!(
request.admin_certs_dir.as_deref(),
Some(Path::new("/tmp/admin-certs"))
);
assert_eq!(
request.admin_allowed_clients,
vec!["CN=alice".to_string(), "CN=bob".to_string()]
);
}
#[test]
fn resolve_companion_binary_uses_env_override() {
unsafe {
std::env::set_var("GREENTIC_DEV_BIN", "/tmp/custom-dev");
}
let resolved =
resolve_companion_binary_from(Some(Path::new("/tmp/gtc")), DEV_BIN).expect("path");
assert_eq!(resolved, PathBuf::from("/tmp/custom-dev"));
unsafe {
std::env::remove_var("GREENTIC_DEV_BIN");
}
}
#[test]
fn resolve_companion_binary_falls_back_to_cargo_home_bin() {
let temp = tempfile::tempdir().expect("tempdir");
let cargo_home = temp.path().join("cargo-home");
let cargo_bin = cargo_home.join("bin");
let cargo_binary = cargo_bin.join(DEV_BIN);
std::fs::create_dir_all(&cargo_bin).expect("mkdir cargo bin");
std::fs::write(&cargo_binary, b"").expect("write cargo binary");
unsafe {
std::env::set_var("CARGO_HOME", &cargo_home);
}
let resolved = resolve_companion_binary_from(None, DEV_BIN).expect("path");
assert_eq!(resolved, cargo_binary);
unsafe {
std::env::remove_var("CARGO_HOME");
}
}
#[test]
fn resolve_companion_binary_falls_back_to_sibling_binary() {
let temp = tempfile::tempdir().expect("tempdir");
let exe_dir = temp.path().join("bin");
std::fs::create_dir_all(&exe_dir).expect("mkdir");
let current_exe = exe_dir.join("gtc");
let sibling = exe_dir.join(DEV_BIN);
std::fs::write(¤t_exe, b"").expect("write gtc");
std::fs::write(&sibling, b"").expect("write companion");
let resolved =
resolve_companion_binary_from(Some(current_exe.as_path()), DEV_BIN).expect("path");
assert_eq!(resolved, sibling);
}
#[test]
fn resolve_companion_binary_falls_back_to_workspace_local_binary() {
let temp = tempfile::tempdir().expect("tempdir");
let workspace = temp.path();
let current_exe = workspace
.join("greentic")
.join("target")
.join("debug")
.join("gtc");
let companion = workspace
.join("greentic-dev")
.join("target")
.join("debug")
.join(DEV_BIN);
std::fs::create_dir_all(current_exe.parent().expect("gtc dir")).expect("mkdir gtc");
std::fs::create_dir_all(companion.parent().expect("dev dir")).expect("mkdir dev");
std::fs::write(¤t_exe, b"").expect("write gtc");
std::fs::write(&companion, b"").expect("write companion");
let resolved =
resolve_companion_binary_from(Some(current_exe.as_path()), DEV_BIN).expect("path");
assert_eq!(resolved, companion);
}
#[test]
fn parse_start_request_rejects_bundle_override() {
let err = parse_start_request(
&["--bundle".to_string(), "/tmp/other".to_string()],
PathBuf::from("/tmp/bundle"),
)
.unwrap_err();
assert!(err.contains("--bundle is managed by gtc start"));
}
#[test]
fn parse_start_cli_options_strips_deploy_flags() {
let options = parse_start_cli_options(&[
"--target".to_string(),
"aws".to_string(),
"--deploy-bundle-source".to_string(),
"https://example.com/demo.gtbundle".to_string(),
"--environment=prod".to_string(),
"--provider-pack".to_string(),
"/tmp/provider.gtpack".to_string(),
"--app-pack=/tmp/app.gtpack".to_string(),
"--tenant".to_string(),
"demo".to_string(),
])
.expect("options");
assert_eq!(
options.explicit_target.map(|value| value.as_str()),
Some("aws")
);
assert_eq!(
options.deploy_bundle_source.as_deref(),
Some("https://example.com/demo.gtbundle")
);
assert_eq!(options.environment.as_deref(), Some("prod"));
assert_eq!(
options.provider_pack.as_deref(),
Some(Path::new("/tmp/provider.gtpack"))
);
assert_eq!(
options.app_pack.as_deref(),
Some(Path::new("/tmp/app.gtpack"))
);
assert_eq!(
options.start_args,
vec!["--tenant".to_string(), "demo".to_string()]
);
}
#[test]
fn validate_cloud_deploy_inputs_accepts_aws_remote_bundle_when_required_envs_present() {
let _guard = env_test_lock().lock().unwrap();
let _path_guard = temp_path_with_binary("terraform");
let bundle_dir = TempDir::new().expect("tempdir");
clear_aws_credential_env();
unsafe {
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST",
"sha256:test",
);
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND",
"s3://bucket/state",
);
env::set_var("AWS_PROFILE", "demo");
}
let result = validate_cloud_deploy_inputs(
StartTarget::Aws,
Some("https://example.com/demo.gtbundle"),
bundle_dir.path(),
"en",
);
unsafe {
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND");
}
clear_aws_credential_env();
assert!(result.is_ok());
}
#[test]
fn validate_cloud_deploy_inputs_rejects_local_bundle_for_aws() {
let _guard = env_test_lock().lock().unwrap();
let _path_guard = temp_path_with_binary("terraform");
let bundle_dir = TempDir::new().expect("tempdir");
clear_aws_credential_env();
unsafe {
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST",
"sha256:test",
);
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND",
"s3://bucket/state",
);
env::set_var("AWS_PROFILE", "demo");
}
let err = validate_cloud_deploy_inputs(
StartTarget::Aws,
Some("./demo.gtbundle"),
bundle_dir.path(),
"en",
)
.unwrap_err();
unsafe {
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND");
}
clear_aws_credential_env();
assert!(err.contains("aws deploy requires a remote bundle source"));
}
#[test]
fn validate_cloud_deploy_inputs_does_not_accept_partial_aws_access_key_env() {
let _guard = env_test_lock().lock().unwrap();
let _path_guard = temp_path_with_binary("terraform");
let bundle_dir = TempDir::new().expect("tempdir");
clear_aws_credential_env();
unsafe {
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND",
"s3://bucket/state",
);
env::set_var("AWS_ACCESS_KEY_ID", "demo");
env::remove_var("AWS_SECRET_ACCESS_KEY");
env::remove_var("AWS_PROFILE");
env::remove_var("AWS_DEFAULT_PROFILE");
env::remove_var("AWS_WEB_IDENTITY_TOKEN_FILE");
}
let err = validate_cloud_deploy_inputs(
StartTarget::Aws,
Some("https://example.com/demo.gtbundle"),
bundle_dir.path(),
"en",
)
.unwrap_err();
unsafe {
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND");
}
clear_aws_credential_env();
assert!(err.contains("missing cloud credentials"));
}
fn clear_aws_credential_env() {
unsafe {
env::remove_var("AWS_ACCESS_KEY_ID");
env::remove_var("AWS_SECRET_ACCESS_KEY");
env::remove_var("AWS_SESSION_TOKEN");
env::remove_var("AWS_PROFILE");
env::remove_var("AWS_DEFAULT_PROFILE");
env::remove_var("AWS_WEB_IDENTITY_TOKEN_FILE");
env::remove_var("AWS_ROLE_ARN");
}
}
fn env_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct PathGuard {
_temp_dir: tempfile::TempDir,
original: Option<std::ffi::OsString>,
}
impl Drop for PathGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => env::set_var("PATH", value),
None => env::remove_var("PATH"),
}
}
}
}
fn temp_path_with_binary(binary: &str) -> PathGuard {
let dir = tempdir().expect("tempdir");
let script = dir.path().join(binary);
fs::write(&script, "#!/bin/sh\nexit 0\n").expect("write shim");
let mut perms = fs::metadata(&script).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&script, perms).expect("chmod");
let original = env::var_os("PATH");
let mut merged = dir.path().display().to_string();
if let Some(existing) = &original {
merged.push(':');
merged.push_str(&existing.to_string_lossy());
}
unsafe {
env::set_var("PATH", merged);
}
PathGuard {
_temp_dir: dir,
original,
}
}
#[test]
fn parse_stop_request_maps_common_flags() {
let request = parse_stop_request(
&[
"--tenant".to_string(),
"demo".to_string(),
"--team=default".to_string(),
"--state-dir".to_string(),
"/tmp/state".to_string(),
],
PathBuf::from("/tmp/bundle"),
)
.expect("request");
assert_eq!(request.bundle.as_deref(), Some("/tmp/bundle"));
assert_eq!(request.tenant, "demo");
assert_eq!(request.team, "default");
assert_eq!(request.state_dir.as_deref(), Some(Path::new("/tmp/state")));
}
#[test]
fn parse_stop_cli_options_strips_destroy_flags() {
let options = parse_stop_cli_options(&[
"--destroy".to_string(),
"--target".to_string(),
"aws".to_string(),
"--environment=prod".to_string(),
"--provider-pack".to_string(),
"/tmp/provider.gtpack".to_string(),
"--app-pack=/tmp/app.gtpack".to_string(),
"--tenant".to_string(),
"demo".to_string(),
])
.expect("options");
assert!(options.destroy);
assert_eq!(
options.explicit_target.map(|value| value.as_str()),
Some("aws")
);
assert_eq!(options.environment.as_deref(), Some("prod"));
assert_eq!(
options.provider_pack.as_deref(),
Some(Path::new("/tmp/provider.gtpack"))
);
assert_eq!(
options.app_pack.as_deref(),
Some(Path::new("/tmp/app.gtpack"))
);
assert_eq!(
options.stop_args,
vec!["--tenant".to_string(), "demo".to_string()]
);
}
#[test]
fn select_start_target_defaults_to_runtime_without_deployer_targets() {
let dir = tempfile::tempdir().expect("tempdir");
let target = select_start_target(dir.path(), None, "en").expect("target");
assert_eq!(target.as_str(), "runtime");
}
#[test]
fn select_start_target_prefers_single_explicit_deployer_target() {
let dir = tempfile::tempdir().expect("tempdir");
let greentic_dir = dir.path().join(".greentic");
std::fs::create_dir_all(&greentic_dir).expect("create .greentic");
std::fs::write(
greentic_dir.join("deployment-targets.json"),
r#"{"targets":[{"target":"aws","provider_pack":"packs/aws.gtpack","default":true}]}"#,
)
.expect("write targets");
let target = select_start_target(dir.path(), None, "en").expect("target");
assert_eq!(target.as_str(), "aws");
}
#[test]
fn select_start_target_uses_default_from_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
let greentic_dir = dir.path().join(".greentic");
std::fs::create_dir_all(&greentic_dir).expect("create .greentic");
std::fs::write(
greentic_dir.join("deployment-targets.json"),
r#"{"targets":[
{"target":"aws","provider_pack":"packs/aws.gtpack"},
{"target":"gcp","provider_pack":"packs/gcp.gtpack","default":true}
]}"#,
)
.expect("write targets");
let target = select_start_target(dir.path(), None, "en").expect("target");
assert_eq!(target.as_str(), "gcp");
}
#[test]
fn resolve_target_provider_pack_reads_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
let greentic_dir = dir.path().join(".greentic");
std::fs::create_dir_all(&greentic_dir).expect("create .greentic");
let expected = dir.path().join("packs").join("gcp.gtpack");
std::fs::create_dir_all(expected.parent().expect("parent")).expect("mkdir");
std::fs::write(&expected, b"fixture").expect("write provider");
std::fs::write(
greentic_dir.join("deployment-targets.json"),
r#"{"targets":[{"target":"gcp","provider_pack":"packs/gcp.gtpack","default":true}]}"#,
)
.expect("write targets");
let resolved =
resolve_target_provider_pack(dir.path(), StartTarget::Gcp, None).expect("provider");
assert_eq!(resolved, expected);
}
#[test]
fn resolve_canonical_target_provider_pack_from_workspace_deployer_dist() {
let workspace = tempfile::tempdir().expect("tempdir");
let repo_dir = workspace.path().join("greentic-deployer");
let exe_dir = repo_dir.join("target").join("debug");
let deployer_bin = exe_dir.join("greentic-deployer");
let dist_pack = repo_dir.join("dist").join("terraform.gtpack");
std::fs::create_dir_all(&exe_dir).expect("mkdir exe dir");
std::fs::create_dir_all(dist_pack.parent().expect("dist parent")).expect("mkdir dist");
std::fs::write(&deployer_bin, b"").expect("write deployer");
std::fs::write(&dist_pack, b"").expect("write pack");
let resolved = resolve_canonical_target_provider_pack_from(
Some(deployer_bin.as_path()),
"terraform.gtpack",
)
.expect("canonical pack");
assert_eq!(resolved, dist_pack);
}
#[test]
fn resolve_admin_cert_dir_prefers_bundle_local_admin_certs() {
let dir = tempfile::tempdir().expect("tempdir");
let certs = dir.path().join(".greentic").join("admin").join("certs");
std::fs::create_dir_all(&certs).expect("mkdir");
std::fs::write(certs.join("ca.crt"), b"ca").expect("ca");
std::fs::write(certs.join("server.crt"), b"cert").expect("cert");
std::fs::write(certs.join("server.key"), b"key").expect("key");
let resolved = resolve_admin_cert_dir(dir.path());
assert_eq!(resolved, certs);
}
#[test]
fn resolve_admin_cert_dir_falls_back_to_bundle_certs_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let certs = dir.path().join("certs");
std::fs::create_dir_all(&certs).expect("mkdir");
std::fs::write(certs.join("ca.crt"), b"ca").expect("ca");
std::fs::write(certs.join("server.crt"), b"cert").expect("cert");
std::fs::write(certs.join("server.key"), b"key").expect("key");
let resolved = resolve_admin_cert_dir(dir.path());
assert_eq!(resolved, certs);
}
#[test]
fn ensure_admin_certs_ready_generates_bundle_local_certs() {
let dir = tempfile::tempdir().expect("tempdir");
let resolved = ensure_admin_certs_ready(dir.path(), None).expect("certs");
assert_eq!(
resolved,
dir.path().join(".greentic").join("admin").join("certs")
);
assert!(resolved.join("ca.crt").exists());
assert!(resolved.join("server.crt").exists());
assert!(resolved.join("server.key").exists());
assert!(resolved.join("client.crt").exists());
assert!(resolved.join("client.key").exists());
}
#[test]
fn ensure_admin_certs_ready_preserves_explicit_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let certs = dir.path().join("custom-certs");
std::fs::create_dir_all(&certs).expect("mkdir");
std::fs::write(certs.join("ca.crt"), b"ca").expect("ca");
std::fs::write(certs.join("server.crt"), b"cert").expect("cert");
std::fs::write(certs.join("server.key"), b"key").expect("key");
let resolved = ensure_admin_certs_ready(dir.path(), Some(&certs)).expect("certs");
assert_eq!(resolved, certs);
}
#[test]
fn write_single_vm_spec_uses_bundle_local_server_certs() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle_dir = dir.path().join("bundle");
let certs = bundle_dir.join(".greentic").join("admin").join("certs");
std::fs::create_dir_all(&certs).expect("mkdir certs");
std::fs::write(certs.join("ca.crt"), b"ca").expect("ca");
std::fs::write(certs.join("server.crt"), b"server cert").expect("server cert");
std::fs::write(certs.join("server.key"), b"server key").expect("server key");
let artifact_path = dir.path().join("bundle.gtbundle");
std::fs::write(&artifact_path, b"fixture").expect("artifact");
let resolved = StartBundleResolution {
bundle_dir: bundle_dir.clone(),
deployment_key: "demo-deploy".to_string(),
deploy_artifact: Some(artifact_path.clone()),
_hold: None,
};
let request = parse_start_request(
&[
"--tenant".to_string(),
"demo".to_string(),
"--team".to_string(),
"default".to_string(),
],
bundle_dir.clone(),
)
.expect("request");
let spec_path =
write_single_vm_spec("demo-bundle", &resolved, &request, &artifact_path).expect("spec");
let spec = std::fs::read_to_string(&spec_path).expect("read spec");
assert!(spec.contains("source: 'file://"));
assert!(spec.contains(&artifact_path.display().to_string()));
assert!(spec.contains("certFile: '"));
assert!(spec.contains(".greentic/admin/certs/server.crt"));
assert!(spec.contains("keyFile: '"));
assert!(spec.contains(".greentic/admin/certs/server.key"));
assert!(!spec.contains("client.crt"));
assert!(!spec.contains("client.key"));
}
#[test]
fn resolve_deploy_app_pack_path_prefers_bundle_metadata_app_pack() {
let dir = tempfile::tempdir().expect("tempdir");
let packs_dir = dir.path().join("packs");
std::fs::create_dir_all(&packs_dir).expect("create packs dir");
let app_pack = packs_dir.join("cards-demo.gtpack");
let other_pack = packs_dir.join("default.gtpack");
std::fs::write(&app_pack, b"app").expect("write app pack");
std::fs::write(&other_pack, b"other").expect("write other pack");
std::fs::write(
dir.path().join("bundle.yaml"),
"bundle_id: demo\napp_packs:\n - /tmp/build/cards-demo.gtpack\n",
)
.expect("write bundle");
let resolved = resolve_deploy_app_pack_path(dir.path(), None).expect("app pack");
assert_eq!(resolved, app_pack);
}
#[test]
fn resolve_deploy_app_pack_path_prefers_bundle_metadata_remote_app_pack() {
let dir = tempfile::tempdir().expect("tempdir");
let packs_dir = dir.path().join("packs");
std::fs::create_dir_all(&packs_dir).expect("create packs dir");
let app_pack = packs_dir.join("cards-demo.gtpack");
let other_pack = packs_dir.join("default.gtpack");
std::fs::write(&app_pack, b"app").expect("write app pack");
std::fs::write(&other_pack, b"other").expect("write other pack");
std::fs::write(
dir.path().join("bundle.yaml"),
"bundle_id: demo\napp_packs:\n - https://example.com/releases/latest/download/cards-demo.gtpack\n",
)
.expect("write bundle");
let resolved = resolve_deploy_app_pack_path(dir.path(), None).expect("app pack");
assert_eq!(resolved, app_pack);
}
#[test]
fn resolve_deploy_app_pack_path_rejects_multiple_candidates_without_canonical_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
let packs_dir = dir.path().join("packs");
std::fs::create_dir_all(&packs_dir).expect("create packs dir");
std::fs::write(packs_dir.join("cards-demo.gtpack"), b"app").expect("write app pack");
std::fs::write(packs_dir.join("other.gtpack"), b"other").expect("write other pack");
let err = resolve_deploy_app_pack_path(dir.path(), None).unwrap_err();
assert!(err.contains("cloud deployment requires a canonical app pack"));
}
#[test]
fn detect_bundle_root_accepts_normalized_bundle_at_root() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("bundle.yaml"), "bundle_id: demo\n").expect("bundle");
std::fs::create_dir_all(dir.path().join("resolved")).expect("resolved");
assert_eq!(detect_bundle_root(dir.path()), dir.path());
}
#[test]
fn detect_bundle_root_accepts_single_nested_normalized_bundle() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle_root = dir.path().join("squashfs-root");
std::fs::create_dir_all(bundle_root.join("resolved")).expect("resolved");
std::fs::write(bundle_root.join("bundle.yaml"), "bundle_id: demo\n").expect("bundle");
assert_eq!(detect_bundle_root(dir.path()), bundle_root);
}
#[test]
fn normalize_bundle_fingerprint_ignores_runtime_noise() {
let normalized = normalize_bundle_fingerprint(
"dir:.greentic\n\
dir:.greentic/dev\n\
file:.greentic/dev/.dev.secrets.env:1009:1773411510\n\
dir:logs\n\
file:logs/operator.log:0:1773411782\n\
dir:state\n\
dir:state/logs\n\
file:state/logs/runtime.log:123:1773411782\n\
dir:state/pids\n\
file:state/pids/operator.pid:5:1773411782\n\
dir:packs\n\
file:packs/cards-demo.gtpack:5267324:1773411414\n\
file:state/config/platform/static-routes.json:206:1773411510",
);
assert_eq!(
normalized,
"dir:.greentic\n\
dir:state\n\
dir:packs\n\
file:packs/cards-demo.gtpack:5267324\n\
file:state/config/platform/static-routes.json:206"
);
}
#[test]
fn fingerprint_bundle_dir_ignores_runtime_noise_files() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".greentic/dev")).expect("create dev dir");
std::fs::create_dir_all(dir.path().join("logs")).expect("create logs dir");
std::fs::create_dir_all(dir.path().join("state/logs")).expect("create state logs dir");
std::fs::create_dir_all(dir.path().join("packs")).expect("create packs dir");
std::fs::write(
dir.path().join(".greentic/dev/.dev.secrets.env"),
"SECRET=demo\n",
)
.expect("write dev secrets");
std::fs::write(dir.path().join("logs/operator.log"), "").expect("write operator log");
std::fs::write(dir.path().join("state/logs/runtime.log"), "runtime\n")
.expect("write runtime log");
std::fs::write(dir.path().join("packs/cards-demo.gtpack"), "fixture")
.expect("write app pack");
let fingerprint = fingerprint_bundle_dir(dir.path()).expect("fingerprint");
assert!(fingerprint.contains("file:packs/cards-demo.gtpack"));
assert!(!fingerprint.contains(".greentic/dev/.dev.secrets.env"));
assert!(!fingerprint.contains("logs/operator.log"));
assert!(!fingerprint.contains("state/logs/runtime.log"));
}
#[test]
fn normalize_bundle_fingerprint_ignores_file_mtime() {
let before = "file:packs/cards-demo.gtpack:5267324:1773411414";
let after = "file:packs/cards-demo.gtpack:5267324:1773516076";
assert_eq!(
normalize_bundle_fingerprint(before),
normalize_bundle_fingerprint(after)
);
}
#[test]
fn admin_registry_roundtrip_and_remove_by_cn() {
let dir = tempdir().expect("tempdir");
let mut registry = AdminRegistryDocument { admins: Vec::new() };
upsert_admin_registry_entry(
&mut registry,
Some("alice".to_string()),
"CN=alice".to_string(),
"ssh-ed25519 AAAA alice".to_string(),
);
assert_eq!(registry.admins.len(), 1);
assert_eq!(registry.admins[0].name.as_deref(), Some("alice"));
save_admin_registry(dir.path(), ®istry).expect("save registry");
let raw = std::fs::read_to_string(admin_registry_path(dir.path())).expect("read registry");
assert!(raw.contains("CN=alice"));
let removed = remove_admin_registry_entry(&mut registry, Some("CN=alice"), None);
assert!(removed);
assert!(registry.admins.is_empty());
}
#[test]
fn resolve_local_mutable_bundle_dir_rejects_archive_paths() {
let dir = tempdir().expect("tempdir");
let archive = dir.path().join("bundle.gtbundle");
std::fs::write(&archive, b"fixture").expect("archive");
let err = resolve_local_mutable_bundle_dir(archive.to_str().expect("utf8")).unwrap_err();
assert!(err.contains("local bundle directory"));
}
#[test]
fn normalize_install_arch_maps_common_aliases() {
assert_eq!(normalize_install_arch("arm64"), Some("aarch64"));
assert_eq!(normalize_install_arch("aarch64"), Some("aarch64"));
assert_eq!(normalize_install_arch("amd64"), Some("x86_64"));
assert_eq!(normalize_install_arch("x86_64"), Some("x86_64"));
}
#[test]
fn normalize_install_arch_rejects_unknown_values() {
assert_eq!(normalize_install_arch("armv7"), None);
}
#[test]
fn rewrite_store_tenant_placeholder_substitutes_template_segment() {
let input = "store://greentic-biz/{tenant}/providers/routing-hook/fast2flow.gtpack:latest";
let output = rewrite_store_tenant_placeholder(input, "3point");
assert_eq!(
output,
"store://greentic-biz/3point/providers/routing-hook/fast2flow.gtpack:latest"
);
}
#[test]
fn default_operator_image_for_target_uses_cloud_specific_refs() {
assert_eq!(
default_operator_image_for_target(StartTarget::Aws),
Some(DEFAULT_AWS_OPERATOR_IMAGE)
);
assert_eq!(
default_operator_image_for_target(StartTarget::Gcp),
Some(DEFAULT_GCP_OPERATOR_IMAGE)
);
assert_eq!(
default_operator_image_for_target(StartTarget::Azure),
Some(DEFAULT_AZURE_OPERATOR_IMAGE)
);
assert_eq!(
default_operator_image_for_target(StartTarget::Runtime),
None
);
}
#[test]
fn apply_default_deploy_env_for_target_prefers_explicit_env() {
let _guard = env_test_lock().lock().unwrap();
unsafe {
env::set_var(
"GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE",
"custom/image@sha256:deadbeef",
);
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
}
let mut process = ProcessCommand::new("env");
apply_default_deploy_env_for_target(&mut process, Some(StartTarget::Gcp));
let envs: HashMap<_, _> = process
.get_envs()
.filter_map(|(key, value)| {
Some((
key.to_string_lossy().to_string(),
value?.to_string_lossy().to_string(),
))
})
.collect();
assert!(
!envs.contains_key("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE"),
"explicit parent env should be inherited without adding a child override"
);
assert_eq!(
envs.get("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST")
.map(String::as_str),
Some(DEFAULT_OPERATOR_IMAGE_DIGEST)
);
unsafe {
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE");
env::remove_var("GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST");
}
}
}