diff --git a/migrations/20260718130000_marketplace_install_authorization.down.sql b/migrations/20260718130000_marketplace_install_authorization.down.sql new file mode 100644 index 00000000..1eaee337 --- /dev/null +++ b/migrations/20260718130000_marketplace_install_authorization.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS marketplace_install_authorization; diff --git a/migrations/20260718130000_marketplace_install_authorization.up.sql b/migrations/20260718130000_marketplace_install_authorization.up.sql new file mode 100644 index 00000000..2634dd9b --- /dev/null +++ b/migrations/20260718130000_marketplace_install_authorization.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE marketplace_install_authorization ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id integer, + user_id varchar(64) NOT NULL, + template_id uuid NOT NULL REFERENCES stack_template(id) ON DELETE RESTRICT, + idempotency_key varchar(80) NOT NULL, + authorization_id varchar(120) NOT NULL, + amount_minor bigint NOT NULL, + currency char(3) NOT NULL, + status varchar(24) NOT NULL, + deployment_hash varchar(120), + void_reason varchar(120), + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_mia_idem UNIQUE (user_id, idempotency_key) +); + +CREATE INDEX ix_mia_project + ON marketplace_install_authorization (project_id); + +CREATE INDEX ix_mia_deploy_hash + ON marketplace_install_authorization (deployment_hash) + WHERE deployment_hash IS NOT NULL; + +CREATE INDEX ix_mia_sweep + ON marketplace_install_authorization (status, expires_at) + WHERE status = 'authorized'; + +CREATE INDEX ix_mia_auth_id + ON marketplace_install_authorization (authorization_id); diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b36d1cd0..f00072b5 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -253,6 +253,21 @@ enum StackerCommands { /// Output in JSON format #[arg(long)] json: bool, + /// Name of saved cloud credential to reuse (overrides auto-detected default) + #[arg(long, value_name = "KEY_NAME")] + key: Option, + /// ID of saved cloud credential to reuse (from `stacker list clouds`) + #[arg(long, value_name = "CLOUD_ID")] + key_id: Option, + /// Cloud region for the new server (e.g. nbg1, fsn1) + #[arg(long, value_name = "REGION")] + region: Option, + /// Cloud server size/type (e.g. cx23, cpx21) + #[arg(long, value_name = "SIZE")] + size: Option, + /// Cloud provider (hetzner|digitalocean|aws|linode|vultr). Overrides provider from saved credential. + #[arg(long, value_name = "PROVIDER")] + provider: Option, }, /// Show container logs Logs { @@ -2706,9 +2721,15 @@ fn get_command( json, domain, set_values, + key, + key_id, + region, + size, + provider, } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceInstallCommand::new( - template, name, file, force, json, domain, set_values, + template, name, file, force, json, domain, set_values, key, key_id, region, size, + provider, ), ), StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd { diff --git a/src/cli/ai_scenarios.rs b/src/cli/ai_scenarios.rs index 2514c3cf..153296bf 100644 --- a/src/cli/ai_scenarios.rs +++ b/src/cli/ai_scenarios.rs @@ -738,7 +738,7 @@ mod tests { provider: crate::cli::config_parser::CloudProvider::Hetzner, orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, region: Some("nbg1".to_string()), - size: Some("cpx11".to_string()), + size: Some("cx23".to_string()), install_image: None, remote_payload_file: None, ssh_key: None, diff --git a/src/cli/compose_service_sync.rs b/src/cli/compose_service_sync.rs index c1564cc7..4c3be0ad 100644 --- a/src/cli/compose_service_sync.rs +++ b/src/cli/compose_service_sync.rs @@ -47,6 +47,38 @@ pub fn inject_npm_proxy_network( inject_external_network(compose_doc, service_name, "default_network") } +/// Attach every service in `compose_doc` to a shared `external: true` `network` +/// and declare it at the top level. Idempotent — services already on the network +/// are left unchanged. Returns `true` if the document was modified. +/// +/// Used so status-panel/agent deploys can reach the project's containers: the +/// agent lives on `default_network`, and project containers must share it. The +/// CLI-generated compose already joins `default_network`; this also covers +/// user-supplied composes that define their own network. +pub fn inject_shared_network_all_services( + compose_doc: &mut serde_yaml::Value, + network: &str, +) -> bool { + let service_names: Vec = compose_doc + .get("services") + .and_then(serde_yaml::Value::as_mapping) + .map(|services| { + services + .keys() + .filter_map(|key| key.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let mut changed = false; + for service_name in service_names { + if inject_external_network(compose_doc, &service_name, network) { + changed = true; + } + } + changed +} + fn inject_external_network( compose_doc: &mut serde_yaml::Value, service_name: &str, @@ -419,6 +451,37 @@ mod tests { use std::collections::HashMap; use tempfile::TempDir; + #[test] + fn inject_shared_network_all_services_is_idempotent_and_covers_all() { + let mut doc: serde_yaml::Value = serde_yaml::from_str( + "services:\n app:\n image: a\n networks: [default_network]\n db:\n image: b\n", + ) + .unwrap(); + + // First pass: only `db` is missing the network → changed. + assert!(inject_shared_network_all_services( + &mut doc, + "default_network" + )); + for svc in ["app", "db"] { + let nets = doc["services"][svc]["networks"].as_sequence().unwrap(); + assert_eq!( + nets.iter() + .filter(|n| n.as_str() == Some("default_network")) + .count(), + 1, + "service {svc} should have default_network exactly once" + ); + } + assert_eq!(doc["networks"]["default_network"]["external"], true); + + // Second pass: everything already present → no change (idempotent). + assert!(!inject_shared_network_all_services( + &mut doc, + "default_network" + )); + } + // ── inject_npm_proxy_network unit tests ────────────────────────────────── fn npm_proxy_config(upstream: &str) -> ProxyConfig { diff --git a/src/cli/config_bundle.rs b/src/cli/config_bundle.rs index d7aa1261..d0d96d09 100644 --- a/src/cli/config_bundle.rs +++ b/src/cli/config_bundle.rs @@ -74,6 +74,7 @@ pub fn build_config_bundle( compose_path: &Path, env_file: Option<&Path>, reference_base: &Path, + attach_agent_network: bool, ) -> Result { validate_environment_name(environment)?; @@ -120,6 +121,16 @@ pub fn build_config_bundle( &mut collected, )?; + // Status-panel/agent deploys: put every project service on the shared + // external `default_network` the agent runs on, so it can reach containers + // by name/IP. Idempotent — services already on the network are untouched. + if attach_agent_network { + crate::cli::compose_service_sync::inject_shared_network_all_services( + &mut compose_yaml, + "default_network", + ); + } + let rewritten_compose = serde_yaml::to_string(&compose_yaml) .map_err(|err| validation_error(format!("failed to write remote compose: {err}")))?; std::fs::write(&remote_compose_path, &rewritten_compose)?; @@ -667,6 +678,7 @@ services: &compose_dir.join("compose.yml"), Some(&compose_dir.join(".env")), &compose_dir, + false, ) .expect("bundle should be built"); @@ -740,6 +752,7 @@ services: &dir.path().join("docker-compose.yml"), None, dir.path(), + false, ) .expect("bundle should be built"); @@ -782,6 +795,7 @@ services: &stacker_dir.join("docker-compose.yml"), None, dir.path(), + false, ) .expect("bundle should be built for generated compose"); @@ -845,6 +859,7 @@ services: &compose_dir.join("compose.yml"), None, &compose_dir, + false, ) .expect("directory mounts should not block the bundle"); @@ -864,6 +879,47 @@ services: assert!(remote.contains("docker/production/romm.env:/romm/config/romm.env:ro")); } + #[test] + fn build_config_bundle_attaches_agent_network_when_requested() { + let dir = TempDir::new().unwrap(); + let compose_dir = dir.path().join("docker/production"); + std::fs::create_dir_all(&compose_dir).unwrap(); + std::fs::write( + compose_dir.join("compose.yml"), + r#" +services: + app: + image: example/app:latest + db: + image: mysql:8 +"#, + ) + .unwrap(); + + let artifacts = build_config_bundle( + dir.path(), + "production", + &compose_dir.join("compose.yml"), + None, + &compose_dir, + true, + ) + .expect("bundle should be built"); + + let remote: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&artifacts.remote_compose_path).unwrap()) + .unwrap(); + // Every service joins default_network, declared external at the top level. + for svc in ["app", "db"] { + let nets = remote["services"][svc]["networks"].as_sequence().unwrap(); + assert!( + nets.iter().any(|n| n.as_str() == Some("default_network")), + "service {svc} should join default_network" + ); + } + assert_eq!(remote["networks"]["default_network"]["external"], true); + } + #[test] fn build_config_bundle_reports_missing_env_file_path() { let dir = TempDir::new().unwrap(); @@ -887,6 +943,7 @@ services: &compose_dir.join("compose.yml"), None, &compose_dir, + false, ) .unwrap_err(); diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 52b2d372..b8317b3e 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -845,6 +845,18 @@ pub struct StackerConfig { /// [`MARKETPLACE_ORIGIN_MARKER`]. #[serde(skip)] pub origin: ConfigOrigin, + + /// Whether the source config explicitly declared an `app:` section. + /// + /// `app` is a non-optional field with serde defaults, so a config that + /// omits `app:` still deserializes to a default `AppSource`. This flag + /// distinguishes "user/template actually declared an app to build" from + /// "app was defaulted in". Set at load time by `from_file`/`from_str` + /// (raw key present) and by `ConfigBuilder` (app setters used). The compose + /// generator uses it to avoid synthesizing a phantom `app` service for + /// services-only configs. Not serialized. + #[serde(skip)] + pub app_present: bool, } impl StackerConfig { @@ -873,8 +885,10 @@ impl StackerConfig { let mut parsed: serde_yaml::Value = serde_yaml::from_str(&raw_content)?; let env_file_vars = load_env_file_vars_from_yaml(path, &raw_content); resolve_env_placeholders_in_value(&mut parsed, &env_file_vars)?; + let app_present = parsed.get("app").is_some(); let mut config = deserialize_config_value(parsed)?; config.origin = origin; + config.app_present = app_present; Ok(config) } @@ -894,8 +908,10 @@ impl StackerConfig { let raw_content = std::fs::read_to_string(path)?; let origin = detect_origin_from_raw(&raw_content); let parsed: serde_yaml::Value = serde_yaml::from_str(&raw_content)?; + let app_present = parsed.get("app").is_some(); let mut config = deserialize_config_value(parsed)?; config.origin = origin; + config.app_present = app_present; Ok(config) } @@ -904,8 +920,10 @@ impl StackerConfig { let origin = detect_origin_from_raw(yaml); let mut parsed: serde_yaml::Value = serde_yaml::from_str(yaml)?; resolve_env_placeholders_in_value(&mut parsed, &HashMap::new())?; + let app_present = parsed.get("app").is_some(); let mut config = deserialize_config_value(parsed)?; config.origin = origin; + config.app_present = app_present; Ok(config) } @@ -1500,6 +1518,14 @@ impl ConfigBuilder { .name .ok_or_else(|| CliError::ConfigValidation("name is required".into()))?; + // Any app-related builder setter marks the app as explicitly declared, + // so the compose generator materializes it even alongside services. + let app_present = self.app_type.is_some() + || self.app_image.is_some() + || self.app_dockerfile.is_some() + || !self.app_volumes.is_empty() + || !self.build_args.is_empty(); + let build_config = if self.build_args.is_empty() { None } else { @@ -1550,6 +1576,7 @@ impl ConfigBuilder { env: self.env, config_contract: ConfigContract::default(), origin: ConfigOrigin::UserAuthored, + app_present, }) } } @@ -2237,7 +2264,7 @@ deploy: provider: CloudProvider::Hetzner, orchestrator: CloudOrchestrator::Remote, region: Some("nbg1".to_string()), - size: Some("cpx11".to_string()), + size: Some("cx23".to_string()), install_image: None, remote_payload_file: None, ssh_key: None, diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index beb5b156..44fe2a20 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -115,15 +115,23 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { let mut named_volumes: Vec = Vec::new(); // --- Main app service --- - let app_service = build_app_service(config); - for vol in &app_service.volumes { - if let Some(named) = extract_named_volume(vol) { - if !named_volumes.contains(&named) { - named_volumes.push(named); + // A marketplace-generated config describes its whole stack in + // `services:` — there is no local application to build. Synthesizing an + // `app` service there produces a phantom `build: .stacker/Dockerfile` + // whose context is never shipped to the remote host, failing the + // deploy with "lstat /home/.../.stacker: no such file or directory". + // Skip it unless the app section carries an explicit source. + if config_has_buildable_app(config) { + let app_service = build_app_service(config); + for vol in &app_service.volumes { + if let Some(named) = extract_named_volume(vol) { + if !named_volumes.contains(&named) { + named_volumes.push(named); + } } } + compose.services.push(app_service); } - compose.services.push(app_service); // --- Additional services (databases, caches, etc.) --- for svc_def in &config.services { @@ -184,17 +192,55 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { // Internal construction helpers (SRP: each builds one aspect) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// Whether the config's `app` section should be materialized as a service. +/// +/// `app` is a non-optional field, so every config carries a default `AppSource` +/// even when none was declared. Synthesizing a service from that default yields +/// a phantom `app` that builds from `.stacker/Dockerfile` — a context that +/// doesn't exist for a services-only stack (a marketplace stack, or a +/// hand-written `stacker.yml` with only `services:`), failing the remote deploy +/// with "lstat .../.stacker: no such file or directory". +/// +/// The app is materialized when: +/// - it declares an explicit source (`image` / `dockerfile` / `build`), or +/// - the source config actually declared an `app:` section (`app_present`), or +/// - there are no services to deploy (keep it so the compose isn't empty). +/// +/// This is origin-independent: it fixes both marketplace-generated and +/// user-authored services-only configs. +fn config_has_buildable_app(config: &StackerConfig) -> bool { + if config.app.image.is_some() || config.app.dockerfile.is_some() || config.app.build.is_some() { + return true; + } + if config.app_present { + return true; + } + config.services.is_empty() +} + fn build_app_service(config: &StackerConfig) -> ComposeService { let mut svc = ComposeService { name: "app".to_string(), ..Default::default() }; + // The compose service (and its DNS name) stays `app` so intra-project + // references keep working, but the `my.stacker.service` label carries the + // project code so the status-panel agent can resolve this container by the + // app code it queries with (`stacker pipe scan --app `). Previously + // this was hardcoded to "app" and never matched the project code. + let app_code = crate::helpers::stacker_labels::sanitize_service_code( + config + .project + .identity + .as_deref() + .unwrap_or(config.name.as_str()), + ); crate::helpers::stacker_labels::insert_runtime_labels( &mut svc.labels, None::, None, crate::helpers::stacker_labels::SCOPE_PROJECT, - "app", + &app_code, "app", ); @@ -484,7 +530,7 @@ impl fmt::Display for ComposeDefinition { mod tests { use super::*; use crate::cli::config_parser::{ - AppSource, ConfigBuilder, DeployConfig, DomainConfig, ProxyConfig, SslMode, + AppSource, ConfigBuilder, ConfigOrigin, DeployConfig, DomainConfig, ProxyConfig, SslMode, }; use std::collections::HashMap; @@ -505,6 +551,29 @@ mod tests { assert!(compose.services[0].ports.contains(&"80:80".to_string())); } + #[test] + fn app_service_label_carries_project_code_not_hardcoded_app() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = &compose.services[0]; + // Service name and DNS stay "app" so intra-project references keep working. + assert_eq!(app.name, "app"); + assert_eq!( + app.labels + .get(crate::helpers::stacker_labels::DNS) + .map(String::as_str), + Some("app") + ); + // The agent-resolution label carries the project code, not "app", so + // `stacker pipe scan --app ` can resolve this container. + assert_eq!( + app.labels + .get(crate::helpers::stacker_labels::SERVICE) + .map(String::as_str), + Some("test-app") + ); + } + #[test] fn test_compose_from_node_config() { let config = minimal_config(AppType::Node); @@ -528,6 +597,116 @@ mod tests { assert!(app.image.is_none()); } + fn service_names(config: &StackerConfig) -> Vec { + ComposeDefinition::try_from(config) + .unwrap() + .services + .iter() + .map(|s| s.name.clone()) + .collect() + } + + // A services-only config with no `app:` section must NOT get a phantom + // `app` (which would build from an unshipped `.stacker/Dockerfile`). This + // holds regardless of origin — both a hand-written user config (e.g. + // screego) and a marketplace-generated one. + #[test] + fn services_only_config_without_app_section_omits_phantom_app() { + let yaml = "\ +name: screego +services: + screego: + image: screego/server:latest + ports: + - \"5050:5050\" +"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(!config.app_present); + let names = service_names(&config); + assert!( + !names.contains(&"app".to_string()), + "services-only config must not synthesize an app service, got {:?}", + names + ); + assert!(names.contains(&"screego".to_string())); + } + + // Same shape, but marketplace-origin (the ghost case) — identical result. + #[test] + fn marketplace_services_only_config_omits_phantom_app() { + let yaml = "\ +# @stacker-origin: marketplace +name: ghost +services: + ghost: + image: ghost:5-alpine + ports: + - \"2368:2368\" +"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.origin, ConfigOrigin::MarketplaceGenerated); + assert!(!config.app_present); + let names = service_names(&config); + assert!(!names.contains(&"app".to_string())); + assert!(names.contains(&"ghost".to_string())); + } + + // An explicit `app:` section is always honored, even alongside services. + #[test] + fn config_with_declared_app_section_keeps_app() { + let yaml = "\ +name: myproj +app: + type: node +services: + db: + image: postgres:16 +"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(config.app_present); + let names = service_names(&config); + assert!( + names.contains(&"app".to_string()), + "declared app must be kept: {:?}", + names + ); + assert!(names.contains(&"db".to_string())); + } + + // An explicit image source keeps the app even without an `app:` key path + // through the builder. + #[test] + fn config_with_explicit_app_image_keeps_app_service() { + let mut config = minimal_config(AppType::Static); + config.app_present = false; // simulate no declared app: section + config.app.image = Some("myorg/app:1.0".to_string()); + config.services = vec![ServiceDefinition { + name: "db".to_string(), + image: "postgres:16".to_string(), + ports: vec![], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + command: None, + healthcheck: None, + }]; + + let names = service_names(&config); + assert!(names.contains(&"app".to_string())); + assert!(names.contains(&"db".to_string())); + } + + // No services and no app declared: keep the fallback app so the compose is + // never empty. + #[test] + fn config_without_services_or_app_keeps_app_fallback() { + let yaml = "name: bare\n"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(!config.app_present); + assert!(config.services.is_empty()); + assert!(service_names(&config).contains(&"app".to_string())); + } + #[test] fn test_compose_app_service_with_explicit_image() { let config = ConfigBuilder::new() diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 52c1a71b..95d66d9d 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1872,6 +1872,13 @@ pub(crate) fn resolve_docker_registry_credentials( } if let Some(s) = server { creds.insert("docker_registry".to_string(), serde_json::Value::String(s)); + } else { + // Always send docker_registry so the install service overrides any + // regional Vault defaults (e.g. Aliyun) with Docker Hub (empty string). + creds.insert( + "docker_registry".to_string(), + serde_json::Value::String(String::new()), + ); } creds @@ -1909,7 +1916,7 @@ fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { .unwrap_or_else(|| "nbg1".to_string()); let server = cloud .and_then(|c| c.size.clone()) - .unwrap_or_else(|| "cpx11".to_string()); + .unwrap_or_else(|| "cx23".to_string()); let stack_code = config .project .identity @@ -3055,7 +3062,7 @@ mod tests { let payload = serde_json::json!({ "provider": "htz", "region": "nbg1", - "server": "cpx11", + "server": "cx23", "os": "ubuntu-22.04", "stack_code": "demo", "selected_plan": "free", @@ -3073,7 +3080,7 @@ mod tests { let payload = serde_json::json!({ "provider": "htz", "region": "nbg1", - "server": "cpx11", + "server": "cx23", "os": "ubuntu-22.04", "commonDomain": "example.com", "stack_code": "", @@ -3094,7 +3101,7 @@ mod tests { let payload = serde_json::json!({ "provider": "htz", "region": "nbg1", - "server": "cpx11", + "server": "cx23", "os": "ubuntu-22.04", "commonDomain": "localhost", "stack_code": "demo-stack", @@ -3538,6 +3545,19 @@ mod tests { ); } + #[test] + fn test_resolve_docker_registry_credentials_sends_empty_when_no_config() { + let config = ConfigBuilder::new().name("public-app").build().unwrap(); + + let creds = resolve_docker_registry_credentials(&config); + assert!(creds.get("docker_username").is_none()); + assert!(creds.get("docker_password").is_none()); + assert_eq!( + creds.get("docker_registry").and_then(|v| v.as_str()), + Some("") + ); + } + #[test] fn test_cloud_deploy_runs_install_container() { let config = sample_cloud_config(); diff --git a/src/cli/service_catalog.rs b/src/cli/service_catalog.rs index 58838c74..7e7a2aac 100644 --- a/src/cli/service_catalog.rs +++ b/src/cli/service_catalog.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; -use crate::cli::config_parser::ServiceDefinition; +use crate::cli::config_parser::{ComposeHealthcheck, ServiceDefinition}; use crate::cli::error::CliError; use crate::cli::stacker_client::StackerClient; @@ -27,6 +27,59 @@ pub struct CatalogEntry { pub related: Vec, } +/// How the default value for a [`CatalogInput`] is produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputDefault { + /// No default — the user must provide a value (errors if non-interactive). + Required, + /// A fixed default, offered when prompting. + Literal(String), + /// Auto-generate a random secret; never prompted for. + GeneratedSecret, +} + +/// A user-configurable value for a catalog service. The service definition +/// references it as `${key}`; the resolved value is written to `.env`, never +/// into `stacker.yml`. +#[derive(Debug, Clone)] +pub struct CatalogInput { + pub key: String, + pub prompt: String, + pub default: InputDefault, + /// Mask input and route the value to `.env`. + pub secret: bool, +} + +/// User inputs for a hardcoded catalog service (by canonical code). The service +/// template references each `key` as `${key}`. Empty for services with no +/// configurable values. +pub fn catalog_inputs(code: &str) -> Vec { + let secret = |key: &str, prompt: &str| CatalogInput { + key: key.to_string(), + prompt: prompt.to_string(), + default: InputDefault::GeneratedSecret, + secret: true, + }; + match code { + "postgres" => vec![secret("POSTGRES_PASSWORD", "PostgreSQL password")], + "mysql" | "mariadb" => vec![ + secret("MYSQL_ROOT_PASSWORD", "MySQL root password"), + secret("MYSQL_PASSWORD", "MySQL application-user password"), + ], + "mongodb" => vec![secret( + "MONGO_INITDB_ROOT_PASSWORD", + "MongoDB root password", + )], + "rabbitmq" => vec![secret("RABBITMQ_DEFAULT_PASS", "RabbitMQ password")], + "minio" => vec![secret("MINIO_ROOT_PASSWORD", "MinIO root password")], + "wordpress" => vec![secret( + "WORDPRESS_DB_PASSWORD", + "WordPress database password", + )], + _ => vec![], + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ServiceCatalog // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -198,6 +251,23 @@ impl ServiceCatalog { // Hardcoded service catalog // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// Standard container healthcheck. The renderer emits `test:` as a YAML string, +/// which Docker Compose runs via `/bin/sh -c` (CMD-SHELL) — so `test` is a bare +/// shell command with no `CMD`/`CMD-SHELL` prefix. +/// +/// Only added for services whose probe tool is guaranteed to ship in the image +/// (`pg_isready`, `mysqladmin`, `mongosh`, `redis-cli`, `rabbitmq-diagnostics`). +/// Images without a guaranteed probe (e.g. `curl`/`wget` may be absent) are left +/// without a healthcheck rather than risk a permanently-unhealthy container. +fn service_healthcheck(test: &str) -> Option { + Some(ComposeHealthcheck { + test: test.to_string(), + interval: "10s".into(), + timeout: "5s".into(), + retries: 5, + }) +} + fn build_hardcoded_catalog() -> Vec { vec![ // ── Databases ──────────────────────────────────── @@ -213,12 +283,12 @@ fn build_hardcoded_catalog() -> Vec { environment: HashMap::from([ ("POSTGRES_DB".into(), "app_db".into()), ("POSTGRES_USER".into(), "app".into()), - ("POSTGRES_PASSWORD".into(), "changeme".into()), + ("POSTGRES_PASSWORD".into(), "${POSTGRES_PASSWORD}".into()), ]), volumes: vec!["postgres_data:/var/lib/postgresql/data".into()], depends_on: vec![], command: None, - healthcheck: None, + healthcheck: service_healthcheck("pg_isready -U app"), }, related: vec!["redis".into()], }, @@ -232,15 +302,18 @@ fn build_hardcoded_catalog() -> Vec { image: "mysql:8.0".into(), ports: vec!["3306:3306".into()], environment: HashMap::from([ - ("MYSQL_ROOT_PASSWORD".into(), "changeme_root".into()), + ( + "MYSQL_ROOT_PASSWORD".into(), + "${MYSQL_ROOT_PASSWORD}".into(), + ), ("MYSQL_DATABASE".into(), "app_db".into()), ("MYSQL_USER".into(), "app".into()), - ("MYSQL_PASSWORD".into(), "changeme".into()), + ("MYSQL_PASSWORD".into(), "${MYSQL_PASSWORD}".into()), ]), volumes: vec!["mysql_data:/var/lib/mysql".into()], depends_on: vec![], command: None, - healthcheck: None, + healthcheck: service_healthcheck("mysqladmin ping -h 127.0.0.1 --silent"), }, related: vec!["redis".into(), "phpmyadmin".into()], }, @@ -255,12 +328,17 @@ fn build_hardcoded_catalog() -> Vec { ports: vec!["27017:27017".into()], environment: HashMap::from([ ("MONGO_INITDB_ROOT_USERNAME".into(), "admin".into()), - ("MONGO_INITDB_ROOT_PASSWORD".into(), "changeme".into()), + ( + "MONGO_INITDB_ROOT_PASSWORD".into(), + "${MONGO_INITDB_ROOT_PASSWORD}".into(), + ), ]), volumes: vec!["mongo_data:/data/db".into()], depends_on: vec![], command: None, - healthcheck: None, + healthcheck: service_healthcheck( + "mongosh --quiet --eval 'db.adminCommand({ping:1})'", + ), }, related: vec![], }, @@ -278,7 +356,7 @@ fn build_hardcoded_catalog() -> Vec { volumes: vec!["redis_data:/data".into()], depends_on: vec![], command: None, - healthcheck: None, + healthcheck: service_healthcheck("redis-cli ping"), }, related: vec![], }, @@ -311,12 +389,15 @@ fn build_hardcoded_catalog() -> Vec { ports: vec!["5672:5672".into(), "15672:15672".into()], environment: HashMap::from([ ("RABBITMQ_DEFAULT_USER".into(), "app".into()), - ("RABBITMQ_DEFAULT_PASS".into(), "changeme".into()), + ( + "RABBITMQ_DEFAULT_PASS".into(), + "${RABBITMQ_DEFAULT_PASS}".into(), + ), ]), volumes: vec!["rabbitmq_data:/var/lib/rabbitmq".into()], depends_on: vec![], command: None, - healthcheck: None, + healthcheck: service_healthcheck("rabbitmq-diagnostics -q ping"), }, related: vec![], }, @@ -391,7 +472,10 @@ fn build_hardcoded_catalog() -> Vec { environment: HashMap::from([ ("WORDPRESS_DB_HOST".into(), "mysql".into()), ("WORDPRESS_DB_USER".into(), "wordpress".into()), - ("WORDPRESS_DB_PASSWORD".into(), "changeme".into()), + ( + "WORDPRESS_DB_PASSWORD".into(), + "${WORDPRESS_DB_PASSWORD}".into(), + ), ("WORDPRESS_DB_NAME".into(), "wordpress".into()), ]), volumes: vec!["wordpress_data:/var/www/html".into()], @@ -553,7 +637,10 @@ fn build_hardcoded_catalog() -> Vec { ports: vec!["9000:9000".into(), "9001:9001".into()], environment: HashMap::from([ ("MINIO_ROOT_USER".into(), "admin".into()), - ("MINIO_ROOT_PASSWORD".into(), "changeme123".into()), + ( + "MINIO_ROOT_PASSWORD".into(), + "${MINIO_ROOT_PASSWORD}".into(), + ), ]), volumes: vec!["minio_data:/data".into()], depends_on: vec![], @@ -683,6 +770,78 @@ mod tests { assert!(e.service.ports.contains(&"5432:5432".to_string())); } + #[test] + fn test_core_data_services_have_healthchecks() { + let cat = ServiceCatalog::offline(); + // Services whose probe tool ships in the image get a healthcheck. + for (code, needle) in [ + ("postgres", "pg_isready"), + ("mysql", "mysqladmin ping"), + ("mongodb", "mongosh"), + ("redis", "redis-cli ping"), + ("rabbitmq", "rabbitmq-diagnostics"), + ] { + let hc = cat + .lookup_hardcoded(code) + .unwrap_or_else(|| panic!("{code} exists")) + .service + .healthcheck + .clone() + .unwrap_or_else(|| panic!("{code} should have a healthcheck")); + assert!( + hc.test.contains(needle), + "{code} healthcheck test '{}' should contain '{needle}'", + hc.test + ); + assert_eq!(hc.retries, 5); + } + } + + #[test] + fn test_catalog_inputs_and_env_references() { + let cat = ServiceCatalog::offline(); + + // Secret placeholders are replaced with ${KEY} references, never literals. + let pg = cat.lookup_hardcoded("postgres").expect("postgres"); + assert_eq!( + pg.service + .environment + .get("POSTGRES_PASSWORD") + .map(String::as_str), + Some("${POSTGRES_PASSWORD}") + ); + // Non-secret values stay literal. + assert_eq!( + pg.service + .environment + .get("POSTGRES_USER") + .map(String::as_str), + Some("app") + ); + + // catalog_inputs describes the configurable keys as generated secrets. + let inputs = catalog_inputs("postgres"); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].key, "POSTGRES_PASSWORD"); + assert!(inputs[0].secret); + assert_eq!(inputs[0].default, InputDefault::GeneratedSecret); + + assert_eq!(catalog_inputs("mysql").len(), 2); + assert!(catalog_inputs("redis").is_empty()); + } + + #[test] + fn test_service_without_guaranteed_probe_has_no_healthcheck() { + let cat = ServiceCatalog::offline(); + // memcached ships no probe CLI, so it is intentionally left without one. + assert!(cat + .lookup_hardcoded("memcached") + .expect("memcached exists") + .service + .healthcheck + .is_none()); + } + #[test] fn test_lookup_hardcoded_smtp_companion() { let cat = ServiceCatalog::offline(); diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index f8464543..5eefa6de 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -247,6 +247,8 @@ pub struct MarketplaceTemplate { pub price: Option, pub billing_cycle: Option, pub is_from_marketplace: Option, + #[serde(default)] + pub creator_name: Option, pub stack_definition: Option, } @@ -256,6 +258,25 @@ pub struct MarketplaceInstallResponse { pub template: MarketplaceTemplate, pub latest_version: serde_json::Value, pub deployment_id: Option, + /// Populated only when the template is billed per_install. + #[serde(default)] + pub authorization: Option, + /// Populated only for per_install installs — server echoes the key it + /// actually used (may differ from what the client sent if the client + /// omitted one entirely). + #[serde(default)] + pub idempotency_key: Option, +} + +/// CLI-side mirror of the server's `AuthorizationSummary`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationSummary { + pub authorization_id: String, + pub status: String, + pub amount_minor: i64, + pub currency: String, + #[serde(default)] + pub expires_at: Option, } /// Marketplace template info as returned by `/api/templates/mine` @@ -1871,15 +1892,27 @@ impl StackerClient { } /// Create a Stacker project from a marketplace template. + /// + /// `idempotency_key` is threaded both as a body field and an + /// `Idempotency-Key` header — server accepts either but prefers the + /// header. For `per_install`-billed templates, retrying with the same + /// key collapses to the single authorization the first call created; + /// omitting it there is unsafe (a network blip after a successful + /// authorize would double-charge on retry). pub async fn install_marketplace_template( &self, slug: &str, name: Option<&str>, deploy_form: Option, install_inputs: Option>, + idempotency_key: &str, ) -> Result { let url = format!("{}/api/templates/{}/install", self.base_url, slug); - let mut body = serde_json::json!({ "name": name, "deploy": deploy_form }); + let mut body = serde_json::json!({ + "name": name, + "deploy": deploy_form, + "idempotency_key": idempotency_key, + }); if let Some(install_inputs) = install_inputs { if let Some(obj) = body.as_object_mut() { obj.insert( @@ -1892,6 +1925,7 @@ impl StackerClient { .http .post(&url) .bearer_auth(&self.token) + .header("Idempotency-Key", idempotency_key) .json(&body) .send() .await @@ -3937,7 +3971,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { .unwrap_or_else(|| "nbg1".to_string()); let server_size = cloud .and_then(|c| c.size.clone()) - .unwrap_or_else(|| "cpx11".to_string()); + .unwrap_or_else(|| "cx23".to_string()); let os = match provider.as_str() { "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) @@ -4310,7 +4344,7 @@ mod tests { provider: crate::cli::config_parser::CloudProvider::Hetzner, orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, region: Some("fsn1".to_string()), - size: Some("cpx11".to_string()), + size: Some("cx23".to_string()), install_image: None, remote_payload_file: None, ssh_key: None, @@ -4324,7 +4358,7 @@ mod tests { let form = build_deploy_form(&config); assert_eq!(form["cloud"]["provider"], "htz"); assert_eq!(form["server"]["region"], "fsn1"); - assert_eq!(form["server"]["server"], "cpx11"); + assert_eq!(form["server"]["server"], "cx23"); assert_eq!(form["stack"]["stack_code"], "myproject"); // Auto-generated server name should start with the project name let name = form["server"]["name"].as_str().unwrap(); diff --git a/src/configuration.rs b/src/configuration.rs index dcafc815..a02205c6 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -34,6 +34,12 @@ pub struct Settings { pub marketplace_assets: MarketplaceAssetSettings, #[serde(default)] pub payouts: PayoutSettings, + /// Global kill switch for the per-install billing model. + /// When false (the default), `billing_cycle="per_install"` templates + /// behave as `one_time` — no authorize/capture, no ownership check via + /// the new gate. See migration `20260718130000` + #[serde(default = "Settings::default_per_install_billing_enabled")] + pub per_install_billing_enabled: bool, } impl std::fmt::Debug for Settings { @@ -91,6 +97,7 @@ impl Default for Settings { deployment: DeploymentSettings::default(), marketplace_assets: MarketplaceAssetSettings::default(), payouts: PayoutSettings::default(), + per_install_billing_enabled: Self::default_per_install_billing_enabled(), } } } @@ -120,6 +127,10 @@ impl Settings { true } + fn default_per_install_billing_enabled() -> bool { + false + } + fn default_casbin_reload_interval_secs() -> u64 { 10 } diff --git a/src/connectors/errors.rs b/src/connectors/errors.rs index 6b521b5b..dfc0d738 100644 --- a/src/connectors/errors.rs +++ b/src/connectors/errors.rs @@ -19,6 +19,11 @@ pub enum ConnectorError { RateLimited(String), /// Internal error in connector Internal(String), + /// Payment required — the external service declined a billing operation + /// (e.g. no payment method, card declined). Surfaced as HTTP 402 upstream. + PaymentRequired(String), + /// Idempotency-key collision or state-transition conflict (HTTP 409). + Conflict(String), } impl fmt::Display for ConnectorError { @@ -31,6 +36,8 @@ impl fmt::Display for ConnectorError { Self::NotFound(msg) => write!(f, "Not found: {}", msg), Self::RateLimited(msg) => write!(f, "Rate limited: {}", msg), Self::Internal(msg) => write!(f, "Internal error: {}", msg), + Self::PaymentRequired(msg) => write!(f, "Payment required: {}", msg), + Self::Conflict(msg) => write!(f, "Conflict: {}", msg), } } } @@ -47,6 +54,8 @@ impl ResponseError for ConnectorError { Self::NotFound(_) => (StatusCode::NOT_FOUND, "Resource not found"), Self::RateLimited(_) => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + Self::PaymentRequired(_) => (StatusCode::PAYMENT_REQUIRED, "Payment required"), + Self::Conflict(_) => (StatusCode::CONFLICT, "Conflict"), }; HttpResponse::build(status).json(json!({ @@ -64,6 +73,8 @@ impl ResponseError for ConnectorError { Self::NotFound(_) => StatusCode::NOT_FOUND, Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED, + Self::Conflict(_) => StatusCode::CONFLICT, } } } diff --git a/src/connectors/user_service/app.rs b/src/connectors/user_service/app.rs index fb8be88c..d4971d5e 100644 --- a/src/connectors/user_service/app.rs +++ b/src/connectors/user_service/app.rs @@ -27,6 +27,36 @@ pub struct Application { /// Default config file templates from app_var (with attachment_path) #[serde(default)] pub default_config_files: Option, + /// Member services for a multi-service stack catalog entry (kind == "stack"). + /// Present only when the catalog code resolves to a stack rather than a + /// single app; each entry carries its own real container image. + #[serde(default)] + pub services: Option>, +} + +/// A single member service of a stack catalog entry (from /applications/catalog +/// for a stack code). Its `docker_image` is the member app's real container +/// image, never the stack's cloud OS image. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogService { + #[serde(rename = "_id", default)] + pub id: Option, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub role: Option, + #[serde(rename = "type", default)] + pub service_type: Option, + #[serde(default)] + pub docker_image: Option, + #[serde(default)] + pub default_ports: Option, + #[serde(default)] + pub default_env: Option, + #[serde(default)] + pub default_config_files: Option, } impl UserServiceClient { @@ -216,5 +246,6 @@ fn application_from_catalog(item: serde_json::Value) -> Option { default_env: None, default_ports: None, default_config_files: None, + services: None, }) } diff --git a/src/connectors/user_service/client.rs b/src/connectors/user_service/client.rs index adf54387..f394fe27 100644 --- a/src/connectors/user_service/client.rs +++ b/src/connectors/user_service/client.rs @@ -7,7 +7,8 @@ use uuid::Uuid; use super::connector::UserServiceConnector; use super::types::{ - CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProfile, + AuthorizationHandle, BillingCapability, CategoryInfo, PlanDefinition, ProductInfo, + StackResponse, UserPlanInfo, UserProfile, }; use super::utils::is_plan_higher_tier; @@ -635,6 +636,134 @@ impl UserServiceConnector for UserServiceClient { ) .await } + + async fn get_catalog_application( + &self, + user_token: &str, + code: &str, + ) -> Result, ConnectorError> { + let app = UserServiceClient::fetch_app_catalog(self, user_token, code).await?; + app.map(|app| serde_json::to_value(app)) + .transpose() + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + async fn can_charge(&self, user_token: &str) -> Result { + let url = format!("{}/api/1.0/marketplace/billing/can-charge", self.base_url); + let resp = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", user_token)) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.map_err(ConnectorError::from)?; + if !status.is_success() { + return Err(map_billing_error_status(status.as_u16(), &text)); + } + serde_json::from_str::(&text) + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + async fn authorize_install_charge( + &self, + user_token: &str, + template_id: &Uuid, + amount_minor: i64, + currency: &str, + idempotency_key: &str, + ) -> Result { + let url = format!("{}/api/1.0/marketplace/billing/authorize", self.base_url); + let payload = serde_json::json!({ + "template_id": template_id.to_string(), + "amount_minor": amount_minor, + "currency": currency, + "idempotency_key": idempotency_key, + }); + let resp = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", user_token)) + .header("Idempotency-Key", idempotency_key) + .json(&payload) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.map_err(ConnectorError::from)?; + if !status.is_success() { + return Err(map_billing_error_status(status.as_u16(), &text)); + } + serde_json::from_str::(&text) + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + async fn capture_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + deployment_hash: &str, + ) -> Result { + let url = format!("{}/api/1.0/marketplace/billing/capture", self.base_url); + let payload = serde_json::json!({ + "authorization_id": authorization_id, + "deployment_hash": deployment_hash, + }); + let resp = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", auth_token)) + .json(&payload) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.map_err(ConnectorError::from)?; + if !status.is_success() { + return Err(map_billing_error_status(status.as_u16(), &text)); + } + serde_json::from_str::(&text) + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + async fn void_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + reason: &str, + ) -> Result<(), ConnectorError> { + let url = format!("{}/api/1.0/marketplace/billing/void", self.base_url); + let payload = serde_json::json!({ + "authorization_id": authorization_id, + "reason": reason, + }); + let resp = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", auth_token)) + .json(&payload) + .send() + .await?; + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + let text = resp.text().await.unwrap_or_default(); + Err(map_billing_error_status(status.as_u16(), &text)) + } +} + +/// Map a non-2xx billing response to the appropriate ConnectorError. +/// Split out so all four billing methods share the same error semantics. +fn map_billing_error_status(status: u16, body: &str) -> ConnectorError { + let body = body.to_string(); + match status { + 402 => ConnectorError::PaymentRequired(body), + 409 => ConnectorError::Conflict(body), + 401 | 403 => ConnectorError::Unauthorized(body), + 404 => ConnectorError::NotFound(body), + 429 => ConnectorError::RateLimited(body), + 500..=599 => ConnectorError::ServiceUnavailable(body), + _ => ConnectorError::HttpError(format!("billing http {}: {}", status, body)), + } } /// Parse the `/oauth_server/api/me` response text and extract the user's plan name. diff --git a/src/connectors/user_service/connector.rs b/src/connectors/user_service/connector.rs index 3d23eb82..f66a1629 100644 --- a/src/connectors/user_service/connector.rs +++ b/src/connectors/user_service/connector.rs @@ -1,7 +1,8 @@ use uuid::Uuid; use super::types::{ - CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProfile, + AuthorizationHandle, BillingCapability, CategoryInfo, PlanDefinition, ProductInfo, + StackResponse, UserPlanInfo, UserProfile, }; use crate::connectors::errors::ConnectorError; @@ -79,4 +80,63 @@ pub trait UserServiceConnector: Send + Sync { page: Option, max_results: Option, ) -> Result, ConnectorError>; + + /// Fetch a single catalog application by code, enriched with its docker + /// image and default env/ports from the app catalog. Unlike the search + /// endpoint, this carries the fields needed to synthesize a compose. + /// Returns `None` when the catalog has no such application. + async fn get_catalog_application( + &self, + user_token: &str, + code: &str, + ) -> Result, ConnectorError>; + + // ── Per-install billing (two-phase charge) ───────────────────── + // + // The four methods below implement the authorize/capture/void + // dance used when a template's billing_cycle is "per_install". + // user_service is authoritative on payment intents and idempotency; + // stacker holds only the opaque `authorization_id` and a local + // `marketplace_install_authorization` row for reconciliation. + + /// Cheap capability probe used inside the access gate before + /// deciding whether to attempt an authorize. Returns + /// `can_charge=false` with a reason when the user has no payment + /// method on file, an expired card, etc. + async fn can_charge(&self, user_token: &str) -> Result; + + /// Reserve funds for a single install. Must be idempotent on + /// `idempotency_key` — replay with the same key returns the same + /// handle. Returns `PaymentRequired` on decline, `Conflict` on + /// idempotency-body mismatch. + async fn authorize_install_charge( + &self, + user_token: &str, + template_id: &Uuid, + amount_minor: i64, + currency: &str, + idempotency_key: &str, + ) -> Result; + + /// Settle a previously-authorized charge after the install + /// completes successfully. `auth_token` may be a user token or a + /// service token depending on caller — deploy-complete uses the + /// service token. Attaches `deployment_hash` for user_service-side + /// audit. + async fn capture_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + deployment_hash: &str, + ) -> Result; + + /// Release a previously-authorized charge. Called on install + /// failure or by the TTL sweeper. `auth_token` semantics identical + /// to `capture_install_charge`. + async fn void_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + reason: &str, + ) -> Result<(), ConnectorError>; } diff --git a/src/connectors/user_service/marketplace_webhook.rs b/src/connectors/user_service/marketplace_webhook.rs index 4924bf59..5d9ed5a7 100644 --- a/src/connectors/user_service/marketplace_webhook.rs +++ b/src/connectors/user_service/marketplace_webhook.rs @@ -16,7 +16,7 @@ use crate::connectors::ConnectorError; use crate::models; /// Marketplace webhook payload sent to User Service -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MarketplaceWebhookPayload { /// Action type for the marketplace sync webhook. pub action: String, @@ -104,6 +104,38 @@ pub struct MarketplaceWebhookPayload { /// Creator email when available. #[serde(skip_serializing_if = "Option::is_none")] pub vendor_email: Option, + + /// Full stack/compose definition from the latest template version. This is + /// the field the User Service caches so `/applications` can serve a + /// deployable definition (install-service Flow 4). Present on + /// approved/published actions; `None` for metadata-only actions. + #[serde(skip_serializing_if = "Option::is_none")] + pub stack_definition: Option, + + /// How to interpret `stack_definition` — `"yaml"` (compose string) or a + /// legacy/JSON object form (`None`). + #[serde(skip_serializing_if = "Option::is_none")] + pub definition_format: Option, + + /// Extra config files shipped with the template version (name + content), + /// when the version defines any. + #[serde(skip_serializing_if = "Option::is_none")] + pub config_files: Option, + + /// Version string of the federated definition. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Returns the JSON value only when it carries content — filters out `null`, +/// `[]`, and `{}` so empty `config_files` don't bloat the webhook. +fn non_empty_json(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::Array(a) if a.is_empty() => None, + serde_json::Value::Object(o) if o.is_empty() => None, + other => Some(other.clone()), + } } /// Response from User Service webhook endpoint @@ -188,6 +220,7 @@ impl MarketplaceWebhookSender { template: &models::marketplace::StackTemplate, vendor_id: &str, category_code: Option, + latest_version: Option<&models::marketplace::StackTemplateVersion>, ) -> Result { let span = tracing::info_span!( "send_template_approved_webhook", @@ -197,6 +230,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_approved".to_string(), + stack_definition: latest_version.map(|v| v.stack_definition.clone()), + definition_format: latest_version.and_then(|v| v.definition_format.clone()), + config_files: latest_version.and_then(|v| non_empty_json(&v.config_files)), + version: latest_version.map(|v| v.version.clone()), stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -249,6 +286,7 @@ impl MarketplaceWebhookSender { template: &models::marketplace::StackTemplate, vendor_id: &str, category_code: Option, + latest_version: Option<&models::marketplace::StackTemplateVersion>, ) -> Result { let span = tracing::info_span!( "send_template_published_webhook", @@ -258,6 +296,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_published".to_string(), + stack_definition: latest_version.map(|v| v.stack_definition.clone()), + definition_format: latest_version.and_then(|v| v.definition_format.clone()), + config_files: latest_version.and_then(|v| non_empty_json(&v.config_files)), + version: latest_version.map(|v| v.version.clone()), stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -318,6 +360,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_updated".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -379,6 +425,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_submitted".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -440,6 +490,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_needs_changes".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -501,6 +555,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_review_rejected".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -562,6 +620,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_rejected".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: stack_template_id.to_string(), external_id: stack_template_id.to_string(), code: None, @@ -605,6 +667,10 @@ impl MarketplaceWebhookSender { let payload = MarketplaceWebhookPayload { action: "template_unpublished".to_string(), + stack_definition: None, + definition_format: None, + config_files: None, + version: None, stack_template_id: template.id.to_string(), external_id: template.id.to_string(), code: Some(template.slug.clone()), @@ -774,6 +840,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; let json = serde_json::to_string(&payload).expect("Failed to serialize"); @@ -813,6 +880,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; let json = serde_json::to_string(&payload).expect("Failed to serialize"); @@ -848,6 +916,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; assert_eq!(payload.action, "template_approved"); @@ -883,6 +952,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; assert_eq!(payload.action, "template_updated"); @@ -918,6 +988,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; assert_eq!(payload.action, "template_approved"); @@ -1017,6 +1088,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; // Verify all fields are accessible @@ -1054,6 +1126,7 @@ mod tests { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; // Should serialize without errors even with all optional fields as None @@ -1061,4 +1134,155 @@ mod tests { assert!(json.contains("template_rejected")); assert!(json.contains("external_id")); } + + #[test] + fn non_empty_json_filters_empty_values() { + assert!(non_empty_json(&serde_json::json!(null)).is_none()); + assert!(non_empty_json(&serde_json::json!([])).is_none()); + assert!(non_empty_json(&serde_json::json!({})).is_none()); + assert!(non_empty_json(&serde_json::json!([{ "name": "docker-compose.yml" }])).is_some()); + assert!(non_empty_json(&serde_json::json!({ "k": "v" })).is_some()); + } + + #[test] + fn payload_omits_definition_fields_when_none() { + let payload = MarketplaceWebhookPayload { + action: "template_updated".to_string(), + stack_template_id: "id".to_string(), + external_id: "id".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&payload).unwrap(); + assert!(json.get("stack_definition").is_none()); + assert!(json.get("definition_format").is_none()); + assert!(json.get("config_files").is_none()); + assert!(json.get("version").is_none()); + } + + #[test] + fn payload_includes_definition_fields_when_present() { + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: "id".to_string(), + external_id: "id".to_string(), + stack_definition: Some(serde_json::json!("services:\n app: {}")), + definition_format: Some("yaml".to_string()), + version: Some("2.0.0".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["stack_definition"], "services:\n app: {}"); + assert_eq!(json["definition_format"], "yaml"); + assert_eq!(json["version"], "2.0.0"); + } + + /// End-to-end: `send_template_published` must POST the federated + /// `stack_definition` (compose) to `/marketplace/sync` so the User Service + /// can cache a deployable definition. + #[tokio::test] + async fn send_template_published_federates_stack_definition() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/marketplace/sync")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true, + "message": "ok", + "product_id": "p1" + }))) + .mount(&server) + .await; + + let sender = MarketplaceWebhookSender::new(WebhookSenderConfig { + base_url: server.uri(), + bearer_token: "test-token".to_string(), + timeout_secs: 5, + retry_attempts: 1, + }); + + let template = models::marketplace::StackTemplate { + id: uuid::Uuid::new_v4(), + slug: "n8n".to_string(), + name: "n8n".to_string(), + creator_user_id: "vendor-1".to_string(), + ..Default::default() + }; + let version = models::marketplace::StackTemplateVersion { + template_id: template.id, + version: "1.0.0".to_string(), + stack_definition: serde_json::json!( + "version: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest" + ), + definition_format: Some("yaml".to_string()), + // Empty config_files must be filtered out of the webhook body. + config_files: serde_json::json!([]), + ..Default::default() + }; + + let resp = sender + .send_template_published(&template, "vendor-1", None, Some(&version)) + .await + .expect("webhook should succeed"); + assert!(resp.success); + + let requests = server + .received_requests() + .await + .expect("requests should be recorded"); + assert_eq!(requests.len(), 1); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + + assert_eq!(body["action"], "template_published"); + assert_eq!(body["definition_format"], "yaml"); + assert!(body["stack_definition"] + .as_str() + .unwrap() + .contains("n8nio/n8n:latest")); + assert_eq!(body["version"], "1.0.0"); + // Empty config_files omitted. + assert!(body.get("config_files").is_none()); + } + + /// Metadata-only actions must NOT carry a definition. + #[tokio::test] + async fn send_template_updated_does_not_federate_definition() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/marketplace/sync")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let sender = MarketplaceWebhookSender::new(WebhookSenderConfig { + base_url: server.uri(), + bearer_token: "t".to_string(), + timeout_secs: 5, + retry_attempts: 1, + }); + + let template = models::marketplace::StackTemplate { + id: uuid::Uuid::new_v4(), + slug: "n8n".to_string(), + name: "n8n".to_string(), + creator_user_id: "vendor-1".to_string(), + ..Default::default() + }; + + sender + .send_template_updated(&template, "vendor-1", None) + .await + .expect("webhook should succeed"); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["action"], "template_updated"); + assert!(body.get("stack_definition").is_none()); + } } diff --git a/src/connectors/user_service/mock.rs b/src/connectors/user_service/mock.rs index b0be40b8..09a07853 100644 --- a/src/connectors/user_service/mock.rs +++ b/src/connectors/user_service/mock.rs @@ -3,8 +3,8 @@ use uuid::Uuid; use crate::connectors::errors::ConnectorError; use super::{ - CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProduct, - UserProfile, UserServiceConnector, + AuthorizationHandle, BillingCapability, CategoryInfo, PlanDefinition, ProductInfo, + StackResponse, UserPlanInfo, UserProduct, UserProfile, UserServiceConnector, }; /// Mock User Service for testing - always succeeds @@ -242,4 +242,60 @@ impl UserServiceConnector for MockUserServiceConnector { Ok(items) } + + async fn get_catalog_application( + &self, + _user_token: &str, + _code: &str, + ) -> Result, ConnectorError> { + Ok(None) + } + + async fn can_charge(&self, _user_token: &str) -> Result { + Ok(BillingCapability { + can_charge: true, + reason: None, + }) + } + + async fn authorize_install_charge( + &self, + _user_token: &str, + _template_id: &Uuid, + amount_minor: i64, + currency: &str, + idempotency_key: &str, + ) -> Result { + Ok(AuthorizationHandle { + authorization_id: format!("mock-auth-{}", idempotency_key), + amount_minor, + currency: currency.to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), + status: "authorized".to_string(), + }) + } + + async fn capture_install_charge( + &self, + _auth_token: &str, + authorization_id: &str, + _deployment_hash: &str, + ) -> Result { + Ok(AuthorizationHandle { + authorization_id: authorization_id.to_string(), + amount_minor: 0, + currency: "USD".to_string(), + expires_at: None, + status: "captured".to_string(), + }) + } + + async fn void_install_charge( + &self, + _auth_token: &str, + _authorization_id: &str, + _reason: &str, + ) -> Result<(), ConnectorError> { + Ok(()) + } } diff --git a/src/connectors/user_service/mod.rs b/src/connectors/user_service/mod.rs index 73d2d1d8..02e7cc54 100644 --- a/src/connectors/user_service/mod.rs +++ b/src/connectors/user_service/mod.rs @@ -26,8 +26,8 @@ pub use marketplace_webhook::{ }; pub use mock::MockUserServiceConnector; pub use types::{ - CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProduct, - UserProfile, + AuthorizationHandle, BillingCapability, CategoryInfo, PlanDefinition, ProductInfo, + StackResponse, UserPlanInfo, UserProduct, UserProfile, }; #[cfg(test)] diff --git a/src/connectors/user_service/stack.rs b/src/connectors/user_service/stack.rs index 484df048..aa72540b 100644 --- a/src/connectors/user_service/stack.rs +++ b/src/connectors/user_service/stack.rs @@ -125,18 +125,11 @@ pub(crate) fn application_from_stack_view(item: StackViewItem) -> Application { .or_else(|| value.get("category")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let docker_image = value - .get("image") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| { - value - .get("images") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); + // IMPORTANT: `value["image"]`/`value["images"]` on a stack_view row are the + // cloud/server OS image (e.g. "docker-ce"), NOT a container image, so they + // must never be used as docker_image. Resolve the real image from the member + // apps' dockerhub_* fields instead (mirrors the User Service fix). + let docker_image = resolve_stack_view_docker_image(&value); let default_port = value .get("ports") .and_then(|v| v.as_array()) @@ -160,5 +153,57 @@ pub(crate) fn application_from_stack_view(item: StackViewItem) -> Application { default_env: None, default_ports: None, default_config_files: None, + services: None, + } +} + +/// Build a container image string for a single stack member app from its +/// `dockerhub_*` fields (defaults the org to "trydirect"), mirroring the User +/// Service `_build_docker_image` helper. Returns None when no image is set. +fn member_docker_image(member: &serde_json::Value) -> Option { + let image = member + .get("dockerhub_image") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty())?; + let name = member + .get("dockerhub_name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("trydirect"); + if let Some(repo) = member + .get("dockerhub_repo") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(format!("{name}/{repo}")) + } else { + Some(format!("{name}/{image}")) + } +} + +/// Resolve a container image for a stack_view row without ever falling back to +/// the cloud OS `image` column. Multi-app stacks have no single image (-> None); +/// a single-member entry resolves that member's real image. +fn resolve_stack_view_docker_image(value: &serde_json::Value) -> Option { + if value + .get("is_stack") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return None; + } + let members: Vec<&serde_json::Value> = ["apps", "services", "features"] + .iter() + .filter_map(|k| value.get(*k)) + .filter_map(|v| v.as_array()) + .flatten() + .collect(); + if members.len() == 1 { + member_docker_image(members[0]) + } else { + None } } diff --git a/src/connectors/user_service/types.rs b/src/connectors/user_service/types.rs index 530ae11f..b757dbbf 100644 --- a/src/connectors/user_service/types.rs +++ b/src/connectors/user_service/types.rs @@ -86,3 +86,29 @@ pub struct CategoryInfo { #[serde(default)] pub priority: Option, } + +// ── Per-install billing ───────────────────────────────── +// +// Opaque handle to a two-phase charge held by user_service. Stacker never +// looks inside `authorization_id` — user_service is authoritative on the +// underlying payment intent, refund history, and expiry. + +/// Authorization handle returned from `/marketplace/billing/authorize`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationHandle { + pub authorization_id: String, + pub amount_minor: i64, + pub currency: String, + #[serde(default)] + pub expires_at: Option, + pub status: String, // "authorized" | "captured" | "voided" +} + +/// Result of `/marketplace/billing/can-charge` — cheap pre-authorize probe +/// used inside the marketplace access gate. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BillingCapability { + pub can_charge: bool, + #[serde(default)] + pub reason: Option, +} diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index be106199..9f86331a 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -1046,6 +1046,7 @@ fn local_config_files_for_agent_deploy( &configured_compose_path, config.env_file.as_deref(), &reference_base, + false, )?; if materialize_stacker_service_in_bundle(&mut bundle, &config, app_code)? { result.notices.push(format!( @@ -1065,6 +1066,7 @@ fn local_config_files_for_agent_deploy( compose_path, None, &app_reference_base, + false, )?; let project_compose = std::fs::read_to_string(&configured_compose_path).map_err(|err| { CliError::ConfigValidation(format!( diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index e50bbe3f..7e068404 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -658,7 +658,10 @@ pub fn run_setup_ai( ]) } -pub fn run_setup_cloud_interactive(config_path: &str) -> Result, CliError> { +pub fn run_setup_cloud_interactive( + config_path: &str, + available_clouds: Option<&[crate::cli::stacker_client::CloudInfo]>, +) -> Result, CliError> { let path = Path::new(config_path); if !path.exists() { return Err(CliError::ConfigNotFound { @@ -728,6 +731,31 @@ pub fn run_setup_cloud_interactive(config_path: &str) -> Result, Cli apply_cloud_settings(&mut config, provider, region_opt, size_opt, ssh_key_opt); + // Prompt for saved cloud credential name (deploy.cloud.key). + // If available_clouds was provided, show the list of available credentials. + if let Some(clouds) = available_clouds { + if !clouds.is_empty() { + let names: Vec<&str> = clouds.iter().map(|c| c.name.as_str()).collect(); + eprintln!("Available cloud credentials: {}", names.join(", ")); + } + } + let key_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.key.clone()) + .unwrap_or_default(); + let key_input = prompt_with_default( + "Cloud credential name (from `stacker list clouds`, leave empty to skip)", + &key_default, + )?; + if !key_input.trim().is_empty() { + if let Some(cloud) = config.deploy.cloud.as_mut() { + cloud.key = Some(key_input.trim().to_string()); + applied.push(format!("Set deploy.cloud.key={}", key_input.trim())); + } + } + let backup_path = format!("{}.bak", config_path); std::fs::copy(config_path, &backup_path)?; @@ -1356,7 +1384,15 @@ impl ConfigSetupCloudCommand { impl CallableTrait for ConfigSetupCloudCommand { fn call(&self) -> Result<(), Box> { let path = resolve_config_path(&self.file); - let applied = run_setup_cloud_interactive(&path)?; + // Try to create a runtime to fetch available cloud credentials. + // Non-fatal if the user isn't logged in — the wizard will still work, + // just without showing the list of saved credentials. + let clouds: Option> = + crate::cli::runtime::CliRuntime::new("config setup cloud") + .ok() + .and_then(|ctx| ctx.block_on(async { ctx.client.list_clouds().await }).ok()); + let clouds_ref = clouds.as_deref(); + let applied = run_setup_cloud_interactive(&path, clouds_ref)?; eprintln!("✓ Updated {}", path); for item in applied { diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index 9e400af1..12dc2784 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -3282,12 +3282,15 @@ fn run_deploy_with_credentials_manager( } else { project_dir.to_path_buf() }; + // Cloud/server deploys always run the status-panel agent, so put project + // services on the agent's shared network for reachability. let mut bundle = build_config_bundle( project_dir, bundle_env, &compose_path, config.env_file.as_deref(), &reference_base, + true, )?; bundle.synthesized_environment = synthesized_environment; eprintln!(" Config bundle: {}", bundle.archive_path.display()); diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index acd2d90f..7c4e33df 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -94,7 +94,7 @@ pub fn full_config_reference_example() -> &'static str { # # orchestrator: local | remote # orchestrator: "remote" # region: "nbg1" -# size: "cpx11" +# size: "cx23" # # install_image: "trydirect/install-service:latest" # local orchestrator only # # remote_payload_file: "./stacker.remote.deploy.json" # remote orchestrator request payload # # ssh_key: "~/.ssh/id_rsa" @@ -307,12 +307,12 @@ fn default_cloud_region(provider: &str) -> &'static str { fn default_cloud_size(provider: &str) -> &'static str { match provider { - "hetzner" => "cpx11", + "hetzner" => "cx23", "digitalocean" => "s-1vcpu-1gb", "linode" => "g6-nanode-1", "vultr" => "vc2-1c-1gb", "aws" => "t3.micro", - _ => "cpx11", + _ => "cx23", } } @@ -1659,6 +1659,8 @@ fn convert_compose_to_stacker(ai_output: &str, repo_name: &str) -> Option, set_values: Vec, + key_name: Option, + key_id: Option, + region: Option, + size: Option, + provider: Option, } impl MarketplaceInstallCommand { @@ -184,6 +187,11 @@ impl MarketplaceInstallCommand { json: bool, domain: Option, set_values: Vec, + key_name: Option, + key_id: Option, + region: Option, + size: Option, + provider: Option, ) -> Self { Self { template, @@ -193,6 +201,71 @@ impl MarketplaceInstallCommand { json, domain, set_values, + key_name, + key_id, + region, + size, + provider, + } + } + + /// Apply CLI overrides (--key, --key-id, --provider, --region, --size) to + /// the deploy form, regardless of whether the config was read from an + /// existing stacker.yml or auto-generated. + fn apply_cloud_overrides(&self, form: &mut serde_json::Value, ctx: &CliRuntime) { + // Resolve cloud credential name from --key or --key-id + let resolved_key_name: Option = if let Some(id) = self.key_id { + ctx.block_on(async { ctx.client.list_clouds().await }) + .ok() + .and_then(|cs| cs.into_iter().find(|c| c.id == id)) + .map(|c| c.name) + } else { + self.key_name.clone().filter(|v| !v.trim().is_empty()) + }; + + // Apply cloud credential name + if let Some(name) = resolved_key_name { + if let Some(cloud) = form.get_mut("cloud").and_then(|v| v.as_object_mut()) { + cloud.insert("name".into(), serde_json::json!(name)); + cloud.insert("save_token".into(), serde_json::json!(false)); + } + } + + // Apply provider override + if let Some(provider) = self + .provider + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + if let Some(cloud) = form.get_mut("cloud").and_then(|v| v.as_object_mut()) { + let provider_code = crate::cli::install_runner::provider_code_for_remote(provider); + cloud.insert("provider".into(), serde_json::json!(provider_code)); + } + } + + // Apply region override + if let Some(region) = self + .region + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + if let Some(server) = form.get_mut("server").and_then(|v| v.as_object_mut()) { + server.insert("region".into(), serde_json::json!(region)); + } + } + + // Apply size override + if let Some(size) = self + .size + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + if let Some(server) = form.get_mut("server").and_then(|v| v.as_object_mut()) { + server.insert("server".into(), serde_json::json!(size)); + } } } } @@ -252,6 +325,7 @@ impl CallableTrait for MarketplaceInstallCommand { serde_json::Value::String(self.template.clone()), ); } + self.apply_cloud_overrides(&mut form, &ctx); (Some(form), config.install.inputs, false) } else { // No local stacker.yml. If this is a catalog application, we cannot @@ -276,6 +350,11 @@ impl CallableTrait for MarketplaceInstallCommand { &self.template, self.name.as_deref(), self.domain.as_deref(), + self.key_name.as_deref(), + self.key_id, + self.region.as_deref(), + self.size.as_deref(), + self.provider.as_deref(), ) .await })?; @@ -293,19 +372,7 @@ impl CallableTrait for MarketplaceInstallCommand { serde_json::Value::String(self.template.clone()), ); } - if let Some(cloud) = config.deploy.cloud.as_ref() { - if let Some(key) = cloud.key.as_deref() { - if let Some(form_cloud) = form - .get_mut("cloud") - .and_then(|value| value.as_object_mut()) - { - form_cloud.insert( - "name".to_string(), - serde_json::Value::String(key.to_string()), - ); - } - } - } + self.apply_cloud_overrides(&mut form, &ctx); (Some(form), config.install.inputs, true) } else { (None, Default::default(), false) @@ -377,6 +444,15 @@ impl CallableTrait for MarketplaceInstallCommand { ); } + // Idempotency key for the install request. Respect an env override so + // CI can pin it explicitly (a re-run with the same key collapses to + // the original authorization for per_install-billed templates). + // Otherwise generate a fresh one per invocation. + let idempotency_key = std::env::var("STACKER_INSTALL_IDEMPOTENCY_KEY") + .ok() + .filter(|k| !k.trim().is_empty()) + .unwrap_or_else(|| format!("cli-{}", uuid::Uuid::new_v4())); + let response = ctx.block_on(async { ctx.client .install_marketplace_template( @@ -384,6 +460,7 @@ impl CallableTrait for MarketplaceInstallCommand { self.name.as_deref(), deploy_form, install_inputs, + &idempotency_key, ) .await })?; @@ -398,7 +475,7 @@ impl CallableTrait for MarketplaceInstallCommand { "Installed '{}' as project #{} and started deployment #{}.", response.template.slug, response.project.id, deployment_id ); - println!("Track with: stacker deployments state {}", deployment_id); + println!("Track with: stacker deployment state"); return Ok(()); } @@ -681,6 +758,11 @@ async fn generate_minimal_install_config( template: &str, name: Option<&str>, domain: Option<&str>, + key_name: Option<&str>, + key_id: Option, + region_override: Option<&str>, + size_override: Option<&str>, + provider_override: Option<&str>, ) -> Result { use crate::cli::config_parser::{CloudConfig, DeployTarget}; @@ -703,27 +785,57 @@ async fn generate_minimal_install_config( // the user to connect a cloud provider first instead of silently // falling back to local. let clouds = client.list_clouds().await?; - let cloud_info = clouds.into_iter().next().ok_or_else(|| { - CliError::ConfigValidation( - "No cloud connections found. Connect one with `stacker config cloud add` (or `stacker login`) and retry.".to_string(), - ) - })?; - let provider = cloud_provider_from_code(&cloud_info.provider).ok_or_else(|| { - CliError::ConfigValidation(format!( - "Unsupported cloud provider '{}' on default cloud '{}'.", - cloud_info.provider, cloud_info.name - )) + + // If --key or --key-id was provided, find the matching cloud credential. + // Otherwise fall back to the first available credential. + let cloud_info = if let Some(id) = key_id { + clouds.into_iter().find(|c| c.id == id).ok_or_else(|| { + CliError::ConfigValidation(format!( + "No cloud connection found with ID '{}'. See `stacker list clouds`.", + id + )) + })? + } else if let Some(name) = key_name.map(str::trim).filter(|v| !v.is_empty()) { + clouds + .into_iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "No cloud connection found with name '{}'. See `stacker list clouds`.", + name + )) + })? + } else { + clouds.into_iter().next().ok_or_else(|| { + CliError::ConfigValidation( + "No cloud connections found. Connect one with `stacker config cloud add` (or `stacker login`) and retry.".to_string(), + ) + })? + }; + // Resolve provider: --provider flag overrides the credential's provider + let provider_code = provider_override + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or(&cloud_info.provider); + let provider = cloud_provider_from_code(provider_code).ok_or_else(|| { + CliError::ConfigValidation(format!("Unsupported cloud provider '{}'.", provider_code)) })?; eprintln!( "Using cloud connection '{}' (provider: {}).", - cloud_info.name, cloud_info.provider + cloud_info.name, provider_code ); let cloud_config = CloudConfig { provider, orchestrator: Default::default(), - region: None, - size: None, + region: region_override + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(String::from), + size: size_override + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(String::from), install_image: None, remote_payload_file: None, ssh_key: None, @@ -732,6 +844,13 @@ async fn generate_minimal_install_config( public_ports: Vec::new(), }; + if region_override.is_some() { + eprintln!("Using region '{}'.", region_override.unwrap()); + } + if size_override.is_some() { + eprintln!("Using server size '{}'.", size_override.unwrap()); + } + // Auto-generated stacker.yml never reuses an existing server. A fresh // server is always provisioned on the resolved cloud. To reuse an // existing one, set `deploy.cloud.server: ` in stacker.yml. @@ -1017,16 +1136,13 @@ impl CallableTrait for MarketplaceLogsCommand { // ── helpers ────────────────────────────────────────── /// Format the plan/price column for display. -/// Shows `required_plan_name` if set, otherwise formats `price` with currency, -/// or "free" if neither indicates a paid template. +/// +/// Mirrors the server-side `is_free` calculation in +/// `services::marketplace_access` so the list column never disagrees with the +/// install-time access check. A template is "free" only when `price` is +/// zero/absent AND `required_plan_name` is absent/empty/"free"; anything else +/// requires payment or a plan tier and must be displayed as such. fn display_plan(template: &MarketplaceTemplate) -> String { - if let Some(plan) = template - .required_plan_name - .as_deref() - .filter(|p| !p.is_empty()) - { - return plan.to_string(); - } if let Some(price) = template.price { if price > 0.0 { let cycle = template.billing_cycle.as_deref().unwrap_or("/mo"); @@ -1034,11 +1150,20 @@ fn display_plan(template: &MarketplaceTemplate) -> String { "one_time" | "one-time" | "once" | "free" => "", "monthly" | "month" | "/mo" => "/mo", "yearly" | "year" | "/yr" => "/yr", + "per_install" | "per-install" => "/install", other => other, }; return format!("${:.2}{}", price, cycle); } } + if let Some(plan) = template + .required_plan_name + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("free")) + { + return plan.to_string(); + } "free".to_string() } @@ -1055,7 +1180,7 @@ fn truncate(s: &str, max_len: usize) -> String { #[cfg(test)] mod tests { use super::{ - apply_install_inputs_to_deploy_form, marketplace_template_matches_slug, + apply_install_inputs_to_deploy_form, display_plan, marketplace_template_matches_slug, resolve_install_inputs, response_stack_definition, }; use crate::cli::stacker_client::{ @@ -1196,6 +1321,8 @@ mod tests { template: marketplace_template("dify"), latest_version, deployment_id: None, + authorization: None, + idempotency_key: None, } } @@ -1212,7 +1339,71 @@ mod tests { price: None, billing_cycle: None, is_from_marketplace: None, + creator_name: None, stack_definition: None, } } + + // display_plan must mirror the server's `is_free` calculation in + // services::marketplace_access — showing "free" only when both `price` + // and `required_plan_name` indicate a free template. Otherwise the CLI + // listing lies to users and `stacker install` rejects with "must + // purchase this template before installing it". + #[test] + fn display_plan_shows_price_when_plan_is_free_but_price_is_positive() { + // Real production row for `umami`: price=10.0, plan="free" (any + // plan tier accepted, but still a $10 one-time purchase). + let mut t = marketplace_template("umami"); + t.price = Some(10.0); + t.required_plan_name = Some("free".to_string()); + t.billing_cycle = Some("one_time".to_string()); + + assert_eq!(display_plan(&t), "$10.00"); + } + + #[test] + fn display_plan_treats_empty_and_free_plan_names_as_free() { + let mut t = marketplace_template("x"); + assert_eq!(display_plan(&t), "free"); + + t.required_plan_name = Some("".to_string()); + assert_eq!(display_plan(&t), "free"); + + t.required_plan_name = Some(" ".to_string()); + assert_eq!(display_plan(&t), "free"); + + t.required_plan_name = Some("FREE".to_string()); + assert_eq!(display_plan(&t), "free"); + } + + #[test] + fn display_plan_shows_non_free_plan_when_price_is_zero_or_missing() { + let mut t = marketplace_template("x"); + t.required_plan_name = Some("professional".to_string()); + assert_eq!(display_plan(&t), "professional"); + + t.price = Some(0.0); + assert_eq!(display_plan(&t), "professional"); + } + + #[test] + fn display_plan_formats_price_with_billing_cycle() { + let mut t = marketplace_template("x"); + t.price = Some(5.0); + + t.billing_cycle = Some("monthly".to_string()); + assert_eq!(display_plan(&t), "$5.00/mo"); + + t.billing_cycle = Some("yearly".to_string()); + assert_eq!(display_plan(&t), "$5.00/yr"); + + t.billing_cycle = Some("one_time".to_string()); + assert_eq!(display_plan(&t), "$5.00"); + + t.billing_cycle = Some("per_install".to_string()); + assert_eq!(display_plan(&t), "$5.00/install"); + + t.billing_cycle = None; + assert_eq!(display_plan(&t), "$5.00/mo"); + } } diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 07aa357c..239b1e94 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -2653,6 +2653,44 @@ fn run_remote_probe( ctx: &CliRuntime, request: &PipeDiscoveryRequest, description: &str, +) -> Result> { + let info = run_remote_probe_once(ctx, request, description)?; + if info.status != "completed" { + return Ok(info); + } + + // Fallback: an app-scoped (`remote_app`) probe reaches only the app's + // published host port. When it resolves a container but finds nothing, + // retry directly against that container — the `direct_container` scope + // reaches the container's internal IP and discovers forms / REST endpoints + // the host-port probe misses (see the WordPress→Matomo pipe write-up). + if request.selector.container.is_none() { + if let Ok(report) = decode_probe_report(&info) { + if !report_has_findings(&report) { + if let Some(name) = pick_fallback_container(&report, &request.selector.selector) { + eprintln!( + " No endpoints via the app's published port; retrying container '{}' directly…", + name + ); + let mut retry = request.clone(); + retry.selector.container = Some(name.clone()); + let retry_desc = format!("{} (container {} — direct probe)", description, name); + let retry_info = run_remote_probe_once(ctx, &retry, &retry_desc)?; + if agent_info_has_findings(&retry_info) { + return Ok(retry_info); + } + } + } + } + } + + Ok(info) +} + +fn run_remote_probe_once( + ctx: &CliRuntime, + request: &PipeDiscoveryRequest, + description: &str, ) -> Result> { let deployment_hash = request.selector.deployment_hash.as_deref().ok_or_else(|| { CliError::ConfigValidation("Remote discovery requires a deployment hash".to_string()) @@ -2683,6 +2721,34 @@ fn run_remote_probe( with_probe_report(info, &report).map_err(Into::into) } +/// True when a probe report discovered any endpoints, forms, or resources. +fn report_has_findings(report: &ProbeEndpointsCommandReport) -> bool { + !report.endpoints.is_empty() || !report.forms.is_empty() || !report.resources.is_empty() +} + +/// Same as [`report_has_findings`] but from a raw completed agent command. +fn agent_info_has_findings(info: &AgentCommandInfo) -> bool { + info.status == "completed" + && decode_probe_report(info) + .map(|report| report_has_findings(&report)) + .unwrap_or(false) +} + +/// Choose which resolved container to re-probe directly. Prefers a container +/// whose name references the app code, otherwise the first resolved container. +fn pick_fallback_container(report: &ProbeEndpointsCommandReport, app_code: &str) -> Option { + if report.containers.is_empty() { + return None; + } + let code = crate::helpers::stacker_labels::sanitize_service_code(app_code); + report + .containers + .iter() + .find(|container| container.name.contains(app_code) || container.name.contains(&code)) + .or_else(|| report.containers.first()) + .map(|container| container.name.clone()) +} + fn synthetic_transport_target_report( request: &PipeDiscoveryRequest, ) -> ProbeEndpointsCommandReport { @@ -2840,6 +2906,47 @@ mod selectable_operation_tests { } } + fn probe_container(name: &str) -> crate::forms::status_panel::ProbeContainer { + crate::forms::status_panel::ProbeContainer { + name: name.to_string(), + image: String::new(), + network: String::new(), + ports: vec![], + addresses: vec![], + } + } + + #[test] + fn direct_container_fallback_selects_app_named_container_when_empty() { + let mut report = sample_report(vec!["html_forms".to_string()]); + + // A report with findings is not "empty" → no fallback needed. + assert!(report_has_findings(&report)); + + // Clear findings: now it's an empty app-scoped result. + report.forms.clear(); + assert!(!report_has_findings(&report)); + + // With resolved containers, the fallback prefers the one whose name + // references the app code, else the first. + report.containers = vec![ + probe_container("project-db-1"), + probe_container("project-wordpress-matomo-1"), + ]; + assert_eq!( + pick_fallback_container(&report, "wordpress-matomo").as_deref(), + Some("project-wordpress-matomo-1") + ); + assert_eq!( + pick_fallback_container(&report, "unrelated").as_deref(), + Some("project-db-1") + ); + + // No resolved containers → nothing to fall back to. + report.containers.clear(); + assert!(pick_fallback_container(&report, "wordpress-matomo").is_none()); + } + #[test] fn exact_cache_round_trip_reuses_discovery_result() { let dir = tempdir().expect("temp dir"); diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index cf232f5d..cb3646ad 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -1530,6 +1530,7 @@ mod tests { env: Default::default(), config_contract: Default::default(), origin: Default::default(), + app_present: false, }; assert_eq!( diff --git a/src/console/commands/cli/service.rs b/src/console/commands/cli/service.rs index e3985057..1dcb5407 100644 --- a/src/console/commands/cli/service.rs +++ b/src/console/commands/cli/service.rs @@ -14,14 +14,14 @@ use crate::cli::compose_service_sync::{ use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; use crate::cli::credentials::CredentialsManager; use crate::cli::error::CliError; -use crate::cli::service_catalog::ServiceCatalog; +use crate::cli::service_catalog::{catalog_inputs, CatalogInput, InputDefault, ServiceCatalog}; use crate::cli::service_import::{ import_plan_from_compose_file, parse_renames, ComposeImportRequest, ServiceImportPlan, ServiceImportReview, }; use crate::cli::stacker_client::{self, StackerClient}; use crate::console::commands::CallableTrait; -use dialoguer::{Confirm, FuzzySelect}; +use dialoguer::{Confirm, FuzzySelect, Input, Password}; use serde::Serialize; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -107,8 +107,9 @@ impl CallableTrait for ServiceAddCommand { let entry = rt.block_on(catalog.resolve(&canonical))?; - // Check if the service has dependencies that are missing - let mut services_to_add: Vec = Vec::new(); + // Add missing dependencies first, then the requested service, tracking + // the canonical codes so we can resolve their configurable inputs. + let mut added_codes: Vec = Vec::new(); for dep in &entry.service.depends_on { if !config.services.iter().any(|s| &s.name == dep) { // Try to resolve the dependency too @@ -117,16 +118,26 @@ impl CallableTrait for ServiceAddCommand { " + Adding dependency: {} ({})", dep_entry.name, dep_entry.service.image ); - services_to_add.push(dep_entry.service); + config.services.push(dep_entry.service); + added_codes.push(dep_entry.code); } } } + config.services.push(entry.service.clone()); + added_codes.push(entry.code.clone()); - // Add dependencies first, then the requested service - for dep_svc in services_to_add { - config.services.push(dep_svc); + // Resolve configurable inputs (passwords, etc.) for the added services + // and persist them to `.env`. The service definitions reference `${KEY}`, + // so secrets never land in stacker.yml. Existing `.env` keys are only + // overwritten with the user's permission. + let inputs: Vec = added_codes + .iter() + .flat_map(|code| catalog_inputs(code)) + .collect(); + if !inputs.is_empty() { + let env_path = project_dir_for_config(path).join(".env"); + apply_service_inputs(&env_path, &inputs)?; } - config.services.push(entry.service.clone()); // Serialize back to YAML let yaml = serde_yaml::to_string(&config).map_err(|e| { @@ -724,6 +735,148 @@ fn project_dir_for_config(path: &Path) -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } +/// Read the top-level keys already defined in a `.env` file (ignoring comments +/// and blanks). Returns an empty set if the file doesn't exist. +fn env_file_keys(env_path: &Path) -> std::collections::HashSet { + std::fs::read_to_string(env_path) + .unwrap_or_default() + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + line.split_once('=').map(|(key, _)| key.trim().to_string()) + }) + .collect() +} + +/// Generate a random 32-char alphanumeric secret (no shell-special chars). +fn generate_secret() -> String { + use rand::{distributions::Alphanumeric, Rng}; + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +/// Resolve a single catalog input to a value (generate / prompt / default). +fn resolve_input_value(input: &CatalogInput, interactive: bool) -> Result { + let prompt_err = |e| CliError::ConfigValidation(format!("Prompt failed: {e}")); + match &input.default { + InputDefault::GeneratedSecret => Ok(generate_secret()), + InputDefault::Literal(default) if interactive => Input::new() + .with_prompt(&input.prompt) + .default(default.clone()) + .interact_text() + .map_err(prompt_err), + InputDefault::Literal(default) => Ok(default.clone()), + InputDefault::Required if !interactive => Err(CliError::ConfigValidation(format!( + "Input '{}' is required; re-run interactively to provide it.", + input.key + ))), + InputDefault::Required if input.secret => Password::new() + .with_prompt(&input.prompt) + .interact() + .map_err(prompt_err), + InputDefault::Required => Input::new() + .with_prompt(&input.prompt) + .interact_text() + .map_err(prompt_err), + } +} + +/// Resolve catalog inputs and persist them to `.env`, referenced as `${KEY}` in +/// the service definitions. Keys already present in `.env` are only overwritten +/// after the user confirms (never in a non-interactive session). +fn apply_service_inputs(env_path: &Path, inputs: &[CatalogInput]) -> Result<(), CliError> { + use std::io::IsTerminal; + let interactive = std::io::stdin().is_terminal(); + let existing_keys = env_file_keys(env_path); + + let conflicting: Vec<&str> = inputs + .iter() + .map(|input| input.key.as_str()) + .filter(|key| existing_keys.contains(*key)) + .collect(); + + let overwrite = if conflicting.is_empty() { + true + } else if !interactive { + eprintln!( + " ℹ {} already set in {} — keeping existing value(s) (re-run interactively to overwrite).", + conflicting.join(", "), + env_path.display() + ); + false + } else { + Confirm::new() + .with_prompt(format!( + "{} already set in {}. Overwrite?", + conflicting.join(", "), + env_path.display() + )) + .default(false) + .interact() + .map_err(|e| CliError::ConfigValidation(format!("Prompt failed: {e}")))? + }; + + let mut updates: Vec<(String, String)> = Vec::new(); + for input in inputs { + if existing_keys.contains(&input.key) && !overwrite { + eprintln!(" = {} (kept existing)", input.key); + continue; + } + updates.push((input.key.clone(), resolve_input_value(input, interactive)?)); + } + + if updates.is_empty() { + return Ok(()); + } + write_env_updates(env_path, &updates)?; + for (key, _) in &updates { + eprintln!(" ✓ {} → {}", key, env_path.display()); + } + Ok(()) +} + +/// Upsert `KEY=value` lines into `.env`, preserving all unrelated lines. Creates +/// the file if missing. +fn write_env_updates(env_path: &Path, updates: &[(String, String)]) -> Result<(), CliError> { + let existing = std::fs::read_to_string(env_path).unwrap_or_default(); + let update_map: std::collections::HashMap<&str, &str> = updates + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let mut written: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut out_lines: Vec = Vec::new(); + + for line in existing.lines() { + if let Some((key, _)) = line.trim_start().split_once('=') { + let key = key.trim(); + if let Some(value) = update_map.get(key) { + out_lines.push(format!("{}={}", key, value)); + written.insert(key); + continue; + } + } + out_lines.push(line.to_string()); + } + for (key, value) in updates { + if !written.contains(key.as_str()) { + out_lines.push(format!("{}={}", key, value)); + } + } + + let mut content = out_lines.join("\n"); + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + std::fs::write(env_path, content)?; + Ok(()) +} + fn print_compose_sync_result(result: &ComposeServiceSyncResult) { if result.updated_services.is_empty() { return; @@ -777,6 +930,61 @@ mod tests { path } + #[test] + fn generate_secret_is_long_and_alphanumeric() { + let s = generate_secret(); + assert_eq!(s.len(), 32); + assert!(s.chars().all(|c| c.is_ascii_alphanumeric())); + assert_ne!(s, generate_secret(), "secrets should differ"); + } + + #[test] + fn env_file_keys_ignores_comments_and_blanks() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + std::fs::write(&path, "# comment\n\nFOO=1\nBAR = two\n").unwrap(); + let keys = env_file_keys(&path); + assert!(keys.contains("FOO")); + assert!(keys.contains("BAR")); + assert_eq!(keys.len(), 2); + // Missing file → empty set. + assert!(env_file_keys(&dir.path().join("nope.env")).is_empty()); + } + + #[test] + fn write_env_updates_upserts_and_preserves_other_lines() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + std::fs::write(&path, "# header\nKEEP=me\nPOSTGRES_PASSWORD=old\n").unwrap(); + + write_env_updates( + &path, + &[ + ("POSTGRES_PASSWORD".to_string(), "new".to_string()), + ("REDIS_PASSWORD".to_string(), "fresh".to_string()), + ], + ) + .unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("# header"), "comment preserved"); + assert!(content.contains("KEEP=me"), "unrelated key preserved"); + assert!( + content.contains("POSTGRES_PASSWORD=new"), + "existing key replaced" + ); + assert!(!content.contains("POSTGRES_PASSWORD=old"), "old value gone"); + assert!(content.contains("REDIS_PASSWORD=fresh"), "new key appended"); + } + + #[test] + fn write_env_updates_creates_file_when_missing() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + write_env_updates(&path, &[("K".to_string(), "v".to_string())]).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "K=v\n"); + } + fn write_compose(dir: &TempDir, body: &str) -> PathBuf { let path = dir.path().join("compose.yml"); std::fs::write(&path, body).unwrap(); diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 04c07375..d0405d5a 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -377,6 +377,42 @@ pub async fn get_by_slug_with_latest( Ok((template, version)) } +/// Fetch the latest version row for a template by its id. Used to federate the +/// `stack_definition` (compose) to the User Service on approval/publish. +pub async fn get_latest_version( + pool: &PgPool, + template_id: uuid::Uuid, +) -> Result, String> { + let query_span = + tracing::info_span!("marketplace_get_latest_version", template_id = %template_id); + + sqlx::query_as::<_, StackTemplateVersion>( + r#"SELECT + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at + FROM stack_template_version WHERE template_id = $1 AND is_latest = true LIMIT 1"#, + ) + .bind(template_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_latest_version error: {:?}", e); + "Internal Server Error".to_string() + }) +} + pub async fn get_by_id( pool: &PgPool, template_id: uuid::Uuid, diff --git a/src/db/marketplace_billing.rs b/src/db/marketplace_billing.rs new file mode 100644 index 00000000..5b9c33f6 --- /dev/null +++ b/src/db/marketplace_billing.rs @@ -0,0 +1,209 @@ +//! Persistence for per-install billing authorizations. +//! +//! The `marketplace_install_authorization` table is the stacker-side ledger +//! for two-phase per-install charges. user_service is authoritative on the +//! underlying money movement (it holds the Stripe payment intent, refund +//! history, etc.); stacker holds only the opaque `authorization_id`, a +//! link to the local project/deployment, and the transition status so a +//! sweeper can reconcile stale rows. +//! +//! Uses runtime `sqlx::query_as` / `sqlx::query` calls rather than the +//! compile-time macros because the schema is introduced in the same commit +//! as this module — the `.sqlx` cache would need a database with the new +//! migration already applied to build. Runtime queries stay compilable in +//! `SQLX_OFFLINE=true` builds. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +/// One row of `marketplace_install_authorization`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuthorizationRow { + pub id: Uuid, + pub project_id: Option, + pub user_id: String, + pub template_id: Uuid, + pub idempotency_key: String, + pub authorization_id: String, + pub amount_minor: i64, + pub currency: String, + pub status: String, + pub deployment_hash: Option, + pub void_reason: Option, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Input for inserting a fresh authorization row. The row is always inserted +/// with `status='authorized'` and `project_id=NULL`; `attach_project` fills +/// in the project link once the project row commits. +#[derive(Debug, Clone)] +pub struct NewAuthorization { + pub user_id: String, + pub template_id: Uuid, + pub idempotency_key: String, + pub authorization_id: String, + pub amount_minor: i64, + pub currency: String, + pub expires_at: Option>, +} + +/// Insert (or fetch, on idempotency-key replay) an authorization row. +/// +/// The `(user_id, idempotency_key)` unique index is the pivot for replay +/// safety — a duplicate install request with the same key returns the +/// previously-persisted row instead of creating a second one. Callers that +/// see the same `authorization_id` in the response can safely treat the +/// request as a no-op. +pub async fn insert_authorization( + pool: &PgPool, + row: NewAuthorization, +) -> Result { + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + + let inserted: Option = sqlx::query_as::<_, AuthorizationRow>( + r#"INSERT INTO marketplace_install_authorization + (user_id, template_id, idempotency_key, authorization_id, + amount_minor, currency, status, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, 'authorized', $7) + ON CONFLICT (user_id, idempotency_key) DO NOTHING + RETURNING *"#, + ) + .bind(&row.user_id) + .bind(row.template_id) + .bind(&row.idempotency_key) + .bind(&row.authorization_id) + .bind(row.amount_minor) + .bind(&row.currency) + .bind(row.expires_at) + .fetch_optional(&mut *tx) + .await + .map_err(|e| format!("insert_authorization: {}", e))?; + + let result = if let Some(inserted) = inserted { + inserted + } else { + sqlx::query_as::<_, AuthorizationRow>( + r#"SELECT * FROM marketplace_install_authorization + WHERE user_id = $1 AND idempotency_key = $2"#, + ) + .bind(&row.user_id) + .bind(&row.idempotency_key) + .fetch_one(&mut *tx) + .await + .map_err(|e| format!("insert_authorization replay lookup: {}", e))? + }; + + tx.commit().await.map_err(|e| e.to_string())?; + Ok(result) +} + +/// Link the authorization to the freshly-inserted project row. +pub async fn attach_project(pool: &PgPool, auth_id: Uuid, project_id: i32) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET project_id = $1, updated_at = now() + WHERE id = $2"#, + ) + .bind(project_id) + .bind(auth_id) + .execute(pool) + .await + .map_err(|e| format!("attach_project: {}", e))?; + Ok(()) +} + +/// Attach the deployment hash so `deploy_complete_handler` can look up +/// the authorization by hash without joining `stack_template_deployment`. +pub async fn attach_deployment_hash( + pool: &PgPool, + auth_id: Uuid, + deployment_hash: &str, +) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET deployment_hash = $1, updated_at = now() + WHERE id = $2"#, + ) + .bind(deployment_hash) + .bind(auth_id) + .execute(pool) + .await + .map_err(|e| format!("attach_deployment_hash: {}", e))?; + Ok(()) +} + +/// Terminal state transition on successful capture. Idempotent — re-running +/// with the same `authorization_id` is a no-op if the row is already +/// captured. +pub async fn mark_captured(pool: &PgPool, authorization_id: &str) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET status = 'captured', updated_at = now() + WHERE authorization_id = $1 AND status = 'authorized'"#, + ) + .bind(authorization_id) + .execute(pool) + .await + .map_err(|e| format!("mark_captured: {}", e))?; + Ok(()) +} + +/// Terminal state transition on void. Records the reason for audit — the +/// sweeper writes `expired`, install-failure paths write `install_failed:*`, +/// user_service-side voids write `refunded` / etc. +pub async fn mark_voided( + pool: &PgPool, + authorization_id: &str, + reason: &str, +) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET status = 'voided', void_reason = $2, updated_at = now() + WHERE authorization_id = $1 AND status = 'authorized'"#, + ) + .bind(authorization_id) + .bind(reason) + .execute(pool) + .await + .map_err(|e| format!("mark_voided: {}", e))?; + Ok(()) +} + +/// Look up the authorization associated with a deployment. Called by +/// `deploy_complete_handler` to decide whether to trigger capture. +pub async fn find_by_deployment_hash( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + sqlx::query_as::<_, AuthorizationRow>( + r#"SELECT * FROM marketplace_install_authorization + WHERE deployment_hash = $1"#, + ) + .bind(deployment_hash) + .fetch_optional(pool) + .await + .map_err(|e| format!("find_by_deployment_hash: {}", e)) +} + +/// Rows the sweeper should void: authorized past their TTL grace window. +pub async fn list_expired_authorized( + pool: &PgPool, + cutoff: DateTime, + limit: i64, +) -> Result, String> { + sqlx::query_as::<_, AuthorizationRow>( + r#"SELECT * FROM marketplace_install_authorization + WHERE status = 'authorized' AND expires_at IS NOT NULL AND expires_at < $1 + ORDER BY expires_at ASC + LIMIT $2"#, + ) + .bind(cutoff) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| format!("list_expired_authorized: {}", e)) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 77f04026..ca933b3b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,6 +8,7 @@ pub mod command; pub mod dag; pub(crate) mod deployment; pub mod marketplace; +pub mod marketplace_billing; pub mod pipe; pub mod product; pub mod project; diff --git a/src/helpers/json.rs b/src/helpers/json.rs index 44469268..822d0dbb 100644 --- a/src/helpers/json.rs +++ b/src/helpers/json.rs @@ -96,6 +96,13 @@ where ErrorForbidden(self.set_msg(msg).to_string()) } + /// HTTP 402 Payment Required — used by the per-install billing gate + /// when a template requires payment and the user has no viable payment + /// method (or an authorize was declined by user_service). + pub(crate) fn payment_required>(self, msg: I) -> Error { + actix_web::error::ErrorPaymentRequired(self.set_msg(msg).to_string()) + } + pub(crate) fn conflict>(self, msg: I) -> Error { actix_web::error::ErrorConflict(self.set_msg(msg).to_string()) } diff --git a/src/helpers/stacker_labels.rs b/src/helpers/stacker_labels.rs index 0289979f..96854a44 100644 --- a/src/helpers/stacker_labels.rs +++ b/src/helpers/stacker_labels.rs @@ -9,6 +9,31 @@ pub const DNS: &str = "my.stacker.dns"; pub const SCOPE_PROJECT: &str = "project"; pub const SCOPE_PLATFORM: &str = "platform"; +/// Normalize a project/app name into the stable code used to identify a service +/// to the status-panel agent. Mirrors the deploy-time stack-code derivation so +/// the `my.stacker.service` label matches the app code the agent resolves by +/// (lowercase alphanumerics, runs of other characters collapsed to `-`). +pub fn sanitize_service_code(name: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_alphanumeric() { + out.push(c); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + let out = out.trim_matches('-').to_string(); + if out.is_empty() { + "app-stack".to_string() + } else { + out + } +} + pub fn insert_runtime_labels( labels: &mut HashMap, project_id: Option, diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 5d53c1be..40f2e8e1 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -134,6 +134,20 @@ pub async fn approve_handler( JsonResponse::::build().not_found("Template not found") })?; + // Fetch the latest version so the webhook can federate the stack_definition + // (compose) to the User Service cache — without it, /applications serves a + // null definition and installs produce an empty compose. Best-effort: a + // missing version must not block approval. + let latest_version = db::marketplace::get_latest_version(pg_pool.get_ref(), id) + .await + .unwrap_or_else(|err| { + tracing::warn!( + "Failed to load latest version for webhook federation: {}", + err + ); + None + }); + // Send webhook asynchronously (non-blocking) // Don't fail the approval if webhook send fails - template is already approved let template_clone = template.clone(); @@ -149,6 +163,7 @@ pub async fn approve_handler( &template_clone, &template_clone.creator_user_id, template_clone.category_code.clone(), + latest_version.as_ref(), ) .instrument(span) .await diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index c3ba09a4..96d1f6bb 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -8,6 +8,7 @@ use serde_valid::Validate; use sqlx::PgPool; use crate::configuration::Settings; +use crate::connectors::errors::ConnectorError; use crate::connectors::install_service::InstallServiceConnector; use crate::connectors::user_service::UserServiceConnector; use crate::forms; @@ -23,6 +24,12 @@ pub struct InstallTemplateRequest { pub deploy: Option, #[serde(default)] pub install_inputs: Map, + /// Client-supplied idempotency key. Required for `per_install` templates + /// so retries collapse to a single authorization row. Omitted for + /// free / one_time templates. If missing on a per_install install the + /// server generates one and echoes it back in the response. + #[serde(default)] + pub idempotency_key: Option, } #[derive(Debug, Serialize)] @@ -32,6 +39,24 @@ pub struct InstallTemplateResponse { pub latest_version: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub deployment_id: Option, + /// Present only for `per_install` installs. Echoes the authorization + /// state so the CLI (and any HTTP-level retry) can reconcile. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization: Option, + /// Present only for `per_install` installs. Set to the effective key + /// used server-side (may be server-generated if the client omitted it). + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationSummary { + pub authorization_id: String, + pub status: String, + pub amount_minor: i64, + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, } fn map_access_error(err: services::MarketplaceAccessError) -> actix_web::Error { @@ -41,6 +66,9 @@ fn map_access_error(err: services::MarketplaceAccessError) -> actix_web::Error { JsonResponse::::build() .internal_server_error("Failed to validate marketplace access") } + services::MarketplaceAccessError::NoPaymentMethod { .. } => { + JsonResponse::::build().payment_required(err.to_string()) + } services::MarketplaceAccessError::MissingUserToken | services::MarketplaceAccessError::InsufficientFeaturePlan | services::MarketplaceAccessError::InsufficientTemplatePlan { .. } @@ -62,73 +90,106 @@ fn build_project_form( latest_version: &models::StackTemplateVersion, requested_name: Option<&str>, ) -> Result { - let is_yaml = latest_version.definition_format.as_deref() == Some("yaml"); + build_project_form_core(template, latest_version, requested_name) + .map_err(|msg| JsonResponse::::build().bad_request(msg)) +} - let mut form: ProjectForm = if is_yaml { - let yaml_str = latest_version.stack_definition.as_str().ok_or_else(|| { - JsonResponse::::build().bad_request(format!( - "Template '{}' has a YAML stack definition that is not a string", - template.slug - )) - })?; +/// Dry-run of the install-time project-form build. Used by `submit_handler` +/// as a submission gate so a template that can't be installed never reaches +/// the "approved" state and the marketplace listing. Errors here are the +/// same errors the install path would raise later. +pub(crate) fn validate_installable_form( + template: &models::StackTemplate, + latest_version: &models::StackTemplateVersion, +) -> Result<(), String> { + build_project_form_core(template, latest_version, None).map(|_| ()) +} - let project_name = requested_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or(&template.slug); - let stack_code = normalized_project_name(project_name); +/// The three shapes we currently see for a stored `stack_definition`: +/// +/// * `LegacyForm` — a JSON object with the `custom` wrapper, produced by +/// older submission paths. Deserializes straight into `ProjectForm`. +/// * `ComposeYaml` — a YAML string of a docker-compose file, indicated by +/// `definition_format = "yaml"`. Embedded as `docker-compose.yml`. +/// * `StackerConfig` — the shape `stacker submit` sends: the raw +/// `stacker.yml` parsed to a JSON object (no `custom` wrapper). The CLI +/// never sets `definition_format` for this shape, so we recognize it by +/// the absence of `custom`. Serialized back to YAML and embedded as +/// `stacker.yml`. +enum DefinitionShape<'a> { + LegacyForm(&'a serde_json::Value), + ComposeYaml(&'a str), + StackerConfig(String), +} - let mut config_files: Vec = - serde_json::from_value(latest_version.config_files.clone()).unwrap_or_default(); - let has_compose = config_files.iter().any(|f| { - f.get("name") - .and_then(|n| n.as_str()) - .map(|n| n.contains("docker-compose") || n.contains("compose")) - .unwrap_or(false) - }); - if !has_compose { - config_files.push(serde_json::json!({ - "name": "docker-compose.yml", - "content": yaml_str - })); - } - - let form_value = serde_json::json!({ - "custom": { - "custom_stack_code": stack_code, - "project_name": template.name.clone(), - "custom_stack_short_description": template.short_description, - "custom_stack_category": template.category_code.as_ref().map(|c| vec![c.clone()]), - "web": [], - "service": serde_json::Value::Array(vec![]), - "feature": serde_json::Value::Array(vec![]), - "networks": [], - "marketplace_config_files": config_files, - "marketplace_version": latest_version.version, - "marketplace_changelog": latest_version.changelog, - "marketplace_assets": latest_version.assets, - "marketplace_seed_jobs": latest_version.seed_jobs, - "marketplace_post_deploy_hooks": latest_version.post_deploy_hooks, - "marketplace_update_mode_capabilities": latest_version.update_mode_capabilities, - } - }); +fn classify_definition( + latest_version: &models::StackTemplateVersion, +) -> Result, String> { + let sd = &latest_version.stack_definition; + if latest_version.definition_format.as_deref() == Some("yaml") { + return sd + .as_str() + .map(DefinitionShape::ComposeYaml) + .ok_or_else(|| "YAML stack definition is not a string".to_string()); + } + if let Some(obj) = sd.as_object() { + if obj.contains_key("custom") { + return Ok(DefinitionShape::LegacyForm(sd)); + } + let yaml = serde_yaml::to_string(sd) + .map_err(|err| format!("Failed to serialize stacker.yml stack definition: {}", err))?; + return Ok(DefinitionShape::StackerConfig(yaml)); + } + Err(format!( + "Unsupported stack_definition type: expected string (compose YAML) or object (stacker.yml / legacy form), got {}", + match sd { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "boolean", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } + )) +} - serde_json::from_value(form_value).map_err(|err| { - JsonResponse::::build().bad_request(format!( - "Template '{}' has an invalid stack definition: {}", - template.slug, err - )) - })? - } else { - serde_json::from_value(latest_version.stack_definition.clone()).map_err(|err| { - JsonResponse::::build().bad_request(format!( - "Template '{}' cannot be installed because its stack definition is invalid: {}", - template.slug, err - )) - })? +fn build_project_form_core( + template: &models::StackTemplate, + latest_version: &models::StackTemplateVersion, + requested_name: Option<&str>, +) -> Result { + let shape = classify_definition(latest_version) + .map_err(|err| format!("Template '{}': {}", template.slug, err))?; + + let mut form: ProjectForm = match &shape { + DefinitionShape::LegacyForm(sd) => { + serde_json::from_value((*sd).clone()).map_err(|err| { + format!( + "Template '{}' cannot be installed because its stack definition is invalid: {}", + template.slug, err + ) + })? + } + DefinitionShape::ComposeYaml(yaml_str) => build_form_from_yaml_embed( + template, + latest_version, + requested_name, + yaml_str, + "docker-compose.yml", + )?, + DefinitionShape::StackerConfig(yaml_str) => build_form_from_yaml_embed( + template, + latest_version, + requested_name, + yaml_str, + "stacker.yml", + )?, }; - if !is_yaml { + // The YAML/stacker.yml branches already synthesize a fresh `custom` + // wrapper from the template + version; only the legacy branch needs + // us to backfill fields on top of the parsed form. + if matches!(shape, DefinitionShape::LegacyForm(_)) { if let Some(name) = requested_name .map(str::trim) .filter(|name| !name.is_empty()) @@ -152,12 +213,264 @@ fn build_project_form( .unwrap_or_default(); } - form.validate() - .map_err(|err| JsonResponse::::build().bad_request(err.to_string()))?; + form.validate().map_err(|err| err.to_string())?; Ok(form) } +fn build_form_from_yaml_embed( + template: &models::StackTemplate, + latest_version: &models::StackTemplateVersion, + requested_name: Option<&str>, + yaml_str: &str, + embed_name: &str, +) -> Result { + let project_name = requested_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(&template.slug); + let stack_code = normalized_project_name(project_name); + + let mut config_files: Vec = + serde_json::from_value(latest_version.config_files.clone()).unwrap_or_default(); + let already_embedded = config_files.iter().any(|f| { + f.get("name") + .and_then(|n| n.as_str()) + .map(|n| n == embed_name) + .unwrap_or(false) + }); + if !already_embedded { + config_files.push(serde_json::json!({ + "name": embed_name, + "content": yaml_str, + })); + } + + let form_value = serde_json::json!({ + "custom": { + "custom_stack_code": stack_code, + "project_name": template.name.clone(), + "custom_stack_short_description": template.short_description, + "custom_stack_category": template.category_code.as_ref().map(|c| vec![c.clone()]), + "web": [], + "service": serde_json::Value::Array(vec![]), + "feature": serde_json::Value::Array(vec![]), + "networks": [], + "marketplace_config_files": config_files, + "marketplace_version": latest_version.version, + "marketplace_changelog": latest_version.changelog, + "marketplace_assets": latest_version.assets, + "marketplace_seed_jobs": latest_version.seed_jobs, + "marketplace_post_deploy_hooks": latest_version.post_deploy_hooks, + "marketplace_update_mode_capabilities": latest_version.update_mode_capabilities, + } + }); + + serde_json::from_value(form_value).map_err(|err| { + format!( + "Template '{}' has an invalid stack definition: {}", + template.slug, err + ) + }) +} + +/// Overlay the deployable fields from an enriched catalog application onto the +/// summary object. The enriched `/applications/catalog/{code}` endpoint is +/// authoritative for these, so its values win when present and non-null. +fn merge_catalog_enrichment(application: &mut Value, enriched: &Value) { + let (Some(target), Some(source)) = (application.as_object_mut(), enriched.as_object()) else { + return; + }; + for key in [ + "docker_image", + "default_port", + "default_ports", + "default_env", + "default_config_files", + // Multi-service stacks: the enriched catalog entry carries `services[]` + // (one per member app, each with its own image) and a `kind:"stack"` + // marker. These MUST be overlaid too, otherwise the service-less search + // summary reaches synthesize_catalog_compose and it wrongly takes the + // single-app path -> "missing a dockerhub_image" for real stacks. + "services", + "kind", + ] { + if let Some(value) = source.get(key).filter(|v| !v.is_null()) { + target.insert(key.to_string(), value.clone()); + } + } +} + +fn json_scalar_to_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Synthesize a minimal single-service docker-compose from a catalog +/// application's enriched fields. +/// +/// A catalog application is a single container described by `docker_image` +/// (+ `default_port`/`default_ports`/`default_env`), not a multi-service +/// stack. We render one service and embed it as `docker-compose.yml`, exactly +/// how DB-backed templates ship their compose — so the rest of the deploy path +/// (including the empty-compose guard) treats it uniformly. +/// +/// Returns `None` when the application has no docker image, i.e. nothing +/// deployable; the caller turns that into a clear install error. +fn synthesize_catalog_compose(application: &Value, service_code: &str) -> Option { + let mut services = serde_yaml::Mapping::new(); + + // Multi-service stack: one compose service per member app, each with its + // own real container image. Members without an image are skipped; if that + // leaves nothing, return None so the caller can fail loudly rather than + // shipping an empty/bogus compose. + if let Some(members) = application.get("services").and_then(|v| v.as_array()) { + for member in members { + let image = member + .get("docker_image") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + let Some(image) = image else { continue }; + let key = member + .get("code") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(service_code); + services.insert( + key.into(), + build_compose_service( + image, + member.get("default_ports"), + member.get("default_port"), + member.get("default_env"), + ), + ); + } + if services.is_empty() { + return None; + } + return compose_document(services); + } + + // Single catalog app: one service from the top-level docker_image. + let image = application + .get("docker_image") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty())?; + services.insert( + service_code.into(), + build_compose_service( + image, + application.get("default_ports"), + application.get("default_port"), + application.get("default_env"), + ), + ); + compose_document(services) +} + +/// Build a single compose service mapping (image + ports + env + restart + +/// default_network) from catalog fields. +fn build_compose_service( + image: &str, + default_ports: Option<&Value>, + default_port: Option<&Value>, + default_env: Option<&Value>, +) -> serde_yaml::Value { + // Ports: prefer an explicit `default_ports` list, else `default_port`. + let mut ports: Vec = Vec::new(); + if let Some(arr) = default_ports.and_then(|v| v.as_array()) { + for p in arr { + if let Some(n) = p.as_i64() { + ports.push(serde_yaml::Value::String(format!("{n}:{n}"))); + } else if let Some(s) = p.as_str().map(str::trim).filter(|s| !s.is_empty()) { + let mapping = if s.contains(':') { + s.to_string() + } else { + format!("{s}:{s}") + }; + ports.push(serde_yaml::Value::String(mapping)); + } + } + } + if ports.is_empty() { + if let Some(n) = default_port.and_then(|v| v.as_i64()) { + ports.push(serde_yaml::Value::String(format!("{n}:{n}"))); + } + } + + // Environment: accept an object ({KEY: value}) or a list of {key,value} / + // "KEY=value" entries. Anything unrecognized is skipped rather than + // guessed at. + let mut env = serde_yaml::Mapping::new(); + match default_env { + Some(Value::Object(map)) => { + for (k, v) in map { + if let Some(s) = json_scalar_to_string(v) { + env.insert(k.clone().into(), s.into()); + } + } + } + Some(Value::Array(arr)) => { + for item in arr { + if let Some(obj) = item.as_object() { + if let (Some(k), Some(v)) = + (obj.get("key").and_then(|x| x.as_str()), obj.get("value")) + { + if let Some(s) = json_scalar_to_string(v) { + env.insert(k.into(), s.into()); + } + } + } else if let Some((k, v)) = item.as_str().and_then(|s| s.split_once('=')) { + env.insert(k.into(), v.into()); + } + } + } + _ => {} + } + + let mut service = serde_yaml::Mapping::new(); + service.insert("image".into(), image.into()); + if !ports.is_empty() { + service.insert("ports".into(), serde_yaml::Value::Sequence(ports)); + } + if !env.is_empty() { + service.insert("environment".into(), serde_yaml::Value::Mapping(env)); + } + service.insert("restart".into(), "unless-stopped".into()); + service.insert( + "networks".into(), + serde_yaml::Value::Sequence(vec!["default_network".into()]), + ); + serde_yaml::Value::Mapping(service) +} + +/// Wrap synthesized services into a full compose document (with the shared +/// external `default_network`) and serialize to YAML. +fn compose_document(services: serde_yaml::Mapping) -> Option { + let mut default_network = serde_yaml::Mapping::new(); + default_network.insert("external".into(), serde_yaml::Value::Bool(true)); + default_network.insert("name".into(), "default_network".into()); + let mut networks = serde_yaml::Mapping::new(); + networks.insert( + "default_network".into(), + serde_yaml::Value::Mapping(default_network), + ); + + let mut root = serde_yaml::Mapping::new(); + root.insert("services".into(), serde_yaml::Value::Mapping(services)); + root.insert("networks".into(), serde_yaml::Value::Mapping(networks)); + + serde_yaml::to_string(&serde_yaml::Value::Mapping(root)).ok() +} + fn catalog_application_project_form( application: &serde_json::Value, slug: &str, @@ -173,6 +486,38 @@ fn catalog_application_project_form( .unwrap_or(app_name); let stack_code = normalized_project_name(project_name); + // Catalog apps carry no stack_definition; synthesize a compose from the + // application's real container image(s) -- one service for a single app, or + // one per member for a stack. Embedded as `marketplace_config_files` (same + // channel DB templates use), it is picked up by `embedded_marketplace_compose` + // at deploy time. + // + // Fail loudly if nothing could be synthesized: a missing/empty image means + // the catalog entry is incomplete, and shipping an empty compose would only + // surface later as an opaque `docker compose up` failure on the server. + let marketplace_config_files = match synthesize_catalog_compose(application, &stack_code) { + Some(compose) => serde_json::json!([{ + "name": "docker-compose.yml", + "content": compose, + }]), + None => { + let is_stack = application.get("kind").and_then(|v| v.as_str()) == Some("stack") + || application + .get("services") + .and_then(|v| v.as_array()) + .is_some(); + let reason = if is_stack { + "none of its member apps resolved a container image (each member app needs its own dockerhub_image)" + } else { + "the catalog entry is missing a dockerhub_image" + }; + return Err(JsonResponse::::build().bad_request(format!( + "Catalog application '{}' cannot be installed: no container image was available to synthesize a docker-compose file — {}.", + slug, reason + ))); + } + }; + let form_value = serde_json::json!({ "custom": { "custom_stack_code": stack_code, @@ -190,6 +535,7 @@ fn catalog_application_project_form( "feature": [], "service": [], "networks": [], + "marketplace_config_files": marketplace_config_files, "catalog_application": application } }); @@ -387,6 +733,89 @@ fn redact_version_value(ver_value: &mut serde_json::Value, format: &str) { } } +/// True when this install should go through per-install billing. +/// Both conditions must hold: the template opts into `per_install` and the +/// global kill switch is on. When either is false we fall through to the +/// legacy `one_time` / `free` paths untouched. +fn is_per_install_effective(template: &models::StackTemplate, settings: &Settings) -> bool { + let opted_in = template + .billing_cycle + .as_deref() + .map(|c| c.trim().eq_ignore_ascii_case("per_install")) + .unwrap_or(false); + opted_in && settings.per_install_billing_enabled +} + +/// Convert a template's `price` (dollars, f64) to minor units (cents, i64) +/// with checked rounding. Rejects None/<=0 for per_install templates — +/// admin review is expected to prevent this from ever reaching install. +fn amount_minor_for(template: &models::StackTemplate) -> Result { + let price = template.price.unwrap_or(0.0); + if price <= 0.0 { + return Err( + JsonResponse::::build().bad_request(format!( + "Template '{}' has billing_cycle=per_install but no positive price", + template.slug + )), + ); + } + let cents = (price * 100.0).round() as i64; + if cents <= 0 { + return Err( + JsonResponse::::build().bad_request(format!( + "Template '{}' price rounds to zero cents", + template.slug + )), + ); + } + Ok(cents) +} + +/// Best-effort background void of an authorization on install failure. +/// Fire-and-forget: the sweeper is the correctness backstop if this +/// spawn is dropped or the user_service call fails. +fn spawn_void( + user_service: Arc, + pg_pool: PgPool, + user_token: String, + authorization_id: String, + reason: String, +) { + tokio::spawn(async move { + if let Err(err) = user_service + .void_install_charge(&user_token, &authorization_id, &reason) + .await + { + tracing::warn!( + "per_install void failed for {}: {} (sweeper will retry)", + authorization_id, + err + ); + } + if let Err(err) = + db::marketplace_billing::mark_voided(&pg_pool, &authorization_id, &reason).await + { + tracing::warn!( + "per_install DB void mark failed for {}: {}", + authorization_id, + err + ); + } + }); +} + +fn summarize( + handle: &crate::connectors::user_service::AuthorizationHandle, +) -> AuthorizationSummary { + AuthorizationSummary { + authorization_id: handle.authorization_id.clone(), + status: handle.status.clone(), + amount_minor: handle.amount_minor, + currency: handle.currency.clone(), + expires_at: handle.expires_at.clone(), + } +} + async fn install_stack_template( template: models::StackTemplate, latest_version: models::StackTemplateVersion, @@ -403,15 +832,146 @@ async fn install_stack_template( .await .map_err(map_access_error)?; - let form = build_project_form(&template, &latest_version, request.name.as_deref())?; + // Per-install billing: authorize before any DB write so a decline + // returns 402 with no side effects. + let per_install = is_per_install_effective(&template, settings); + let (mut authorization_state, effective_idem_key) = if per_install { + let user_token = user.access_token.as_deref().ok_or_else(|| { + JsonResponse::::build() + .forbidden("User token required for per-install billing") + })?; + let amount_minor = amount_minor_for(&template)?; + let currency = template + .currency + .clone() + .unwrap_or_else(|| "USD".to_string()); + let idempotency_key = request + .idempotency_key + .clone() + .filter(|k| !k.trim().is_empty()) + .unwrap_or_else(|| { + let key = format!("srv-{}", uuid::Uuid::new_v4()); + tracing::warn!( + "install request for per_install template '{}' missing idempotency_key; generated '{}'", + template.slug, + key + ); + key + }); + + let handle = user_service + .authorize_install_charge( + user_token, + &template.id, + amount_minor, + ¤cy, + &idempotency_key, + ) + .await + .map_err(|err| match err { + ConnectorError::PaymentRequired(msg) => JsonResponse::::build() + .payment_required(format!("Payment declined: {}", msg)), + ConnectorError::Conflict(msg) => JsonResponse::::build() + .bad_request(format!("Idempotency conflict: {}", msg)), + other => { + tracing::error!("authorize_install_charge failed: {:?}", other); + JsonResponse::::build() + .internal_server_error("Failed to authorize install charge") + } + })?; + + // Persist the authorization row before touching the project row so + // a crash between authorize and project insert still leaves us an + // auditable ledger entry for the sweeper. + let expires_at = handle + .expires_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + let auth_row = db::marketplace_billing::insert_authorization( + pg_pool, + db::marketplace_billing::NewAuthorization { + user_id: user.id.clone(), + template_id: template.id, + idempotency_key: idempotency_key.clone(), + authorization_id: handle.authorization_id.clone(), + amount_minor: handle.amount_minor, + currency: handle.currency.clone(), + expires_at, + }, + ) + .await + .map_err(|err| { + tracing::error!("insert_authorization failed: {}", err); + spawn_void( + user_service.clone(), + pg_pool.clone(), + user_token.to_string(), + handle.authorization_id.clone(), + "install_failed:db_insert_authorization".to_string(), + ); + JsonResponse::::build() + .internal_server_error("Failed to persist install authorization") + })?; + + ( + Some((auth_row, handle, user_token.to_string())), + Some(idempotency_key), + ) + } else { + (None, None) + }; + + // Any error from here forward must void the authorization if one was + // taken. We keep the flow linear and handle each `?` explicitly via a + // small helper that voids on failure paths. + let form = match build_project_form(&template, &latest_version, request.name.as_deref()) { + Ok(f) => f, + Err(e) => { + if let Some((_, handle, token)) = authorization_state.take() { + spawn_void( + user_service.clone(), + pg_pool.clone(), + token, + handle.authorization_id, + "install_failed:build_project_form".to_string(), + ); + } + return Err(e); + } + }; + let mut request_json = serde_json::to_value(&form).map_err(|err| { tracing::error!("Failed to serialize marketplace project form: {:?}", err); + if let Some((_, handle, token)) = authorization_state.take() { + spawn_void( + user_service.clone(), + pg_pool.clone(), + token, + handle.authorization_id, + "install_failed:serialize_form".to_string(), + ); + } JsonResponse::::build().internal_server_error("Internal Server Error") })?; let install_inputs = normalized_install_inputs(&request.install_inputs); attach_install_inputs(&mut request_json, &install_inputs); - let mut project = insert_project_from_form(pg_pool, user, &form, request_json).await?; + let mut project = match insert_project_from_form(pg_pool, user, &form, request_json).await { + Ok(p) => p, + Err(e) => { + if let Some((_, handle, token)) = authorization_state.take() { + spawn_void( + user_service.clone(), + pg_pool.clone(), + token, + handle.authorization_id, + "install_failed:insert_project".to_string(), + ); + } + return Err(e); + } + }; project.source_template_id = Some(template.id); project.template_version = Some(latest_version.version.clone()); project = db::project::update(pg_pool, project).await.map_err(|err| { @@ -419,6 +979,20 @@ async fn install_stack_template( JsonResponse::::build().internal_server_error("Internal Server Error") })?; + // Link the auth row to the freshly-committed project so + // deploy_complete_handler and cancel/refund paths can find it. + if let Some((auth_row, _handle, _token)) = authorization_state.as_ref() { + if let Err(err) = + db::marketplace_billing::attach_project(pg_pool, auth_row.id, project.id).await + { + tracing::warn!( + "attach_project failed for auth {}: {} (row still usable via idempotency_key)", + auth_row.id, + err + ); + } + } + let slug = template.slug.clone(); let (project, deployment_id) = maybe_deploy_installed_project( user, @@ -435,6 +1009,61 @@ async fn install_stack_template( ) .await?; + // Attach deployment_hash if a deployment was queued. If no deploy was + // requested (sync install-only) capture immediately — the value the + // buyer paid for is the install artifact itself. + if let Some((auth_row, handle, token)) = authorization_state.as_mut() { + if let Some(deploy_id) = deployment_id { + match db::deployment::fetch(pg_pool, deploy_id).await { + Ok(Some(deployment)) => { + if let Err(err) = db::marketplace_billing::attach_deployment_hash( + pg_pool, + auth_row.id, + &deployment.deployment_hash, + ) + .await + { + tracing::warn!( + "attach_deployment_hash failed for auth {}: {}", + auth_row.id, + err + ); + } + } + Ok(None) => tracing::warn!( + "deployment id {} not found when linking auth {}", + deploy_id, + auth_row.id + ), + Err(err) => tracing::warn!("deployment lookup failed for {}: {}", deploy_id, err), + } + } else { + // Install-without-deploy: synthesize a hash and capture now. + let synthetic = format!("install-only:{}", project.id); + let _ = + db::marketplace_billing::attach_deployment_hash(pg_pool, auth_row.id, &synthetic) + .await; + match user_service + .capture_install_charge(token, &handle.authorization_id, &synthetic) + .await + { + Ok(new_handle) => { + let _ = + db::marketplace_billing::mark_captured(pg_pool, &handle.authorization_id) + .await; + *handle = new_handle; + } + Err(err) => { + tracing::warn!( + "sync install-only capture failed for {}: {} (sweeper will reconcile)", + handle.authorization_id, + err + ); + } + } + } + } + let format = latest_version .definition_format .as_deref() @@ -445,11 +1074,18 @@ async fn install_stack_template( redact_version_value(&mut ver_value, &format); + let (authorization, idempotency_key) = match (authorization_state, effective_idem_key) { + (Some((_, handle, _)), Some(key)) => (Some(summarize(&handle)), Some(key)), + _ => (None, None), + }; + Ok(InstallTemplateResponse { project, template: serde_json::to_value(template).unwrap_or_else(|_| serde_json::json!({})), latest_version: ver_value, deployment_id, + authorization, + idempotency_key, }) } @@ -485,7 +1121,7 @@ async fn install_catalog_application( })?; let slug_lc = slug.to_ascii_lowercase(); - let application = applications + let mut application = applications .into_iter() .find(|application| catalog_application_matches_slug(application, &slug_lc)) .ok_or_else(|| { @@ -495,6 +1131,21 @@ async fn install_catalog_application( )) })?; + // The search result is a summary and may lack the docker image / default + // env & ports. Enrich from the catalog endpoint so we can synthesize a + // deployable compose; missing enrichment is non-fatal (best effort). + match user_service.get_catalog_application(token, slug).await { + Ok(Some(enriched)) => merge_catalog_enrichment(&mut application, &enriched), + Ok(None) => {} + Err(err) => { + tracing::warn!( + "Catalog enrichment lookup failed for '{}': {:?} (continuing with summary)", + slug, + err + ); + } + } + let form = catalog_application_project_form(&application, slug, request.name.as_deref())?; let mut request_json = serde_json::to_value(&form).map_err(|err| { tracing::error!( @@ -529,6 +1180,8 @@ async fn install_catalog_application( .await?; Ok(InstallTemplateResponse { + authorization: None, + idempotency_key: None, project, template: application, latest_version: serde_json::json!({ @@ -620,9 +1273,12 @@ pub async fn install_handler( #[cfg(test)] mod tests { use super::{ - build_project_form, catalog_application_project_form, - ensure_catalog_application_has_deploy_context, + amount_minor_for, build_project_form, catalog_application_project_form, + ensure_catalog_application_has_deploy_context, is_per_install_effective, + merge_catalog_enrichment, summarize, synthesize_catalog_compose, validate_installable_form, }; + use crate::configuration::Settings; + use crate::connectors::user_service::AuthorizationHandle; use crate::{forms::project::Payload, models}; use serde_json::{json, Map}; use uuid::Uuid; @@ -721,6 +1377,10 @@ mod tests { "name": "Dify", "description": "Dify AI application platform", "categories": ["AI"], + // A single-app catalog entry carries a real container image; without + // one it is not installable (catalog_application_project_form now + // fails loud rather than shipping an empty compose). + "docker_image": "langgenius/dify-api", "is_from_marketplace": true }); @@ -744,6 +1404,256 @@ mod tests { ); } + #[test] + fn synthesize_catalog_compose_builds_single_service_from_image_and_port() { + let application = json!({ + "code": "n8n", + "docker_image": "n8nio/n8n:latest", + "default_port": 5678, + "default_env": { "N8N_PORT": "5678", "GENERIC_TIMEZONE": "UTC" } + }); + + let compose = synthesize_catalog_compose(&application, "n8n") + .expect("compose should be synthesized when a docker image is present"); + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + let svc = &parsed["services"]["n8n"]; + + assert_eq!(svc["image"], serde_yaml::Value::from("n8nio/n8n:latest")); + assert_eq!(svc["ports"][0], serde_yaml::Value::from("5678:5678")); + assert_eq!( + svc["environment"]["N8N_PORT"], + serde_yaml::Value::from("5678") + ); + assert_eq!(svc["restart"], serde_yaml::Value::from("unless-stopped")); + // External default_network is declared so the stack joins the shared net. + assert_eq!( + parsed["networks"]["default_network"]["external"], + serde_yaml::Value::from(true) + ); + } + + #[test] + fn synthesize_catalog_compose_returns_none_without_image() { + let application = json!({ "code": "n8n", "default_port": 5678 }); + assert!(synthesize_catalog_compose(&application, "n8n").is_none()); + } + + #[test] + fn synthesize_catalog_compose_accepts_kv_list_env() { + let application = json!({ + "docker_image": "img:1", + "default_env": [ { "key": "A", "value": "1" }, "B=2" ] + }); + let compose = synthesize_catalog_compose(&application, "svc").unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + assert_eq!( + parsed["services"]["svc"]["environment"]["A"], + serde_yaml::Value::from("1") + ); + assert_eq!( + parsed["services"]["svc"]["environment"]["B"], + serde_yaml::Value::from("2") + ); + } + + #[test] + fn synthesize_catalog_compose_builds_multi_service_stack_from_members() { + // A stack (e.g. LAMP) carries no top-level image; each member service + // provides its own real container image. The synthesizer must emit one + // compose service per member, keyed by member code, and never fall back + // to the empty stack-level docker_image. + let application = json!({ + "code": "lamp", + "kind": "stack", + "docker_image": "", + "services": [ + { + "code": "apache", + "docker_image": "trydirect/php", + "default_ports": [80], + "default_env": { "PHP_ENV": "prod" } + }, + { + "code": "mariadb", + "docker_image": "mariadb:11", + "default_env": [ { "key": "MARIADB_ROOT_PASSWORD", "value": "secret" } ] + } + ] + }); + + let compose = synthesize_catalog_compose(&application, "lamp") + .expect("a stack with member images should synthesize a multi-service compose"); + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + let services = parsed["services"].as_mapping().expect("services mapping"); + + // One service per member, keyed by member code (not the stack code). + assert_eq!(services.len(), 2); + assert_eq!( + parsed["services"]["apache"]["image"], + serde_yaml::Value::from("trydirect/php") + ); + assert_eq!( + parsed["services"]["apache"]["ports"][0], + serde_yaml::Value::from("80:80") + ); + assert_eq!( + parsed["services"]["apache"]["environment"]["PHP_ENV"], + serde_yaml::Value::from("prod") + ); + assert_eq!( + parsed["services"]["mariadb"]["image"], + serde_yaml::Value::from("mariadb:11") + ); + assert_eq!( + parsed["services"]["mariadb"]["environment"]["MARIADB_ROOT_PASSWORD"], + serde_yaml::Value::from("secret") + ); + // The empty stack-level docker_image must never leak in as a service. + assert!(services.get(serde_yaml::Value::from("lamp")).is_none()); + } + + #[test] + fn synthesize_catalog_compose_returns_none_for_stack_without_member_images() { + // A stack whose members all lack images cannot be synthesized -> None, + // so the caller fails loud rather than shipping an empty compose. + let application = json!({ + "kind": "stack", + "docker_image": "", + "services": [ { "code": "apache" }, { "code": "mariadb" } ] + }); + assert!(synthesize_catalog_compose(&application, "lamp").is_none()); + } + + #[test] + fn full_production_lamp_payload_deserializes_and_synthesizes_13_services() { + // The EXACT bytes returned by GET /applications/catalog/lamp in production + // (all 13 members). Proves the full response parses into Application, that + // services[] survives the get_catalog_application round-trip, and that the + // synthesizer emits a multi-service compose. If this passes, a runtime + // "single-app fail-loud" cannot be a deserialization problem. + let raw = include_str!("lamp_catalog_fixture.json"); + + let app: crate::connectors::user_service::app::Application = serde_json::from_str(raw) + .expect("full production LAMP payload must deserialize into Application"); + let value = serde_json::to_value(&app).expect("Application must reserialize"); + + let services = value + .get("services") + .and_then(|v| v.as_array()) + .expect("services[] must survive the Application round-trip"); + assert_eq!(services.len(), 13, "expected all 13 members to survive"); + + let compose = synthesize_catalog_compose(&value, "lamp") + .expect("full payload must synthesize a multi-service compose"); + for img in ["trydirect/mysql", "trydirect/apache", "trydirect/nginx-proxy-manager"] { + assert!(compose.contains(img), "compose missing {img}:\n{compose}"); + } + } + + #[test] + fn merge_catalog_enrichment_carries_services_into_synthesized_stack() { + // Reproduces the real install_catalog_application flow: a service-less + // search summary is enriched from /applications/catalog, then synthesized. + // The stack's services[]/kind must survive merge_catalog_enrichment, + // otherwise synthesize wrongly takes the single-app path and fails loud. + let enriched: serde_json::Value = + serde_json::from_str(include_str!("lamp_catalog_fixture.json")).unwrap(); + let mut summary = json!({ "code": "lamp", "name": "LAMP", "is_from_marketplace": true }); + + merge_catalog_enrichment(&mut summary, &enriched); + + assert_eq!(summary.get("kind").and_then(|v| v.as_str()), Some("stack")); + let services = summary + .get("services") + .and_then(|v| v.as_array()) + .expect("services[] must be carried into the summary by the merge"); + assert_eq!(services.len(), 13); + + let compose = synthesize_catalog_compose(&summary, "lamp") + .expect("merged stack summary must synthesize a multi-service compose"); + assert!(compose.contains("trydirect/mysql"), "compose:\n{compose}"); + assert!(compose.contains("trydirect/apache"), "compose:\n{compose}"); + } + + #[test] + fn real_stack_catalog_payload_round_trips_and_synthesizes_multi_service() { + // Exact shape returned by GET /applications/catalog/lamp in production: + // kind:"stack", empty top-level docker_image, member services[] with real + // images, null/list default_ports, object default_env. This reproduces the + // full server path: fetch_app_catalog deserializes into Application, then + // get_catalog_application reserializes via serde_json::to_value, then the + // synthesizer runs. `services[]` must survive the round-trip. + let raw = r#"{ + "_id": 54, "code": "lamp", "name": "LAMP", "kind": "stack", + "docker_image": "", + "services": [ + {"_id":1,"code":"mysql","name":"MySQL","role":null,"type":"service", + "docker_image":"trydirect/mysql","default_ports":null, + "default_env":{"MYSQL_HOST":"mysqldb","MYSQL_PORT":"3306"}, + "default_config_files":[]}, + {"_id":9,"code":"statuspanel","name":"Status Panel","role":"statuspanel", + "type":"feature","docker_image":"trydirect/status", + "default_ports":["5000"],"default_env":{},"default_config_files":[]} + ] + }"#; + + let app: crate::connectors::user_service::app::Application = + serde_json::from_str(raw).expect("catalog stack payload must deserialize into Application"); + let value = serde_json::to_value(&app).expect("Application must reserialize"); + + // The critical invariant: services[] survives the typed round-trip. + let services = value + .get("services") + .and_then(|v| v.as_array()) + .expect("services[] must survive the Application round-trip"); + assert_eq!(services.len(), 2, "round-trip dropped services: {value}"); + + let compose = synthesize_catalog_compose(&value, "lamp") + .expect("stack payload must synthesize a multi-service compose, not fail loud"); + assert!(compose.contains("trydirect/mysql"), "compose: {compose}"); + assert!(compose.contains("trydirect/status"), "compose: {compose}"); + } + + #[test] + fn catalog_form_embeds_synthesized_compose_when_image_present() { + let application = json!({ + "code": "n8n", + "name": "n8n", + "docker_image": "n8nio/n8n:latest", + "default_port": 5678 + }); + + let form = catalog_application_project_form(&application, "n8n", None).unwrap(); + let files = &form.custom.marketplace_config_files; + let arr = files + .as_array() + .expect("marketplace_config_files should be an array"); + let compose = arr + .iter() + .find(|f| f.get("name").and_then(|n| n.as_str()) == Some("docker-compose.yml")) + .and_then(|f| f.get("content")) + .and_then(|c| c.as_str()) + .expect("a docker-compose.yml should be embedded"); + assert!(compose.contains("n8nio/n8n:latest")); + assert!(compose.contains("services:")); + } + + #[test] + fn merge_catalog_enrichment_overlays_deployable_fields() { + let mut application = json!({ "code": "n8n", "name": "n8n" }); + let enriched = json!({ + "docker_image": "n8nio/n8n:latest", + "default_port": 5678, + "default_env": null + }); + merge_catalog_enrichment(&mut application, &enriched); + + assert_eq!(application["docker_image"], json!("n8nio/n8n:latest")); + assert_eq!(application["default_port"], json!(5678)); + // Null enriched values must not overwrite / introduce nulls. + assert!(application.get("default_env").is_none()); + } + #[test] fn catalog_application_slug_match_accepts_code_or_slug() { assert!(super::catalog_application_matches_slug( @@ -766,10 +1676,121 @@ mod tests { name: None, deploy: None, install_inputs: Map::new(), + idempotency_key: None, }; assert!(ensure_catalog_application_has_deploy_context("dify", &request).is_err()); } + + // Production's ghost: the exact shape `stacker submit` sends — the raw + // stacker.yml parsed to JSON (has `app`/`services`/`name`, no `custom` + // wrapper, `definition_format` unset). Install must accept this and + // embed the serialized YAML as `stacker.yml` in marketplace_config_files. + #[test] + fn build_project_form_accepts_stacker_yml_shape_from_submit() { + let template = models::StackTemplate { + slug: "ghost".to_string(), + name: "ghost".to_string(), + short_description: Some("Ghost blog".to_string()), + category_code: Some("cms".to_string()), + ..Default::default() + }; + let version = models::StackTemplateVersion { + id: Uuid::new_v4(), + template_id: Uuid::new_v4(), + version: "1.0.0".to_string(), + stack_definition: json!({ + "name": "ghost", + "app": { "type": "custom", "image": "ghost:5-alpine" }, + "services": [], + "deploy": { "target": "local" } + }), + config_files: json!([]), + assets: json!([]), + seed_jobs: json!([]), + post_deploy_hooks: json!([]), + update_mode_capabilities: None, + definition_format: None, + changelog: None, + is_latest: Some(true), + created_at: None, + }; + + let form = build_project_form(&template, &version, None) + .expect("stacker.yml shape from `stacker submit` must build a ProjectForm"); + assert_eq!(form.custom.custom_stack_code, "ghost"); + assert_eq!(form.custom.project_name, Some("ghost".to_string())); + + let files: &Vec = form + .custom + .marketplace_config_files + .as_array() + .expect("marketplace_config_files should be an array"); + let embed = files + .iter() + .find(|f| f.get("name").and_then(|n| n.as_str()) == Some("stacker.yml")) + .expect( + "stacker.yml shape should be embedded as `stacker.yml`, not `docker-compose.yml`", + ); + let content = embed + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or_default(); + assert!( + content.contains("app:") && content.contains("ghost:5-alpine"), + "embedded content should be the serialized stacker.yml, got: {}", + content + ); + + validate_installable_form(&template, &version) + .expect("validator should agree with build_project_form"); + } + + #[test] + fn validate_installable_accepts_yaml_compose_definition() { + let template = models::StackTemplate { + slug: "umami".to_string(), + name: "umami".to_string(), + ..Default::default() + }; + let version = make_yaml_version(); + + validate_installable_form(&template, &version) + .expect("a compose-YAML stack definition must pass the gate"); + } + + #[test] + fn validate_installable_accepts_legacy_custom_wrapped_definition() { + let template = models::StackTemplate { + slug: "legacy".to_string(), + name: "legacy".to_string(), + ..Default::default() + }; + let version = models::StackTemplateVersion { + id: Uuid::new_v4(), + template_id: Uuid::new_v4(), + version: "1.0.0".to_string(), + stack_definition: json!({ + "custom": { + "custom_stack_code": "legacy", + "web": [], + "networks": [] + } + }), + config_files: json!([]), + assets: json!([]), + seed_jobs: json!([]), + post_deploy_hooks: json!([]), + update_mode_capabilities: None, + definition_format: None, + changelog: None, + is_latest: Some(true), + created_at: None, + }; + + validate_installable_form(&template, &version) + .expect("a legacy custom-wrapped stack definition must pass the gate"); + } #[test] fn reserved_prefix_vars_rejected_in_install_inputs() { let mut deploy = crate::forms::project::Deploy::default(); @@ -857,4 +1878,99 @@ mod tests { } } } + + // ── per_install helpers (pure functions) ───────────────────────── + // + // Full install-handler integration tests live under `tests/` — they + // need a real Postgres + wiremock and belong with the cucumber suite. + // The tests here pin the pure pieces that decide *whether* to bill and + // *how much*, plus the response-shape summarizer. + + fn per_install_template_fixture() -> models::StackTemplate { + models::StackTemplate { + slug: "paid-per-install".to_string(), + price: Some(9.99), + billing_cycle: Some("per_install".to_string()), + currency: Some("USD".to_string()), + product_id: Some(42), + ..Default::default() + } + } + + fn settings_with_billing_flag(enabled: bool) -> Settings { + let mut s = Settings::default(); + s.per_install_billing_enabled = enabled; + s + } + + #[test] + fn is_per_install_effective_requires_both_opt_in_and_flag() { + let template = per_install_template_fixture(); + + assert!(is_per_install_effective( + &template, + &settings_with_billing_flag(true) + )); + assert!( + !is_per_install_effective(&template, &settings_with_billing_flag(false)), + "kill switch off must force the gate to fall through to legacy paths" + ); + + // Template not opted in but flag on — still not per_install. + let mut legacy = template.clone(); + legacy.billing_cycle = Some("one_time".to_string()); + assert!(!is_per_install_effective( + &legacy, + &settings_with_billing_flag(true) + )); + + // billing_cycle missing entirely — not per_install. + legacy.billing_cycle = None; + assert!(!is_per_install_effective( + &legacy, + &settings_with_billing_flag(true) + )); + } + + #[test] + fn amount_minor_for_rounds_and_rejects_non_positive() { + let mut t = per_install_template_fixture(); + + t.price = Some(9.99); + assert_eq!(amount_minor_for(&t).unwrap(), 999); + + t.price = Some(10.005); + assert_eq!( + amount_minor_for(&t).unwrap(), + 1001, + "banker rounding to nearest cent" + ); + + // Zero and negative and missing are rejected up front — admin + // review should catch these before install, but we defensively + // fail-closed here anyway. + t.price = Some(0.0); + assert!(amount_minor_for(&t).is_err()); + t.price = Some(-1.0); + assert!(amount_minor_for(&t).is_err()); + t.price = None; + assert!(amount_minor_for(&t).is_err()); + } + + #[test] + fn summarize_flattens_authorization_handle_for_response() { + let h = AuthorizationHandle { + authorization_id: "auth-xyz".to_string(), + amount_minor: 999, + currency: "USD".to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), + status: "authorized".to_string(), + }; + let s = summarize(&h); + assert_eq!(s.authorization_id, "auth-xyz"); + assert_eq!(s.amount_minor, 999); + assert_eq!(s.currency, "USD"); + assert_eq!(s.status, "authorized"); + assert_eq!(s.expires_at.as_deref(), Some("2099-01-01T00:00:00Z")); + } } diff --git a/src/routes/marketplace/lamp_catalog_fixture.json b/src/routes/marketplace/lamp_catalog_fixture.json new file mode 100644 index 00000000..b2db2f28 --- /dev/null +++ b/src/routes/marketplace/lamp_catalog_fixture.json @@ -0,0 +1,202 @@ +{ + "_id": 54, + "code": "lamp", + "name": "LAMP", + "kind": "stack", + "docker_image": "", + "services": [ + { + "_id": 1, + "code": "mysql", + "name": "MySQL", + "role": null, + "type": "service", + "docker_image": "trydirect/mysql", + "default_ports": null, + "default_env": { + "MYSQL_HOST": "mysqldb", + "MYSQL_PORT": "3306", + "MYSQL_USER": "admin", + "MYSQL_DATABASE": "default", + "MYSQL_PASSWORD": "password-10", + "MYSQL_ROOT_PASSWORD": "password-10" + }, + "default_config_files": [] + }, + { + "_id": 2, + "code": "nginx", + "name": "Nginx", + "role": null, + "type": "service", + "docker_image": "trydirect/nginx", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + }, + { + "_id": 9, + "code": "statuspanel", + "name": "Status Panel", + "role": "statuspanel", + "type": "feature", + "docker_image": "trydirect/status", + "default_ports": [ + "5000" + ], + "default_env": { + "status_config_json": "status_config_json", + "ADMIN_IP_RANGE": "0.0.0.0/0", + "status_panel_password": "password-10", + "status_panel_port": "5000", + "status_panel_username": "admin" + }, + "default_config_files": [] + }, + { + "_id": 12, + "code": "fail2ban", + "name": "Fail2ban", + "role": "fail2ban", + "type": "feature", + "docker_image": "trydirect/fail2ban", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + }, + { + "_id": 45, + "code": "redis", + "name": "Redis", + "role": null, + "type": "service", + "docker_image": "trydirect/redis", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + }, + { + "_id": 48, + "code": "smtp", + "name": "SMTP server", + "role": "smtp", + "type": "service", + "docker_image": "trydirect/smtp", + "default_ports": [ + "25", + "465", + "587" + ], + "default_env": { + "SMTP_HOST": "smtp", + "SMTP_PORT": "25" + }, + "default_config_files": [] + }, + { + "_id": 50, + "code": "mariadb", + "name": "MariaDB", + "role": null, + "type": "service", + "docker_image": "trydirect/mariadb", + "default_ports": null, + "default_env": { + "MYSQL_HOST": "mysqldb", + "MYSQL_PORT": "3306", + "MYSQL_USER": "admin", + "MYSQL_DATABASE": "default", + "MYSQL_PASSWORD": "password-10" + }, + "default_config_files": [] + }, + { + "_id": 51, + "code": "apache", + "name": "Apache", + "role": null, + "type": "service", + "docker_image": "trydirect/apache", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + }, + { + "_id": 173, + "code": "phpmyadmin", + "name": "phpMyAdmin", + "role": null, + "type": "web", + "docker_image": "trydirect/phpmyadmin", + "default_ports": [ + "8082" + ], + "default_env": { + "MYSQL_HOST": "db", + "phpmyadmin_login": "root" + }, + "default_config_files": [] + }, + { + "_id": 181, + "code": "development_support", + "name": "Custom Optimization", + "role": null, + "type": "feature", + "docker_image": "trydirect/development_support", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + }, + { + "_id": 192, + "code": "portainer_ce_feature", + "name": "Portainer CE", + "role": "portainer-ce-feature", + "type": "feature", + "docker_image": "trydirect/portainer-ce", + "default_ports": [ + "8000", + "9000" + ], + "default_env": { + "portainer_user": "admin", + "PORTAINER_URL": "${commonDomain}:9000", + "PORTAINER_PORT": "9000", + "portainer_password": "password-10" + }, + "default_config_files": [] + }, + { + "_id": 205, + "code": "nginx_proxy_manager", + "name": "Nginx Proxy Manager", + "role": "nginx_proxy_manager", + "type": "feature", + "docker_image": "trydirect/nginx-proxy-manager", + "default_ports": [ + "80", + "81", + "443" + ], + "default_env": { + "npm_as_feature": "true", + "nginx_pm_user": "admin@example.com", + "nginx_pm_password": "changeme", + "gitlab_port": "8889" + }, + "default_config_files": [] + }, + { + "_id": 207, + "code": "mydumper", + "name": "MyDumper", + "role": "mydumper", + "type": "feature", + "docker_image": "mydumper", + "default_ports": null, + "default_env": {}, + "default_config_files": [] + } + ] +} \ No newline at end of file diff --git a/src/routes/marketplace/public.rs b/src/routes/marketplace/public.rs index 6f0a152f..532f5ab0 100644 --- a/src/routes/marketplace/public.rs +++ b/src/routes/marketplace/public.rs @@ -1,4 +1,7 @@ +use std::sync::Arc; + use crate::configuration::Settings; +use crate::connectors::user_service::UserServiceConnector; use crate::db; use crate::helpers::redact::{redact_sensitive_json_values, redact_yaml_string}; use crate::helpers::JsonResponse; @@ -280,6 +283,7 @@ pub async fn deploy_complete_handler( body: web::Json, pg_pool: web::Data, settings: web::Data, + user_service: web::Data>, ) -> Result { require_stacker_service_auth(&req)?; @@ -324,6 +328,56 @@ pub async fn deploy_complete_handler( ); }; + // Per-install billing: capture the previously-authorized charge now that + // the deploy has confirmed successful. Lookup is by deployment_hash on the + // authorization row (populated at install time); non-per_install rows + // simply won't match and this becomes a no-op. Non-fatal on capture + // failure — the sweeper reconciles stragglers. + match db::marketplace_billing::find_by_deployment_hash( + pg_pool.get_ref(), + &payload.deployment_hash, + ) + .await + { + Ok(Some(auth_row)) if auth_row.status == "authorized" => { + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + match user_service + .capture_install_charge( + &service_token, + &auth_row.authorization_id, + &payload.deployment_hash, + ) + .await + { + Ok(_handle) => { + if let Err(err) = db::marketplace_billing::mark_captured( + pg_pool.get_ref(), + &auth_row.authorization_id, + ) + .await + { + tracing::warn!( + "mark_captured DB write failed for {}: {}", + auth_row.authorization_id, + err + ); + } + } + Err(err) => tracing::warn!( + "capture_install_charge failed for {} (deploy already succeeded, sweeper will retry): {}", + auth_row.authorization_id, + err + ), + } + } + Ok(_) => {} // no per_install authorization for this deployment + Err(err) => tracing::warn!( + "find_by_deployment_hash failed for {}: {}", + payload.deployment_hash, + err + ), + } + let response = DeployCompleteResponse { success: true, template_id: template_id.to_string(), diff --git a/src/routes/marketplace/search.rs b/src/routes/marketplace/search.rs index 76325a92..45648f57 100644 --- a/src/routes/marketplace/search.rs +++ b/src/routes/marketplace/search.rs @@ -23,6 +23,7 @@ struct TemplatePricing { price: Option, billing_cycle: Option, required_plan_name: Option, + creator_name: Option, } async fn enrich_items_with_pricing(pg_pool: &PgPool, items: &mut [serde_json::Value]) { @@ -41,7 +42,7 @@ async fn enrich_items_with_pricing(pg_pool: &PgPool, items: &mut [serde_json::Va let pricing = sqlx::query_as::<_, TemplatePricing>( r#" - SELECT slug, price, billing_cycle, required_plan_name + SELECT slug, price, billing_cycle, required_plan_name, creator_name FROM stack_template WHERE slug = ANY($1) AND status = 'approved' "#, @@ -73,6 +74,9 @@ async fn enrich_items_with_pricing(pg_pool: &PgPool, items: &mut [serde_json::Va if let Some(ref plan) = p.required_plan_name { item["required_plan_name"] = serde_json::json!(plan); } + if let Some(ref creator) = p.creator_name { + item["creator_name"] = serde_json::json!(creator); + } } } } diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index d910c715..4e96698e 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -49,6 +49,9 @@ fn map_marketplace_access_error(err: services::MarketplaceAccessError) -> actix_ JsonResponse::::build() .internal_server_error("Failed to validate marketplace access") } + services::MarketplaceAccessError::NoPaymentMethod { .. } => { + JsonResponse::::build().payment_required(err.to_string()) + } services::MarketplaceAccessError::MissingUserToken | services::MarketplaceAccessError::InsufficientFeaturePlan | services::MarketplaceAccessError::InsufficientTemplatePlan { .. } @@ -1307,6 +1310,20 @@ fn apply_deploy_bundle( Ok(compose_content) } +/// Extract a compose file from `custom.marketplace_config_files` if present. +/// +/// Marketplace templates with YAML-embed definitions (ComposeYaml, StackerConfig) +/// store their compose YAML here rather than in the structured web/service/feature +/// arrays that `DcBuilder` reads. This helper lets `execute_deployment` fall +/// back to the embedded compose before resorting to `DcBuilder::build()`. +fn embedded_marketplace_compose(metadata: &serde_json::Value) -> Option { + let cf = metadata + .get("custom") + .and_then(|c| c.get("marketplace_config_files")) + .filter(|v| is_non_empty_json(v))?; + compose_content_from_config_files(cf).ok().flatten() +} + async fn load_project_template_version( pg_pool: &PgPool, project: &models::Project, @@ -1349,14 +1366,21 @@ async fn execute_deployment( sync_runtime_artifact_bundle(settings, &mut project) .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + // For marketplace templates with YAML-embed definitions (ComposeYaml, + // StackerConfig), the compose is stored in custom.marketplace_config_files + // rather than the structured web/service/feature arrays. Extract it + // before DcBuilder::new() moves `project`. + let marketplace_compose = embedded_marketplace_compose(&project.metadata); + let id = project.id; let dc = DcBuilder::new(project); - let fc = match deploy_compose { - Some(compose) => compose, - None => dc - .build() - .map_err(|err| JsonResponse::::build().internal_server_error(err))?, - }; + let fc = deploy_compose + .or(marketplace_compose) + .map(Ok) + .unwrap_or_else(|| { + dc.build() + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + })?; let mut new_public_key: Option = None; let mut bootstrap_private_key: Option = None; @@ -2275,8 +2299,8 @@ mod tests { use super::{ apply_deploy_bundle, build_runtime_artifact_bundle, compose_content_from_config_files, default_status_panel_npm_credentials, derive_public_ports_from_metadata, - ensure_trailing_newline, find_matching_hetzner_server, hetzner_server_ip, - preserve_marketplace_runtime_artifacts, resolve_provided_ssh_keypair, + embedded_marketplace_compose, ensure_trailing_newline, find_matching_hetzner_server, + hetzner_server_ip, preserve_marketplace_runtime_artifacts, resolve_provided_ssh_keypair, should_seed_default_status_panel_npm_credentials, sync_runtime_artifact_bundle, validate_min_cpu_requirement, validate_min_disk_requirement, validate_min_ram_requirement, HetznerIpv4, HetznerPublicNet, HetznerServer, @@ -3027,4 +3051,43 @@ mod tests { }); assert!(derive_public_ports_from_metadata(&malformed).is_empty()); } + + #[test] + fn embedded_marketplace_compose_extracts_from_config_files() { + let compose_yaml = "version: '3.8'\nservices:\n ghost:\n image: ghost:5-alpine\n"; + let metadata = json!({ + "custom": { + "marketplace_config_files": [ + {"name": "docker-compose.yml", "content": compose_yaml} + ] + } + }); + let result = embedded_marketplace_compose(&metadata); + assert_eq!(result.as_deref(), Some(compose_yaml)); + } + + #[test] + fn embedded_marketplace_compose_returns_none_when_empty() { + let metadata = json!({"custom": {"marketplace_config_files": []}}); + assert!(embedded_marketplace_compose(&metadata).is_none()); + + let metadata = json!({"custom": {}}); + assert!(embedded_marketplace_compose(&metadata).is_none()); + + let metadata = json!({}); + assert!(embedded_marketplace_compose(&metadata).is_none()); + } + + #[test] + fn embedded_marketplace_compose_ignores_non_compose_files() { + let metadata = json!({ + "custom": { + "marketplace_config_files": [ + {"name": ".env", "content": "KEY=value"}, + {"name": "nginx.conf", "content": "server {}"} + ] + } + }); + assert!(embedded_marketplace_compose(&metadata).is_none()); + } } diff --git a/src/services/install_authorization_sweeper.rs b/src/services/install_authorization_sweeper.rs new file mode 100644 index 00000000..bc80bb5e --- /dev/null +++ b/src/services/install_authorization_sweeper.rs @@ -0,0 +1,114 @@ +//! Background sweeper for expired per-install billing authorizations. +//! +//! An authorization can end up "stuck" in the `authorized` state if the +//! deploy pipeline never confirms via `deploy_complete_handler` — the +//! agent crashed, the server never came up, the user Ctrl-C'd before +//! confirmation, etc. The sweeper voids these once they pass their TTL +//! so the buyer isn't stuck holding a live authorization on their card +//! and stacker's ledger reconciles with user_service's. +//! +//! Correctness invariant: this sweeper is a **cleanup tool**, not the +//! source of truth. user_service's own `expires_at` on the underlying +//! payment intent auto-voids independently — if the sweeper is down or +//! never runs, the authorization still lapses at the payment provider. +//! What the sweeper adds is prompt DB-state reconciliation. + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use sqlx::PgPool; + +use crate::connectors::errors::ConnectorError; +use crate::connectors::user_service::UserServiceConnector; +use crate::db; + +/// How often the sweeper checks for expired authorizations. +const TICK: Duration = Duration::from_secs(60); + +/// How far past `expires_at` we let a row linger before voiding, giving +/// deploy-complete a grace window to arrive after the TTL fires. +const GRACE_SECS: i64 = 300; + +/// How many rows we void per tick. Voids are one-round-trip each and +/// user_service will rate-limit us if we're too aggressive. +const BATCH_LIMIT: i64 = 500; + +pub fn spawn( + pg_pool: PgPool, + user_service: Arc, + per_install_enabled: bool, +) { + if !per_install_enabled { + tracing::info!("install_authorization_sweeper skipped: per_install billing disabled"); + return; + } + tokio::spawn(async move { + tracing::info!("install_authorization_sweeper started (tick={:?})", TICK); + loop { + tokio::time::sleep(TICK).await; + if let Err(err) = tick_once(&pg_pool, user_service.as_ref()).await { + tracing::warn!("install_authorization_sweeper tick error: {}", err); + } + } + }); +} + +async fn tick_once(pool: &PgPool, user_service: &dyn UserServiceConnector) -> Result<(), String> { + let cutoff = Utc::now() - chrono::Duration::seconds(GRACE_SECS); + let expired = + db::marketplace_billing::list_expired_authorized(pool, cutoff, BATCH_LIMIT).await?; + if expired.is_empty() { + return Ok(()); + } + tracing::info!( + "install_authorization_sweeper: voiding {} expired authorization(s)", + expired.len() + ); + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + for row in expired { + match user_service + .void_install_charge(&service_token, &row.authorization_id, "expired") + .await + { + Ok(_) => { + if let Err(err) = + db::marketplace_billing::mark_voided(pool, &row.authorization_id, "expired") + .await + { + tracing::warn!( + "sweeper mark_voided DB error for {}: {}", + row.authorization_id, + err + ); + } + } + Err(ConnectorError::Conflict(_)) => { + // user_service says the authorization is not in the + // `authorized` state — most likely already captured out + // of band. Reconcile our local view. + tracing::info!( + "sweeper reconciling {} as captured (user_service returned 409)", + row.authorization_id + ); + if let Err(err) = + db::marketplace_billing::mark_captured(pool, &row.authorization_id).await + { + tracing::warn!( + "sweeper mark_captured DB error for {}: {}", + row.authorization_id, + err + ); + } + } + Err(err) => { + tracing::warn!( + "sweeper void failed for {}: {} (will retry next tick)", + row.authorization_id, + err + ); + } + } + } + Ok(()) +} diff --git a/src/services/marketplace_access.rs b/src/services/marketplace_access.rs index 0f1d2cd5..b269703f 100644 --- a/src/services/marketplace_access.rs +++ b/src/services/marketplace_access.rs @@ -9,8 +9,16 @@ const MARKETPLACE_INSTALL_MIN_PLAN: &str = "professional"; pub enum MarketplaceAccessError { MissingUserToken, InsufficientFeaturePlan, - InsufficientTemplatePlan { required_plan: String }, + InsufficientTemplatePlan { + required_plan: String, + }, TemplateNotOwned, + /// The template is priced per-install and the user has no viable + /// payment method on file (declined at the `can_charge` probe, before + /// any authorize attempt). + NoPaymentMethod { + reason: String, + }, ValidationFailed(String), } @@ -32,6 +40,11 @@ impl std::fmt::Display for MarketplaceAccessError { Self::TemplateNotOwned => { write!(f, "You must purchase this template before installing it") } + Self::NoPaymentMethod { reason } => write!( + f, + "A valid payment method is required to install this template ({})", + reason + ), Self::ValidationFailed(reason) => { write!(f, "Failed to validate marketplace access: {}", reason) } @@ -104,6 +117,30 @@ pub async fn validate_marketplace_template_access( } } + let is_per_install = template + .billing_cycle + .as_deref() + .map(|c| c.trim().eq_ignore_ascii_case("per_install")) + .unwrap_or(false); + + // Per-install templates skip the ownership check entirely — there is no + // permanent purchase for these. Instead we probe the user's payment + // capability so `stacker install` fails fast with a clear 402 if the + // user has no card on file, rather than deep inside the install handler + // after the actual authorize attempt. + if is_per_install { + let capability = user_service + .can_charge(user_token) + .await + .map_err(validation_failed)?; + if !capability.can_charge { + return Err(MarketplaceAccessError::NoPaymentMethod { + reason: capability.reason.unwrap_or_else(|| "unknown".to_string()), + }); + } + return Ok(()); + } + let no_price = template.price.map(|p| p <= 0.0).unwrap_or(true); let no_plan = template .required_plan_name @@ -126,18 +163,47 @@ pub async fn validate_marketplace_template_access( mod tests { use super::*; use crate::connectors::user_service::{ - CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProduct, - UserProfile, + AuthorizationHandle, BillingCapability, CategoryInfo, PlanDefinition, ProductInfo, + StackResponse, UserPlanInfo, UserProduct, UserProfile, }; use crate::connectors::ConnectorError; use async_trait::async_trait; - use std::collections::{HashMap, HashSet}; + use std::collections::{HashMap, HashSet, VecDeque}; + use std::sync::Mutex; use uuid::Uuid; + /// Recorded billing-side calls for post-hoc assertion. Used by the + /// per-install billing tests to check that the access gate called + /// `can_charge` at the right moment and never invoked authorize/capture/void + /// (those are the install handler's responsibility). + #[derive(Debug, Clone, PartialEq)] + enum CapturedCall { + CanCharge, + Authorize { + template_id: Uuid, + amount_minor: i64, + currency: String, + idempotency_key: String, + }, + Capture { + authorization_id: String, + deployment_hash: String, + }, + Void { + authorization_id: String, + reason: String, + }, + } + struct TestUserService { plans: HashMap, owned_identifiers: HashSet, fail_plan_check: bool, + can_charge_result: Mutex, + authorize_responses: Mutex>>, + capture_responses: Mutex>>, + void_responses: Mutex>>, + captured_calls: Mutex>, } impl TestUserService { @@ -152,6 +218,14 @@ mod tests { .map(|identifier| (*identifier).to_string()) .collect(), fail_plan_check: false, + can_charge_result: Mutex::new(BillingCapability { + can_charge: true, + reason: None, + }), + authorize_responses: Mutex::new(VecDeque::new()), + capture_responses: Mutex::new(VecDeque::new()), + void_responses: Mutex::new(VecDeque::new()), + captured_calls: Mutex::new(Vec::new()), } } @@ -161,6 +235,23 @@ mod tests { self.fail_plan_check = true; self } + + /// Preload the can-charge response used by the per_install access-gate + /// branch. Default is `{can_charge: true, reason: None}`. + #[allow(dead_code)] + fn with_can_charge(self, can_charge: bool, reason: Option<&str>) -> Self { + *self.can_charge_result.lock().unwrap() = BillingCapability { + can_charge, + reason: reason.map(str::to_string), + }; + self + } + + /// Snapshot the CapturedCall vector for assertions. + #[allow(dead_code)] + fn calls(&self) -> Vec { + self.captured_calls.lock().unwrap().clone() + } } #[async_trait] @@ -256,6 +347,102 @@ mod tests { ) -> Result, ConnectorError> { unimplemented!() } + + async fn get_catalog_application( + &self, + _user_token: &str, + _code: &str, + ) -> Result, ConnectorError> { + unimplemented!() + } + + async fn can_charge(&self, _user_token: &str) -> Result { + self.captured_calls + .lock() + .unwrap() + .push(CapturedCall::CanCharge); + Ok(self.can_charge_result.lock().unwrap().clone()) + } + + async fn authorize_install_charge( + &self, + _user_token: &str, + template_id: &Uuid, + amount_minor: i64, + currency: &str, + idempotency_key: &str, + ) -> Result { + self.captured_calls + .lock() + .unwrap() + .push(CapturedCall::Authorize { + template_id: *template_id, + amount_minor, + currency: currency.to_string(), + idempotency_key: idempotency_key.to_string(), + }); + self.authorize_responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| { + Ok(AuthorizationHandle { + authorization_id: format!("test-auth-{}", idempotency_key), + amount_minor, + currency: currency.to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), + status: "authorized".to_string(), + }) + }) + } + + async fn capture_install_charge( + &self, + _auth_token: &str, + authorization_id: &str, + deployment_hash: &str, + ) -> Result { + self.captured_calls + .lock() + .unwrap() + .push(CapturedCall::Capture { + authorization_id: authorization_id.to_string(), + deployment_hash: deployment_hash.to_string(), + }); + self.capture_responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| { + Ok(AuthorizationHandle { + authorization_id: authorization_id.to_string(), + amount_minor: 0, + currency: "USD".to_string(), + expires_at: None, + status: "captured".to_string(), + }) + }) + } + + async fn void_install_charge( + &self, + _auth_token: &str, + authorization_id: &str, + reason: &str, + ) -> Result<(), ConnectorError> { + self.captured_calls + .lock() + .unwrap() + .push(CapturedCall::Void { + authorization_id: authorization_id.to_string(), + reason: reason.to_string(), + }); + self.void_responses + .lock() + .unwrap() + .pop_front() + .unwrap_or(Ok(())) + } } fn test_user() -> models::User { @@ -489,4 +676,132 @@ mod tests { Err(MarketplaceAccessError::ValidationFailed(_)) )); } + + // ── per_install access-gate tests ─────────────────────────────── + // + // These pin the semantics documented in the plan: a template with + // billing_cycle="per_install" bypasses ownership entirely, but must + // still clear the feature-plan gate and expose a valid payment method. + + fn per_install_template() -> models::StackTemplate { + models::StackTemplate { + slug: "paid-per-install".to_string(), + product_id: Some(2001), + price: Some(9.99), + billing_cycle: Some("per_install".to_string()), + required_plan_name: None, + ..Default::default() + } + } + + #[tokio::test] + async fn per_install_template_skips_ownership_and_passes_when_can_charge() { + // No `owned_identifiers` on purpose — ownership must NOT be consulted + // for per_install templates. + let svc = Arc::new(TestUserService::new(&[("professional", true)], &[])); + let user_service: Arc = svc.clone(); + + let result = validate_marketplace_template_access( + &user_service, + &test_user(), + &per_install_template(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result); + let calls = svc.calls(); + assert!( + calls.contains(&CapturedCall::CanCharge), + "gate must consult can_charge for per_install templates: {:?}", + calls + ); + assert!( + !calls + .iter() + .any(|c| matches!(c, CapturedCall::Authorize { .. })), + "gate must NOT authorize — the install handler does that: {:?}", + calls + ); + } + + #[tokio::test] + async fn per_install_template_rejects_when_no_payment_method() { + let svc = Arc::new( + TestUserService::new(&[("professional", true)], &[]) + .with_can_charge(false, Some("no_payment_method")), + ); + let user_service: Arc = svc.clone(); + + let result = validate_marketplace_template_access( + &user_service, + &test_user(), + &per_install_template(), + ) + .await; + + assert!(matches!( + result, + Err(MarketplaceAccessError::NoPaymentMethod { ref reason }) + if reason == "no_payment_method" + )); + } + + #[tokio::test] + async fn per_install_template_still_requires_feature_plan() { + // Team/Pro feature-plan gate must precede the per_install branch — + // a user without the "professional" tier gets rejected before we + // ever probe payment capability. + let svc = Arc::new(TestUserService::new(&[("professional", false)], &[])); + let user_service: Arc = svc.clone(); + + let result = validate_marketplace_template_access( + &user_service, + &test_user(), + &per_install_template(), + ) + .await; + + assert!(matches!( + result, + Err(MarketplaceAccessError::InsufficientFeaturePlan) + )); + assert!( + !svc.calls().contains(&CapturedCall::CanCharge), + "can_charge must not be probed when the feature-plan gate fails" + ); + } + + #[tokio::test] + async fn one_time_template_ownership_check_unchanged() { + // Regression: `one_time` templates (umami-shape) still use the + // ownership gate after the split-out per_install branch. + // umami's row carries required_plan_name="free", which the gate + // still consults via user_has_plan — every user has the "free" + // tier in production, so we grant it in the mock too. + let svc = Arc::new(TestUserService::new( + &[("professional", true), ("free", true)], + &[], + )); + let user_service: Arc = svc.clone(); + let template = models::StackTemplate { + slug: "umami".to_string(), + product_id: Some(-961115967), + price: Some(10.0), + billing_cycle: Some("one_time".to_string()), + required_plan_name: Some("free".to_string()), + ..Default::default() + }; + + let result = + validate_marketplace_template_access(&user_service, &test_user(), &template).await; + + assert!(matches!( + result, + Err(MarketplaceAccessError::TemplateNotOwned) + )); + assert!( + !svc.calls().contains(&CapturedCall::CanCharge), + "can_charge must NOT be probed for one_time templates" + ); + } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 347deb66..e505e57d 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod env_model; pub mod explain; pub mod grpc_pipe; pub mod handoff; +pub mod install_authorization_sweeper; pub mod log_cache; pub mod marketplace_access; pub mod marketplace_assets; diff --git a/src/startup.rs b/src/startup.rs index 9e512ce3..b044bbb3 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -107,6 +107,15 @@ pub async fn run( connectors::init_user_service(&settings.connectors, api_pool.clone()); let dockerhub_connector = connectors::init_dockerhub(&settings.connectors).await; let install_service_connector = connectors::init_install_service(&settings.connectors); + + // Start the per-install billing sweeper. No-ops when the feature flag + // is off; when on, ticks every 60s to void expired-but-uncaptured + // authorizations so stacker's ledger reconciles with user_service. + crate::services::install_authorization_sweeper::spawn( + api_pool.get_ref().clone(), + user_service_connector.get_ref().clone(), + settings.per_install_billing_enabled, + ); let payout_provider = crate::services::init_payout_provider(&settings.payouts) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?; let payout_provider = web::Data::new(payout_provider); diff --git a/tests/features/marketplace_per_install_billing.feature.pending b/tests/features/marketplace_per_install_billing.feature.pending new file mode 100644 index 00000000..1d3157c5 --- /dev/null +++ b/tests/features/marketplace_per_install_billing.feature.pending @@ -0,0 +1,64 @@ +# --- +# STATUS: pending — file extension `.feature.pending` keeps this out of the +# auto-discovered cucumber run in `tests/bdd.rs` (which globs `tests/features` +# for `.feature` files). Rename to `.feature` once the step definitions in +# `tests/steps/marketplace.rs` are extended to cover the billing scenarios +# (wiremock routes for authorize/capture/void, DB assertion helpers on +# `marketplace_install_authorization`, and per_install feature-flag setup +# in `ensure_shared_app_available()`). +# --- +Feature: Per-install billing for marketplace templates + Templates with billing_cycle="per_install" charge the buyer on each + install via a two-phase authorize/capture with user_service. This + contrasts with legacy "one_time" templates that check a permanent + ownership flag. The invariant: no money moves without a corresponding + install artifact, and retries with the same idempotency key never + double-charge. + + Background: + Given the per_install billing feature flag is enabled + And a marketplace template with slug "paid-per-install" and billing_cycle "per_install" and price 9.99 USD + And I am authenticated as a user on the "professional" plan + And the user_service billing mock is running + + Scenario: Successful per-install charge captures on deploy-complete + Given the user_service billing mock will authorize any charge + And the user_service billing mock will capture any authorization + When I POST to "/api/templates/paid-per-install/install" with idempotency key "idem-ok-1" and a deploy body + Then the response status is 200 + And the response item has "authorization.status" equal to "authorized" + And the marketplace_install_authorization row for the response project has status "authorized" + When the agent posts "/api/marketplace/deploy-complete" for the resulting deployment + Then the response status is 200 + And the marketplace_install_authorization row for the project has status "captured" + And the user_service billing mock recorded exactly 1 authorize and 1 capture + + Scenario: Declined per-install charge does not create a project + Given the user_service billing mock will decline authorizations with reason "card_declined" + When I POST to "/api/templates/paid-per-install/install" with idempotency key "idem-decl-1" + Then the response status is 402 + And no project exists for the current user with source template "paid-per-install" + And no marketplace_install_authorization row exists for idempotency key "idem-decl-1" + + Scenario: Retry with same idempotency key does not double-charge + Given the user_service billing mock will authorize any charge + When I POST to "/api/templates/paid-per-install/install" with idempotency key "idem-retry-1" + And I POST to "/api/templates/paid-per-install/install" with idempotency key "idem-retry-1" + Then both responses have the same "authorization.authorization_id" + And both responses have the same "project.id" + And the user_service billing mock recorded exactly 1 authorize + + Scenario: Deploy failure voids the authorization + Given the user_service billing mock will authorize any charge + And any deployment will fail with reason "agent_unreachable" + When I POST to "/api/templates/paid-per-install/install" with idempotency key "idem-fail-1" and a deploy body + Then the marketplace_install_authorization row for idempotency key "idem-fail-1" eventually has status "voided" + And the user_service billing mock recorded exactly 1 void with reason starting "install_failed" or "expired" + + Scenario: Existing one_time templates are unaffected + Given a marketplace template with slug "umami" and billing_cycle "one_time" and price 10.00 USD + And I own the template "umami" + When I POST to "/api/templates/umami/install" with idempotency key "idem-umami-1" + Then the response status is 200 + And no marketplace_install_authorization row exists for the response project + And the user_service billing mock recorded 0 authorize calls diff --git a/tests/marketplace_integration.rs b/tests/marketplace_integration.rs index f7c21cdd..d68e6bb7 100644 --- a/tests/marketplace_integration.rs +++ b/tests/marketplace_integration.rs @@ -204,6 +204,7 @@ fn test_webhook_payload_for_template_approval() { "supported_clouds": ["hetzner"], "min_ram_mb": 2048 })), + ..Default::default() }; // Verify payload has all required fields for approval @@ -255,6 +256,7 @@ fn test_webhook_payload_for_template_update_price() { "supported_os": ["ubuntu-22.04"], "min_disk_gb": 20 })), + ..Default::default() }; assert_eq!(payload.action, "template_updated"); @@ -298,6 +300,7 @@ fn test_webhook_payload_for_template_rejection() { next_action_hint: None, vendor_email: None, infrastructure_requirements: None, + ..Default::default() }; assert_eq!(payload.action, "template_rejected"); @@ -339,6 +342,7 @@ fn test_webhook_payload_for_template_needs_changes() { ), vendor_email: None, infrastructure_requirements: Some(serde_json::json!({"min_ram_mb": 2048})), + ..Default::default() }; assert_eq!(payload.action, "template_needs_changes"); diff --git a/website/dist/index.html b/website/dist/index.html index ec9de490..9e9c2dde 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -11,6 +11,15 @@ + + + diff --git a/website/src/index.html b/website/src/index.html index ec9de490..9e9c2dde 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -11,6 +11,15 @@ + + +