From 9ea7117f7e73a046f9caff39ba78fe57681f6d97 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 17 Jul 2026 12:07:56 +0300 Subject: [PATCH 01/29] 'stacker service add' password generator, stacker pipe, container probing --- src/cli/compose_service_sync.rs | 63 ++++++++ src/cli/config_bundle.rs | 57 +++++++ src/cli/generator/compose.rs | 37 ++++- src/cli/service_catalog.rs | 185 +++++++++++++++++++++-- src/console/commands/cli/agent.rs | 2 + src/console/commands/cli/deploy.rs | 3 + src/console/commands/cli/pipe.rs | 107 +++++++++++++ src/console/commands/cli/service.rs | 226 ++++++++++++++++++++++++++-- src/helpers/stacker_labels.rs | 25 +++ 9 files changed, 682 insertions(+), 23 deletions(-) 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/generator/compose.rs b/src/cli/generator/compose.rs index beb5b156..f4624c64 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -189,12 +189,24 @@ fn build_app_service(config: &StackerConfig) -> 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", ); @@ -505,6 +517,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); 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/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/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/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/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/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, From d93305e17a34511c97f23c32061050fc74c07fb5 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 17 Jul 2026 15:56:46 +0300 Subject: [PATCH 02/29] hint command fix --- src/bin/stacker.rs | 10 +- src/cli/install_runner.rs | 20 ++ src/console/commands/cli/marketplace.rs | 129 ++++++++-- src/routes/marketplace/install.rs | 323 +++++++++++++++++++----- 4 files changed, 402 insertions(+), 80 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b36d1cd0..29ebaffe 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -253,6 +253,12 @@ 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, }, /// Show container logs Logs { @@ -2706,9 +2712,11 @@ fn get_command( json, domain, set_values, + key, + key_id, } => 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, ), ), StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd { diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 52c1a71b..640c54d0 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1872,6 +1872,10 @@ 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 @@ -3538,6 +3542,22 @@ 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/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 9758bbec..71868839 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -173,6 +173,8 @@ pub struct MarketplaceInstallCommand { json: bool, domain: Option, set_values: Vec, + key_name: Option, + key_id: Option, } impl MarketplaceInstallCommand { @@ -184,6 +186,8 @@ impl MarketplaceInstallCommand { json: bool, domain: Option, set_values: Vec, + key_name: Option, + key_id: Option, ) -> Self { Self { template, @@ -193,6 +197,8 @@ impl MarketplaceInstallCommand { json, domain, set_values, + key_name, + key_id, } } } @@ -276,6 +282,8 @@ impl CallableTrait for MarketplaceInstallCommand { &self.template, self.name.as_deref(), self.domain.as_deref(), + self.key_name.as_deref(), + self.key_id, ) .await })?; @@ -398,7 +406,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 +689,8 @@ async fn generate_minimal_install_config( template: &str, name: Option<&str>, domain: Option<&str>, + key_name: Option<&str>, + key_id: Option, ) -> Result { use crate::cli::config_parser::{CloudConfig, DeployTarget}; @@ -703,11 +713,33 @@ 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(), - ) - })?; + + // 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(), + ) + })? + }; let provider = cloud_provider_from_code(&cloud_info.provider).ok_or_else(|| { CliError::ConfigValidation(format!( "Unsupported cloud provider '{}' on default cloud '{}'.", @@ -1017,16 +1049,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"); @@ -1039,6 +1068,14 @@ fn display_plan(template: &MarketplaceTemplate) -> String { 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 +1092,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::{ @@ -1215,4 +1252,64 @@ mod tests { 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 = None; + assert_eq!(display_plan(&t), "$5.00/mo"); + } } diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index c3ba09a4..a0599fa4 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -62,73 +62,107 @@ 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 - })); +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", + } + )) +} - 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| { - 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 +186,67 @@ 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 + ) + }) +} + fn catalog_application_project_form( application: &serde_json::Value, slug: &str, @@ -621,7 +710,7 @@ pub async fn install_handler( mod tests { use super::{ build_project_form, catalog_application_project_form, - ensure_catalog_application_has_deploy_context, + ensure_catalog_application_has_deploy_context, validate_installable_form, }; use crate::{forms::project::Payload, models}; use serde_json::{json, Map}; @@ -770,6 +859,114 @@ mod tests { 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(); From 28dc922dd3707bea589a022cfc495e59b916b42f Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 17 Jul 2026 17:10:08 +0300 Subject: [PATCH 03/29] install from marketplace compose fix, cloud params in cli command --- src/bin/stacker.rs | 14 +++- src/cli/ai_scenarios.rs | 2 +- src/cli/config_parser.rs | 2 +- src/cli/install_runner.rs | 8 +- src/cli/stacker_client.rs | 6 +- src/console/commands/cli/config.rs | 40 +++++++++- src/console/commands/cli/init.rs | 10 ++- src/console/commands/cli/marketplace.rs | 99 ++++++++++++++++++++----- src/routes/project/deploy.rs | 76 +++++++++++++++++-- 9 files changed, 214 insertions(+), 43 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 29ebaffe..53b591db 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -259,6 +259,15 @@ enum StackerCommands { /// 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 { @@ -2714,9 +2723,12 @@ fn get_command( 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, key, key_id, + 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/config_parser.rs b/src/cli/config_parser.rs index 52b2d372..25788264 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -2237,7 +2237,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/install_runner.rs b/src/cli/install_runner.rs index 640c54d0..adcfed15 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1913,7 +1913,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 @@ -3059,7 +3059,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", @@ -3077,7 +3077,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": "", @@ -3098,7 +3098,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", diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index f8464543..4d351cf0 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -3937,7 +3937,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 +4310,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 +4324,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/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/init.rs b/src/console/commands/cli/init.rs index acd2d90f..2b6ab7cd 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", } } @@ -2025,7 +2025,9 @@ impl CallableTrait for InitCommand { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); let applied = - crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; + crate::console::commands::cli::config::run_setup_cloud_interactive( + &path_str, None, + )?; for item in applied { eprintln!(" - {}", item); } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 71868839..a10f90c6 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -175,6 +175,9 @@ pub struct MarketplaceInstallCommand { set_values: Vec, key_name: Option, key_id: Option, + region: Option, + size: Option, + provider: Option, } impl MarketplaceInstallCommand { @@ -188,6 +191,9 @@ impl MarketplaceInstallCommand { set_values: Vec, key_name: Option, key_id: Option, + region: Option, + size: Option, + provider: Option, ) -> Self { Self { template, @@ -199,6 +205,54 @@ impl MarketplaceInstallCommand { 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)); + } } } } @@ -258,6 +312,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 @@ -284,6 +339,9 @@ impl CallableTrait for MarketplaceInstallCommand { 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 })?; @@ -301,19 +359,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) @@ -691,6 +737,9 @@ async fn generate_minimal_install_config( 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}; @@ -740,22 +789,27 @@ async fn generate_minimal_install_config( ) })? }; - let provider = cloud_provider_from_code(&cloud_info.provider).ok_or_else(|| { + // 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 '{}' on default cloud '{}'.", - cloud_info.provider, cloud_info.name + "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, @@ -764,6 +818,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. diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index d910c715..e0b28bdc 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -1307,6 +1307,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 +1363,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 +2296,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 +3048,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()); + } } From 9469bc1c4ff193547cf215c45b1132ff7b685c27 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 19 Jul 2026 11:39:30 +0300 Subject: [PATCH 04/29] feat: add --name flag to pipe create for non-interactive mode --- docs/DAG_PIPES_PART1_CLI_GUIDE.md | 28 ++++++ src/bin/stacker.rs | 38 +++++++- src/console/commands/cli/pipe.rs | 142 +++++++++++++++++++++++++++--- 3 files changed, 196 insertions(+), 12 deletions(-) diff --git a/docs/DAG_PIPES_PART1_CLI_GUIDE.md b/docs/DAG_PIPES_PART1_CLI_GUIDE.md index 078d8879..1cd6fc21 100644 --- a/docs/DAG_PIPES_PART1_CLI_GUIDE.md +++ b/docs/DAG_PIPES_PART1_CLI_GUIDE.md @@ -232,10 +232,38 @@ stacker pipe trigger \ | `--ai` | `create` | Use AI for smart field matching | | `--no-ai` | `create` | Use deterministic matching only | | `--manual` | `create` | Skip auto-matching entirely | +| `--source-endpoint "METHOD /path"` | `create` | Specify the source endpoint by hand (skips discovery) | +| `--target-endpoint "METHOD /path"` | `create` | Specify the target endpoint by hand (skips discovery) | +| `--source-fields a,b,c` | `create` | Source field names for the manual endpoint | +| `--target-fields x,y,z` | `create` | Target field names for the manual endpoint | | `--limit 50` | `history` | Show more results | --- +## Manual endpoints (skip discovery) + +Endpoint discovery probes the running app over HTTP, which needs the app to be +reachable and to expose a recognizable API. When that doesn't work — or you +already know the endpoints — declare them directly instead: + +```bash +stacker pipe create directus chatwoot \ + --source-endpoint "GET /items/articles" \ + --target-endpoint "POST /api/v1/accounts/1/conversations" \ + --source-fields title,body \ + --target-fields subject,content +``` + +- **Both** `--source-endpoint` and `--target-endpoint` must be given together. +- When both are set, `pipe create` does **no probing at all** — so it works even + if the app isn't deployed or can't be reached. +- Format is `"METHOD /path"` (e.g. `"POST /api/v1/items"`); a bare `/path` + defaults to `GET`. +- `--source-fields` / `--target-fields` are comma-separated and drive field + mapping just like discovered fields would. + +--- + ## Trigger Types Explained | Type | How it works | Best for | diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 53b591db..5a1655ba 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -1232,6 +1232,22 @@ enum PipeCommands { /// Use ML-based field matching (n-gram cosine similarity) #[arg(long, conflicts_with_all = ["ai", "no_ai"])] ml: bool, + /// Source endpoint as "METHOD /path" (e.g. "POST /api/v1/webhook"). + /// Requires --target-endpoint; when both are set, discovery is skipped. + #[arg(long)] + source_endpoint: Option, + /// Target endpoint as "METHOD /path" (e.g. "POST /api/v1/items"). + #[arg(long)] + target_endpoint: Option, + /// Comma-separated source field names (used with --source-endpoint). + #[arg(long, value_delimiter = ',')] + source_fields: Vec, + /// Comma-separated target field names (used with --target-endpoint). + #[arg(long, value_delimiter = ',')] + target_fields: Vec, + /// Pipe name (skips interactive prompt) + #[arg(long)] + name: Option, /// Output in JSON format #[arg(long)] json: bool, @@ -2434,10 +2450,27 @@ fn get_command( ai, no_ai, ml, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, json, deployment, } => Box::new(pipe::PipeCreateCommand::new( - source, target, manual, ai, no_ai, ml, json, deployment, + source, + target, + manual, + ai, + no_ai, + ml, + json, + deployment, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, )), PipeCommands::List { json, deployment } => { Box::new(pipe::PipeListCommand::new(json, deployment)) @@ -2728,7 +2761,8 @@ fn get_command( provider, } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceInstallCommand::new( - template, name, file, force, json, domain, set_values, key, key_id, region, size, provider, + 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/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 239b1e94..82aa3f20 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -2147,9 +2147,17 @@ pub struct PipeCreateCommand { pub ml: bool, pub json: bool, pub deployment: Option, + /// Manual source endpoint "METHOD /path"; when both source+target are set, + /// discovery is skipped entirely (Fix 3). + pub source_endpoint: Option, + pub target_endpoint: Option, + pub source_fields: Vec, + pub target_fields: Vec, + pub name: Option, } impl PipeCreateCommand { + #[allow(clippy::too_many_arguments)] pub fn new( source: String, target: String, @@ -2159,6 +2167,11 @@ impl PipeCreateCommand { ml: bool, json: bool, deployment: Option, + source_endpoint: Option, + target_endpoint: Option, + source_fields: Vec, + target_fields: Vec, + name: Option, ) -> Self { Self { source, @@ -2169,8 +2182,48 @@ impl PipeCreateCommand { ml, json, deployment, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, } } + + /// True when both endpoints are explicitly specified — skip all discovery. + fn manual_endpoints(&self) -> bool { + self.source_endpoint.is_some() && self.target_endpoint.is_some() + } +} + +/// Parse a manual endpoint spec: `"METHOD /path"` (e.g. `"POST /api/v1/items"`), +/// or a bare `"/path"` which defaults to GET. +fn parse_endpoint_spec(spec: &str) -> Result<(String, String), CliError> { + let spec = spec.trim(); + let (first, rest) = match spec.split_once(char::is_whitespace) { + Some((m, p)) => (m.trim(), p.trim()), + None => ("GET", spec), + }; + if !rest.starts_with('/') { + return Err(CliError::ConfigValidation(format!( + "Invalid endpoint '{spec}'. Use 'METHOD /path', e.g. 'POST /api/v1/items'." + ))); + } + Ok((first.to_uppercase(), rest.to_string())) +} + +/// Build a `SelectableOperation` from a manual endpoint spec + field list. +fn manual_operation(spec: &str, fields: &[String]) -> Result { + let (method, path) = parse_endpoint_spec(spec)?; + Ok(SelectableOperation { + container: None, + adapter: None, + method, + path, + summary: "manually specified endpoint".to_string(), + fields: fields.to_vec(), + sample: None, + }) } #[derive(Debug, Clone)] @@ -2843,6 +2896,51 @@ mod selectable_operation_tests { use serde_json::json; use tempfile::tempdir; + #[test] + fn parse_endpoint_spec_method_and_path() { + assert_eq!( + parse_endpoint_spec("POST /api/v1/items").unwrap(), + ("POST".to_string(), "/api/v1/items".to_string()) + ); + // Lowercase method is normalized; extra whitespace tolerated. + assert_eq!( + parse_endpoint_spec(" get /health ").unwrap(), + ("GET".to_string(), "/health".to_string()) + ); + } + + #[test] + fn parse_endpoint_spec_bare_path_defaults_to_get() { + assert_eq!( + parse_endpoint_spec("/graphql").unwrap(), + ("GET".to_string(), "/graphql".to_string()) + ); + } + + #[test] + fn parse_endpoint_spec_rejects_non_path() { + assert!(parse_endpoint_spec("POST items").is_err()); + assert!(parse_endpoint_spec("nonsense").is_err()); + } + + #[test] + fn manual_operation_carries_fields_and_no_adapter() { + let op = manual_operation( + "POST /api/v1/webhook", + &["name".to_string(), "email".to_string()], + ) + .unwrap(); + assert_eq!(op.method, "POST"); + assert_eq!(op.path, "/api/v1/webhook"); + assert_eq!(op.fields, vec!["name".to_string(), "email".to_string()]); + assert!(op.adapter.is_none()); + assert!(op.container.is_none()); + // Feeds the existing template builder as a plain {path, method}. + let tmpl = template_endpoint_for_operation(&op); + assert_eq!(tmpl["method"], "POST"); + assert_eq!(tmpl["path"], "/api/v1/webhook"); + } + #[test] fn extract_operations_includes_html_forms_and_container() { let info = AgentCommandInfo { @@ -3146,12 +3244,22 @@ impl CallableTrait for PipeCreateCommand { }; let create_protocols = default_pipe_create_protocols(); + // Fix 3: manual endpoints. Both sides must be given together; when set, + // discovery is skipped entirely and the pipe is built from the flags — + // no probing, so it works even if the app can't be reached. + if self.source_endpoint.is_some() != self.target_endpoint.is_some() { + return Err(Box::new(CliError::ConfigValidation( + "--source-endpoint and --target-endpoint must be provided together.".to_string(), + ))); + } + let manual_mode = self.manual_endpoints(); + let source_adapter_meta = builtin_adapter_for_selector(&self.source, PipeAdapterRole::Source); let target_adapter_meta = builtin_adapter_for_selector(&self.target, PipeAdapterRole::Target); - let source_run = if source_adapter_meta.is_none() { + let source_run = if !manual_mode && source_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for source '{}'...", @@ -3184,7 +3292,7 @@ impl CallableTrait for PipeCreateCommand { } else { None }; - let target_run = if target_adapter_meta.is_none() { + let target_run = if !manual_mode && target_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for target '{}'...", @@ -3256,13 +3364,23 @@ impl CallableTrait for PipeCreateCommand { return Ok(()); } - // Step 2: Extract discovered endpoints - let source_ops = if let Some(metadata) = &source_adapter_meta { + // Step 2: Extract endpoints — manual flags take precedence over discovery. + let source_ops = if manual_mode { + vec![manual_operation( + self.source_endpoint.as_deref().expect("source endpoint"), + &self.source_fields, + )?] + } else if let Some(metadata) = &source_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&source_run.as_ref().expect("source discovery").info) }; - let target_ops = if let Some(metadata) = &target_adapter_meta { + let target_ops = if manual_mode { + vec![manual_operation( + self.target_endpoint.as_deref().expect("target endpoint"), + &self.target_fields, + )?] + } else if let Some(metadata) = &target_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&target_run.as_ref().expect("target discovery").info) @@ -3421,11 +3539,15 @@ impl CallableTrait for PipeCreateCommand { }; // Step 6: Ask for pipe name - let default_name = format!("{}-to-{}", self.source, self.target); - let pipe_name: String = dialoguer::Input::new() - .with_prompt("Pipe name") - .default(default_name) - .interact_text()?; + let pipe_name: String = if let Some(name) = &self.name { + name.clone() + } else { + let default_name = format!("{}-to-{}", self.source, self.target); + dialoguer::Input::new() + .with_prompt("Pipe name") + .default(default_name) + .interact_text()? + }; // Step 7: Create template via API — include matching metadata in config let mut config = serde_json::json!({"retry_count": 3}); From eaa009a0df4eda18eb1edeba3be28a89db8dcbe3 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 19 Jul 2026 13:48:11 +0300 Subject: [PATCH 05/29] ci: add agent-executor Docker build job --- .github/workflows/docker.yml | 35 +++++++++++++++++++++++++++++++++++ Dockerfile.agent-executor | 29 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 Dockerfile.agent-executor diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4a2e46d0..e5b5c5d0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -273,3 +273,38 @@ jobs: file: ./stackerdb/Dockerfile push: true tags: ${{ steps.stackerdb_tags.outputs.tags }} + + agent-executor-docker: + name: Agent Executor Docker + runs-on: ubuntu-latest + needs: cicd-docker + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set Docker tags + id: agent_tags + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "tags=trydirect/status-executor:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tags=trydirect/status-executor:latest" >> "$GITHUB_OUTPUT" + fi + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.agent-executor + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.agent_tags.outputs.tags }} diff --git a/Dockerfile.agent-executor b/Dockerfile.agent-executor new file mode 100644 index 00000000..64f6dff3 --- /dev/null +++ b/Dockerfile.agent-executor @@ -0,0 +1,29 @@ +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY build.rs ./ +COPY .sqlx .sqlx/ +COPY proto ./proto +COPY src ./src +COPY crates ./crates +COPY tests ./tests +COPY scenarios ./scenarios + +ENV SQLX_OFFLINE=true + +RUN cargo build --release --bin agent-executor + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev ca-certificates; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/target/release/agent-executor . + +ENTRYPOINT ["/app/agent-executor"] From 0a4f2100c1932c033f12fc9525f6e605c493d37a Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 19 Jul 2026 16:22:04 +0300 Subject: [PATCH 06/29] spin up agent-executor on pipe activate --- Dockerfile.agent-executor-amd64 | 27 ++++++++++ configuration.yaml.dist | 9 ++++ ...9120000_dag_step_exec_correlation.down.sql | 1 + ...719120000_dag_step_exec_correlation.up.sql | 7 +++ src/configuration.rs | 51 +++++++++++++++++++ src/db/dag.rs | 42 +++++++++++++++ src/helpers/mq_manager.rs | 4 +- 7 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.agent-executor-amd64 create mode 100644 migrations/20260719120000_dag_step_exec_correlation.down.sql create mode 100644 migrations/20260719120000_dag_step_exec_correlation.up.sql diff --git a/Dockerfile.agent-executor-amd64 b/Dockerfile.agent-executor-amd64 new file mode 100644 index 00000000..2f6e2ac2 --- /dev/null +++ b/Dockerfile.agent-executor-amd64 @@ -0,0 +1,27 @@ +FROM --platform=linux/amd64 rust:bookworm AS builder + +RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY build.rs ./ +COPY .sqlx .sqlx/ +COPY proto ./proto +COPY src ./src +COPY crates ./crates + +ENV SQLX_OFFLINE=true + +RUN cargo build --release --bin agent-executor 2>&1 + +FROM --platform=linux/amd64 debian:bookworm-slim + +RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev ca-certificates; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/target/release/agent-executor . + +ENTRYPOINT ["/app/agent-executor"] diff --git a/configuration.yaml.dist b/configuration.yaml.dist index 57f2bd36..80d425d6 100644 --- a/configuration.yaml.dist +++ b/configuration.yaml.dist @@ -23,6 +23,15 @@ amqp: username: guest password: guest +# Pipe / DAG step execution +pipe: + # in_process (default) runs steps synchronously in the API process. + # amqp publishes StepCommand to the `pipe_execution` exchange for a standalone + # agent-executor to run; only applies to remote instances (with a deployment hash). + executor_transport: in_process + # Per-level timeout (seconds) when awaiting step results over AMQP. + step_result_timeout_secs: 120 + # Vault configuration (can be overridden by environment variables) # For production, set VAULT_ADDRESS=https://vault.try.direct (not Docker-internal IPs) # The status panel agent authenticates through the Stacker server, which validates diff --git a/migrations/20260719120000_dag_step_exec_correlation.down.sql b/migrations/20260719120000_dag_step_exec_correlation.down.sql new file mode 100644 index 00000000..5af3acf1 --- /dev/null +++ b/migrations/20260719120000_dag_step_exec_correlation.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_dag_step_exec_lookup; diff --git a/migrations/20260719120000_dag_step_exec_correlation.up.sql b/migrations/20260719120000_dag_step_exec_correlation.up.sql new file mode 100644 index 00000000..ffd19049 --- /dev/null +++ b/migrations/20260719120000_dag_step_exec_correlation.up.sql @@ -0,0 +1,7 @@ +-- Correlation key for the AMQP agent-executor path. +-- StepResultMsg carries (execution_id, step_id); the platform must map that +-- back to exactly one pipe_dag_step_executions row to persist the result. +-- A unique index guarantees the mapping is unambiguous and lets us look rows +-- up by (pipe_execution_id, step_id) instead of the surrogate row id. +CREATE UNIQUE INDEX idx_dag_step_exec_lookup + ON pipe_dag_step_executions (pipe_execution_id, step_id); diff --git a/src/configuration.rs b/src/configuration.rs index dcafc815..8e7d5966 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -34,6 +34,8 @@ pub struct Settings { pub marketplace_assets: MarketplaceAssetSettings, #[serde(default)] pub payouts: PayoutSettings, + #[serde(default)] + pub pipe: PipeSettings, } impl std::fmt::Debug for Settings { @@ -66,6 +68,7 @@ impl std::fmt::Debug for Settings { .field("deployment", &self.deployment) .field("marketplace_assets", &self.marketplace_assets) .field("payouts", &self.payouts) + .field("pipe", &self.pipe) .finish() } } @@ -91,6 +94,7 @@ impl Default for Settings { deployment: DeploymentSettings::default(), marketplace_assets: MarketplaceAssetSettings::default(), payouts: PayoutSettings::default(), + pipe: PipeSettings::default(), } } } @@ -206,6 +210,53 @@ impl Default for DeploymentSettings { } } +/// How DAG/pipe steps are executed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutorTransport { + /// Steps run synchronously inside the API process (default, always safe). + InProcess, + /// Steps are published to the `pipe_execution` exchange and run by a + /// standalone agent-executor scoped to the deployment hash. + Amqp, +} + +impl Default for ExecutorTransport { + fn default() -> Self { + Self::InProcess + } +} + +/// Pipe/DAG execution settings. +#[derive(Debug, serde::Deserialize, Clone)] +pub struct PipeSettings { + /// Selects the transport for DAG step execution. `amqp` only takes effect + /// for remote instances (those with a deployment hash); local instances + /// always run in-process regardless of this setting. + #[serde(default)] + pub executor_transport: ExecutorTransport, + /// Per-level timeout (seconds) when awaiting `StepResultMsg` over AMQP. + /// A level whose results do not all arrive within this window is failed, + /// preventing a lost message from stranding a step in `running` forever. + #[serde(default = "PipeSettings::default_step_result_timeout_secs")] + pub step_result_timeout_secs: u64, +} + +impl Default for PipeSettings { + fn default() -> Self { + Self { + executor_transport: ExecutorTransport::default(), + step_result_timeout_secs: Self::default_step_result_timeout_secs(), + } + } +} + +impl PipeSettings { + fn default_step_result_timeout_secs() -> u64 { + 120 + } +} + #[derive(serde::Deserialize, Clone)] pub struct PayoutSettings { #[serde(default = "PayoutSettings::default_provider")] diff --git a/src/db/dag.rs b/src/db/dag.rs index 3bf04a48..37425db8 100644 --- a/src/db/dag.rs +++ b/src/db/dag.rs @@ -319,3 +319,45 @@ pub async fn update_step_execution( format!("Failed to update step execution: {}", err) }) } + +/// Update a step execution located by its correlation key `(pipe_execution_id, step_id)` +/// rather than the surrogate row id. Used by the AMQP result consumer, which only +/// knows the pair carried on `StepResultMsg`. Relies on the unique index +/// `idx_dag_step_exec_lookup` to resolve to a single row. +#[tracing::instrument(name = "Update step execution status by step", skip(pool))] +pub async fn update_step_execution_by_step( + pool: &PgPool, + pipe_execution_id: &Uuid, + step_id: &Uuid, + status: &str, + output_data: Option<&serde_json::Value>, + error: Option<&str>, +) -> Result { + let span = tracing::info_span!("Updating DAG step execution status by step"); + let now = chrono::Utc::now(); + sqlx::query_as::<_, DagStepExecution>( + r#" + UPDATE pipe_dag_step_executions SET + status = $3, + output_data = COALESCE($4, output_data), + error = COALESCE($5, error), + started_at = CASE WHEN $3 = 'running' AND started_at IS NULL THEN $6 ELSE started_at END, + completed_at = CASE WHEN $3 IN ('completed', 'failed', 'skipped') THEN $6 ELSE completed_at END + WHERE pipe_execution_id = $1 AND step_id = $2 + RETURNING id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at + "#, + ) + .bind(pipe_execution_id) + .bind(step_id) + .bind(status) + .bind(output_data) + .bind(error) + .bind(now) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to update step execution by step: {:?}", err); + format!("Failed to update step execution by step: {}", err) + }) +} diff --git a/src/helpers/mq_manager.rs b/src/helpers/mq_manager.rs index e7e31c1c..aba74c61 100644 --- a/src/helpers/mq_manager.rs +++ b/src/helpers/mq_manager.rs @@ -178,7 +178,9 @@ impl MqManager { msg })?; - let channel = self.create_channel().await?; + // Return the SAME channel the queue was declared and bound on. Previously + // this created a fresh channel, which has no knowledge of the binding, so + // a `basic_consume` on it received nothing. Callers consume on this channel. Ok(channel) } } From 2ef820d828c08eb56b5f82aa74e83dd2018af386 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 20 Jul 2026 09:30:25 +0300 Subject: [PATCH 07/29] source_url: Option added, optional external source URL (agent fetches via HTTP, no curl needed in container) --- src/forms/status_panel.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/forms/status_panel.rs b/src/forms/status_panel.rs index 8e47f104..f279103b 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -1043,6 +1043,9 @@ pub struct ActivatePipeCommandRequest { /// Source container name #[serde(default)] pub source_container: Option, + /// External source URL (agent fetches via HTTP, no curl needed) + #[serde(default)] + pub source_url: Option, /// Source endpoint path to watch #[serde(default = "default_pipe_source_endpoint")] pub source_endpoint: String, @@ -1130,6 +1133,9 @@ pub struct TriggerPipeCommandRequest { /// Optional source container override #[serde(default)] pub source_container: Option, + /// Optional external source URL (agent fetches via HTTP, no curl needed) + #[serde(default)] + pub source_url: Option, /// Optional source endpoint override #[serde(default = "default_pipe_source_endpoint")] pub source_endpoint: String, From 98b788b1ecc366402706baa043f2011c53d94067 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 20 Jul 2026 09:30:46 +0300 Subject: [PATCH 08/29] cargo fmt --all --- src/cli/install_runner.rs | 10 +++---- src/console/commands/cli/init.rs | 7 +++-- src/console/commands/cli/marketplace.rs | 36 ++++++++++++++++++------- src/routes/marketplace/install.rs | 9 ++++--- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index adcfed15..95d66d9d 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1875,7 +1875,10 @@ pub(crate) fn resolve_docker_registry_credentials( } 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.insert( + "docker_registry".to_string(), + serde_json::Value::String(String::new()), + ); } creds @@ -3544,10 +3547,7 @@ mod tests { #[test] fn test_resolve_docker_registry_credentials_sends_empty_when_no_config() { - let config = ConfigBuilder::new() - .name("public-app") - .build() - .unwrap(); + let config = ConfigBuilder::new().name("public-app").build().unwrap(); let creds = resolve_docker_registry_credentials(&config); assert!(creds.get("docker_username").is_none()); diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 2b6ab7cd..a57784e0 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -2024,10 +2024,9 @@ impl CallableTrait for InitCommand { if self.with_cloud { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); - let applied = - crate::console::commands::cli::config::run_setup_cloud_interactive( - &path_str, None, - )?; + let applied = crate::console::commands::cli::config::run_setup_cloud_interactive( + &path_str, None, + )?; for item in applied { eprintln!(" - {}", item); } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index a10f90c6..2b4a5488 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -234,7 +234,12 @@ impl MarketplaceInstallCommand { } // Apply provider override - if let Some(provider) = self.provider.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + 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)); @@ -242,14 +247,24 @@ impl MarketplaceInstallCommand { } // Apply region override - if let Some(region) = self.region.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + 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(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)); } @@ -795,10 +810,7 @@ async fn generate_minimal_install_config( .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 - )) + CliError::ConfigValidation(format!("Unsupported cloud provider '{}'.", provider_code)) })?; eprintln!( "Using cloud connection '{}' (provider: {}).", @@ -808,8 +820,14 @@ async fn generate_minimal_install_config( let cloud_config = CloudConfig { provider, orchestrator: Default::default(), - 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), + 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, diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index a0599fa4..e97f98e9 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -108,9 +108,8 @@ fn classify_definition( 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) - })?; + 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!( @@ -907,7 +906,9 @@ mod tests { 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`"); + .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()) From 55e5c4dfe1f791002d5344fb5f334c510ba546ee Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 20 Jul 2026 14:03:54 +0300 Subject: [PATCH 09/29] feat: add --source-url to pipe trigger and activate CLI commands --- src/bin/stacker.rs | 11 ++++++++++- src/console/commands/cli/pipe.rs | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 5a1655ba..fdf2f807 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -1274,6 +1274,9 @@ enum PipeCommands { /// Poll interval in seconds (only for --trigger=poll) #[arg(long, default_value = "300")] poll_interval: u32, + /// External source URL (agent fetches via HTTP, no curl needed) + #[arg(long)] + source_url: Option, /// Output in JSON format #[arg(long)] json: bool, @@ -1299,6 +1302,9 @@ enum PipeCommands { /// Optional JSON input data to feed into the pipe #[arg(long)] data: Option, + /// External source URL (agent fetches via HTTP, no curl needed) + #[arg(long)] + source_url: Option, /// Output in JSON format #[arg(long)] json: bool, @@ -2479,12 +2485,14 @@ fn get_command( pipe_id, trigger, poll_interval, + source_url, json, deployment, } => Box::new(pipe::PipeActivateCommand::new( pipe_id, trigger, poll_interval, + source_url, json, deployment, )), @@ -2496,10 +2504,11 @@ fn get_command( PipeCommands::Trigger { pipe_id, data, + source_url, json, deployment, } => Box::new(pipe::PipeTriggerCommand::new( - pipe_id, data, json, deployment, + pipe_id, data, source_url, json, deployment, )), PipeCommands::History { instance_id, diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 82aa3f20..732c68c1 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -4356,6 +4356,7 @@ pub struct PipeActivateCommand { pub pipe_id: String, pub trigger: String, pub poll_interval: u32, + pub source_url: Option, pub json: bool, pub deployment: Option, } @@ -4365,6 +4366,7 @@ impl PipeActivateCommand { pipe_id: String, trigger: String, poll_interval: u32, + source_url: Option, json: bool, deployment: Option, ) -> Self { @@ -4372,6 +4374,7 @@ impl PipeActivateCommand { pipe_id, trigger, poll_interval, + source_url, json, deployment, } @@ -4488,6 +4491,7 @@ impl CallableTrait for PipeActivateCommand { "pipe_instance_id": self.pipe_id, "source_adapter": pipe.source_adapter.clone(), "source_container": pipe.source_container.clone(), + "source_url": self.source_url, "source_endpoint": source_endpoint, "source_method": source_method, "target_adapter": pipe.target_adapter.clone(), @@ -4615,6 +4619,7 @@ impl CallableTrait for PipeDeactivateCommand { pub struct PipeTriggerCommand { pub pipe_id: String, pub data: Option, + pub source_url: Option, pub json: bool, pub deployment: Option, } @@ -4623,12 +4628,14 @@ impl PipeTriggerCommand { pub fn new( pipe_id: String, data: Option, + source_url: Option, json: bool, deployment: Option, ) -> Self { Self { pipe_id, data, + source_url, json, deployment, } @@ -4729,6 +4736,7 @@ impl CallableTrait for PipeTriggerCommand { let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, "input_data": input_data, + "source_url": self.source_url, }); let request = AgentEnqueueRequest::new(&hash, "trigger_pipe").with_raw_parameters(params); From bdbbc62b3a02f69a95e76d5ef1831858f136bdef Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 20 Jul 2026 14:17:51 +0300 Subject: [PATCH 10/29] target_headers implemented --- src/bin/stacker.rs | 11 ++++++++++- src/console/commands/cli/pipe.rs | 30 ++++++++++++++++++++++++++++++ src/forms/status_panel.rs | 6 ++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index fdf2f807..35e187f2 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -1277,6 +1277,9 @@ enum PipeCommands { /// External source URL (agent fetches via HTTP, no curl needed) #[arg(long)] source_url: Option, + /// Target header as "Key: Value" (repeatable, e.g. --target-header "Authorization: Bearer tok") + #[arg(long)] + target_header: Vec, /// Output in JSON format #[arg(long)] json: bool, @@ -1305,6 +1308,9 @@ enum PipeCommands { /// External source URL (agent fetches via HTTP, no curl needed) #[arg(long)] source_url: Option, + /// Target header as "Key: Value" (repeatable, e.g. --target-header "Authorization: Bearer tok") + #[arg(long)] + target_header: Vec, /// Output in JSON format #[arg(long)] json: bool, @@ -2486,6 +2492,7 @@ fn get_command( trigger, poll_interval, source_url, + target_header, json, deployment, } => Box::new(pipe::PipeActivateCommand::new( @@ -2493,6 +2500,7 @@ fn get_command( trigger, poll_interval, source_url, + target_header, json, deployment, )), @@ -2505,10 +2513,11 @@ fn get_command( pipe_id, data, source_url, + target_header, json, deployment, } => Box::new(pipe::PipeTriggerCommand::new( - pipe_id, data, source_url, json, deployment, + pipe_id, data, source_url, target_header, json, deployment, )), PipeCommands::History { instance_id, diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 732c68c1..75835e43 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -37,6 +37,16 @@ use pipe_adapter_sdk::{ use regex::Regex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; + +fn parse_target_headers(raw: &[String]) -> std::collections::HashMap { + let mut headers = std::collections::HashMap::new(); + for entry in raw { + if let Some((key, value)) = entry.split_once(':') { + headers.insert(key.trim().to_string(), value.trim().to_string()); + } + } + headers +} use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, IsTerminal}; use std::path::{Path, PathBuf}; @@ -4357,6 +4367,7 @@ pub struct PipeActivateCommand { pub trigger: String, pub poll_interval: u32, pub source_url: Option, + pub target_headers: Vec, pub json: bool, pub deployment: Option, } @@ -4367,6 +4378,7 @@ impl PipeActivateCommand { trigger: String, poll_interval: u32, source_url: Option, + target_headers: Vec, json: bool, deployment: Option, ) -> Self { @@ -4375,6 +4387,7 @@ impl PipeActivateCommand { trigger, poll_interval, source_url, + target_headers, json, deployment, } @@ -4487,6 +4500,12 @@ impl CallableTrait for PipeActivateCommand { progress::finish_success(&pb, "Status: active"); // 2. Send activate_pipe command to agent + let target_headers = if self.target_headers.is_empty() { + None + } else { + Some(parse_target_headers(&self.target_headers)) + }; + let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, "source_adapter": pipe.source_adapter.clone(), @@ -4499,6 +4518,7 @@ impl CallableTrait for PipeActivateCommand { "target_url": pipe.target_url.clone(), "target_endpoint": target_endpoint, "target_method": target_method, + "target_headers": target_headers, "field_mapping": field_mapping, "trigger_type": self.trigger, "poll_interval_secs": self.poll_interval, @@ -4620,6 +4640,7 @@ pub struct PipeTriggerCommand { pub pipe_id: String, pub data: Option, pub source_url: Option, + pub target_headers: Vec, pub json: bool, pub deployment: Option, } @@ -4629,6 +4650,7 @@ impl PipeTriggerCommand { pipe_id: String, data: Option, source_url: Option, + target_headers: Vec, json: bool, deployment: Option, ) -> Self { @@ -4636,6 +4658,7 @@ impl PipeTriggerCommand { pipe_id, data, source_url, + target_headers, json, deployment, } @@ -4733,10 +4756,17 @@ impl CallableTrait for PipeTriggerCommand { }; ensure_remote_pipe_command_capability(&ctx, &hash)?; + let target_headers = if self.target_headers.is_empty() { + None + } else { + Some(parse_target_headers(&self.target_headers)) + }; + let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, "input_data": input_data, "source_url": self.source_url, + "target_headers": target_headers, }); let request = AgentEnqueueRequest::new(&hash, "trigger_pipe").with_raw_parameters(params); diff --git a/src/forms/status_panel.rs b/src/forms/status_panel.rs index f279103b..12d1f36a 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -1079,6 +1079,9 @@ pub struct ActivatePipeCommandRequest { /// Target HTTP method #[serde(default = "default_target_method")] pub target_method: String, + /// Optional target headers (auth tokens, API keys, etc.) + #[serde(default)] + pub target_headers: Option>, /// Field mapping (JSONPath expressions) #[serde(default)] pub field_mapping: Option, @@ -1157,6 +1160,9 @@ pub struct TriggerPipeCommandRequest { /// Optional target method override #[serde(default = "default_target_method")] pub target_method: String, + /// Optional target headers (auth tokens, API keys, etc.) + #[serde(default)] + pub target_headers: Option>, /// Optional field mapping override #[serde(default)] pub field_mapping: Option, From 7e1d9c8ac312b572ec5b65772545709294255b20 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 20 Jul 2026 14:19:58 +0300 Subject: [PATCH 11/29] feat: add target_headers support for authenticated pipe delivery --- src/bin/stacker.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 35e187f2..b232d8e6 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -2517,7 +2517,12 @@ fn get_command( json, deployment, } => Box::new(pipe::PipeTriggerCommand::new( - pipe_id, data, source_url, target_header, json, deployment, + pipe_id, + data, + source_url, + target_header, + json, + deployment, )), PipeCommands::History { instance_id, From 6c912c7c83d4bbfb561240dc438f0983ef2e5ec5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:46:10 +0000 Subject: [PATCH 12/29] Update Rust crate derive_builder to 0.20.0 --- Cargo.lock | 83 ++++++++++++++++++++++++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db3d7dda..e0567e19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,8 +1994,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -2012,17 +2022,42 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2158,29 +2193,29 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" dependencies = [ - "derive_builder_macro 0.12.0", + "derive_builder_macro 0.13.1", ] [[package]] name = "derive_builder" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "derive_builder_macro 0.13.1", + "derive_builder_macro 0.20.2", ] [[package]] name = "derive_builder_core" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" dependencies = [ - "darling", + "darling 0.14.4", "proc-macro2", "quote", "syn 1.0.109", @@ -2188,34 +2223,34 @@ dependencies = [ [[package]] name = "derive_builder_core" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "derive_builder_macro" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" dependencies = [ - "derive_builder_core 0.12.0", + "derive_builder_core 0.13.1", "syn 1.0.109", ] [[package]] name = "derive_builder_macro" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "derive_builder_core 0.13.1", - "syn 1.0.109", + "derive_builder_core 0.20.2", + "syn 2.0.117", ] [[package]] @@ -7027,7 +7062,7 @@ dependencies = [ "config", "cucumber", "deadpool-lapin", - "derive_builder 0.12.0", + "derive_builder 0.20.2", "dialoguer", "docker-compose-types", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index 175071f9..c21c4c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ sqlx-adapter = { version = "1.8.0", default-features = false, features = ["postg dotenvy = "0.15" # dctypes -derive_builder = "0.12.0" +derive_builder = "0.20.0" indexmap = { version = "2.0.0", features = ["serde"], optional = true } serde_yaml = "0.9" lapin = { version = "2.3.1", features = ["serde_json"] } From 0bb25f0a4fb8772b372190632af96a3ebc597595 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:46:18 +0000 Subject: [PATCH 13/29] Update Rust crate dialoguer to 0.12 --- Cargo.lock | 7 +++---- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db3d7dda..891b0cd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2267,15 +2267,14 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "dialoguer" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console 0.15.11", + "console 0.16.3", "fuzzy-matcher", "shell-words", "tempfile", - "thiserror 1.0.69", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 175071f9..9b29dba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ lapin = { version = "2.3.1", features = ["serde_json"] } futures-lite = "2.2.0" clap = { version = "4.4.8", features = ["derive", "env"] } clap_complete = "4" -dialoguer = { version = "0.11", features = ["fuzzy-select"] } +dialoguer = { version = "0.12", features = ["fuzzy-select"] } indicatif = "0.17" brotli = "3.4.0" serde_path_to_error = "0.1.14" From d6af03c1f4455f5638a561199a579291fdcc18b4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:46:26 +0000 Subject: [PATCH 14/29] Update Rust crate docker-compose-types to 0.24.0 --- Cargo.lock | 71 ++++++++++++++++++++++++++++++++++++++++-------------- Cargo.toml | 2 +- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db3d7dda..f060a8d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,8 +1994,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -2012,17 +2022,42 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2167,11 +2202,11 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "derive_builder_macro 0.13.1", + "derive_builder_macro 0.20.2", ] [[package]] @@ -2180,7 +2215,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" dependencies = [ - "darling", + "darling 0.14.4", "proc-macro2", "quote", "syn 1.0.109", @@ -2188,14 +2223,14 @@ dependencies = [ [[package]] name = "derive_builder_core" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2210,12 +2245,12 @@ dependencies = [ [[package]] name = "derive_builder_macro" -version = "0.13.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "derive_builder_core 0.13.1", - "syn 1.0.109", + "derive_builder_core 0.20.2", + "syn 2.0.117", ] [[package]] @@ -2334,11 +2369,11 @@ checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] name = "docker-compose-types" -version = "0.7.2" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fdd6fa1c9e8e716f5f73406b868929f468702449621e7397066478b9bf89c" +checksum = "bdfd601efc90644958510466e8904ca90da65a98fdfe4d21f940644a21c026b4" dependencies = [ - "derive_builder 0.13.1", + "derive_builder 0.20.2", "indexmap 2.14.0", "serde", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 175071f9..87dd381b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ brotli = "3.4.0" serde_path_to_error = "0.1.14" zstd = "0.13" deadpool-lapin = "0.12.1" -docker-compose-types = "0.7.0" +docker-compose-types = "0.24.0" actix-casbin-auth = { git = "https://github.com/casbin-rs/actix-casbin-auth.git"} casbin = "2.2.0" aes-gcm = "0.10.3" From 009ad5bb644a7dc20744988a8b6634c0f4ed564c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:46:34 +0000 Subject: [PATCH 15/29] Update Rust crate hmac to 0.13.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db3d7dda..46173fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7036,7 +7036,7 @@ dependencies = [ "futures-lite 2.6.1", "futures-util", "glob", - "hmac 0.12.1", + "hmac 0.13.0", "indexmap 2.14.0", "indicatif", "lapin", diff --git a/Cargo.toml b/Cargo.toml index 175071f9..d276a16b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ futures-util = "0.3.29" futures = "0.3.29" tokio-stream = "0.1.14" actix-http = "3.4.0" -hmac = "0.12.1" +hmac = "0.13.0" sha2 = "0.10.8" sqlx-adapter = { version = "1.8.0", default-features = false, features = ["postgres", "runtime-tokio-native-tls"]} dotenvy = "0.15" From bd41c2a401cbb151309dad65a14162975285882e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 21 Jul 2026 14:38:34 +0300 Subject: [PATCH 16/29] website update, with v0.3.1 features and fixes --- website/dist/index.html | 135 ++++++++++++++++++++++++++++++--------- website/dist/main.js | 11 +++- website/dist/main.js.map | 2 +- website/dist/styles.css | 24 ++++++- website/src/index.html | 135 ++++++++++++++++++++++++++++++--------- website/src/main.ts | 11 +++- website/src/styles.css | 24 ++++++- 7 files changed, 273 insertions(+), 69 deletions(-) diff --git a/website/dist/index.html b/website/dist/index.html index ec9de490..99d19127 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -3,8 +3,8 @@ - Stacker v0.2.9 — AI Infrastructure CLI by TryDirect - + Stacker v0.3.1 - AI Infrastructure CLI by TryDirect + @@ -28,6 +28,7 @@ Release AI Engine Examples + Test Stacks Commands Providers @@ -52,16 +53,17 @@
- New Release v0.2.9 · AI-Powered · Cloud + Bare Metal + New Release v0.3.1 · AI-Powered · Cloud + Bare Metal

Ship your stack from code to server
with one CLI.

- Stacker scans your project, generates a production-ready stacker.yml, - deploys to cloud or bare metal, manages remote agents, links apps with pipes, - and ships marketplace-ready stacks — all from your terminal. + Stacker scans your project - or clones it straight from GitHub - generates a + production-ready stacker.yml, deploys to cloud or bare metal, manages + remote agents, links apps with authenticated pipes, and ships marketplace-ready + stacks - all from your terminal.

-
v0.2.9
+
v0.3.1

- Latest release includes AI config setup CLI, OAuth device auth, - .env bootstrapping, Hetzner location fixes, and hardened agent health & logs. + Since v0.3.0: init straight from GitHub, authenticated app pipes, + non-interactive pipe create, a service password generator, + one-command marketplace install, and more reliable config bundling.

- AI setup CLI - Device auth - .env bootstrap - Preflight checks - npm networking - Agent health - Config promote - Hetzner fixes + Init from GitHub + Authenticated pipes + Pipe --source-url + Non-interactive create + Service passwords + Marketplace install + Bind-mount fixes + OpenSSH keys
@@ -138,6 +141,14 @@

AI-Powered Configuration

Deep Project Scanning

Automatically detects Dockerfile, docker-compose.yml, package.json, Cargo.toml, requirements.txt, and the rest of the stack before it writes config.

+
+
+ +
+

Init Straight from GitHub

+

Run stacker init --from-github <owner/repo> to clone a repository, detect its type and Compose services, infer healthchecks, and generate stacker.yml, .env.example, and a generate-secrets.sh helper - no local checkout required.

+
New in v0.3.1
+
@@ -170,8 +181,8 @@

Agent Control + Firewall

-

Pipes & Container Linking

-

Discover endpoints, map fields, trigger flows, replay executions, and connect apps together with stacker pipe automation.

+

Authenticated App Pipes

+

Discover endpoints, map fields, and connect apps with stacker pipe. Now with target_headers for authenticated delivery and --source-url so the agent-executor fetches inputs over HTTP - no curl inside the container.

@@ -185,14 +196,14 @@

Marketplace Shipping

Service Catalog

-

20+ built-in service templates — Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

+

20+ built-in service templates - Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

SSH Key Management

-

Generate, view, and upload SSH keys for your servers — all stored securely in HashiCorp Vault. No manual key juggling.

+

Generate, view, and upload SSH keys for your servers - all stored securely in HashiCorp Vault. No manual key juggling.

@@ -203,14 +214,54 @@

SSH Key Management

-

Stacker v0.2.9
brings smarter onboarding & deploy reliability.

-

The newest release adds AI config setup from the CLI, OAuth device auth, .env bootstrapping on deploy, improved Hetzner support, and hardened agent health and log workflows.

+

Stacker v0.3.1
connects apps and starts from anywhere.

+

New since v0.3.0: initialize projects straight from a GitHub URL, wire apps together with authenticated pipes, generate service passwords automatically, install marketplace stacks in one command, and ship more reliable config bundling for remote deploys.

+
+ Init +

stacker init --from-github

+

Clone a repo with --from-github <owner/repo> (or -g), auto-detect the project type and Compose services, infer healthchecks for Postgres, Redis, MySQL, Mongo, RabbitMQ, and Elasticsearch, and write stacker.yml plus .env.example and scripts/generate-secrets.sh.

+
+
+ Pipes +

Authenticated pipe delivery

+

Attach target_headers to a pipe so deliveries to downstream apps carry auth tokens or signatures. Combined with --source-url, the agent-executor fetches inputs over HTTP and forwards them with the right credentials.

+
+
+ Pipes +

Non-interactive pipe create

+

Pass --name to stacker pipe create for fully scripted, non-interactive setup. Activating a pipe now spins up the agent-executor automatically and probes the target container before wiring the flow.

+
+
+ Services +

Service password generator

+

stacker service add now generates strong passwords for services that need them, so templates like Postgres, MySQL, and MinIO come with secure credentials out of the box instead of placeholder defaults.

+
+
+ Marketplace +

One-command marketplace install

+

Install a marketplace stack directly from its Compose definition and pass cloud parameters inline on the CLI command, so provisioning and deploying a published stack is a single step.

+
+
+ Deploy +

Reliable config bundling

+

Directory bind mounts such as ./library, ./assets, and ./config now pass through to remote targets, env_file references stay project-relative, and deploy-time files are mirrored into the installer runtime contract before Compose starts.

+
+
+ Security +

OpenSSH-format SSH keys

+

Generated Ed25519 keypairs are validated in canonical OpenSSH format and covered by dedicated key-format tests, so keys uploaded to your servers and cloud providers work without manual conversion.

+
+
+ Cloud +

Web UI firewall port fix

+

Cloud firewall port handling is fixed for the web UI path, so public ports opened from the dashboard and the CLI stay consistent across provider firewall rules.

+
AI Setup

stacker config setup ai

-

Configure your AI provider, endpoint, model, and tasks directly from the CLI — Ollama-friendly with --provider, --endpoint, and --model flags.

+

Configure your AI provider, endpoint, model, and tasks directly from the CLI - Ollama-friendly with --provider, --endpoint, and --model flags.

Auth @@ -277,7 +328,7 @@

Marketplace workflows

Your infrastructure,
understood by AI.

-

Stacker's AI engine doesn't just guess — it deeply analyzes your project to generate the perfect deployment configuration.

+

Stacker's AI engine doesn't just guess - it deeply analyzes your project to generate the perfect deployment configuration.

@@ -400,6 +451,27 @@

Ship a docs-driven open source site

+ +
+
+
+ + +

Real stacks.
Real deploys. Test them yourself.

+

We curate awesome-selfhosted-stacker - a growing catalog of ready-to-run, self-hosted apps with stacker.yml configs you can actually deploy. Clone one, run stacker deploy, and see the whole workflow end to end on your own cloud or bare-metal server.

+ +
+
+
+
@@ -506,9 +578,9 @@

21 top-level commands.
stacker init - --with-ai + --from-github
-

Scan your project and generate stacker.yml — with optional AI assistance.

+

Scan a local project or clone one with --from-github <owner/repo> to generate stacker.yml - with optional --with-ai assistance.

@@ -559,14 +631,14 @@

21 top-level commands.
stacker service add <name>

-

Add services from 20+ built-in templates — Postgres, Redis, WordPress, and more. Auto-adds dependencies.

+

Add services from 20+ built-in templates - Postgres, Redis, WordPress, and more. Auto-adds dependencies.

stacker pipe - create + create --name
-

Discover endpoints, connect apps, activate flows, replay executions, and inspect automation history.

+

Connect apps and activate flows non-interactively with --name, authenticate delivery via target_headers, and fetch inputs over HTTP with --source-url.

@@ -685,7 +757,7 @@

How do I talk to the right agent?

Ready to deploy
smarter?

-

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

+

Ship with Stacker v0.3.1: init from GitHub, authenticated app pipes, generated service passwords, one-command marketplace install, bare-metal deploys, and agent ops in one place.

@@ -737,6 +809,7 @@

Product

diff --git a/website/dist/main.js b/website/dist/main.js index f136a6df..0473fcf5 100644 --- a/website/dist/main.js +++ b/website/dist/main.js @@ -1,6 +1,6 @@ "use strict"; // ============================================================ -// Stacker Website — TypeScript Interactions +// Stacker Website - TypeScript Interactions // Particle system, typing effect, scroll animations, counters // ============================================================ class ParticleSystem { @@ -198,7 +198,7 @@ class ScrollAnimator { document.querySelectorAll('[data-animate]').forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.top < vh) { - // Element is already in the viewport — show it immediately + // Element is already in the viewport - show it immediately const delay = parseInt(el.dataset.delay || '0', 10); setTimeout(() => el.classList.add('is-visible'), delay); } @@ -343,6 +343,7 @@ document.addEventListener('DOMContentLoaded', () => { const heroOutput = document.getElementById('heroOutput'); if (heroCommand && heroOutput) { new TypeWriter(heroCommand, heroOutput, [ + 'stacker init --from-github trydirect/awesome-selfhosted-stacker', 'stacker init --with-ai', 'stacker service add wordpress', 'stacker deploy --target server --runtime kata', @@ -350,6 +351,12 @@ document.addEventListener('DOMContentLoaded', () => { './stacker ai ask "how do I connect my metal bare server"', 'stacker agent configure-firewall --public-ports 80/tcp,443/tcp', ], [ + [ + '▸ Cloning github.com/trydirect/awesome-selfhosted-stacker...', + '✓ Detected: Node.js + PostgreSQL + Redis from compose', + '✓ Wrote stacker.yml, .env.example, generate-secrets.sh', + '✓ Ready to deploy - run: stacker deploy', + ], [ '▸ Scanning project structure...', '✓ Detected: Python + FastAPI + PostgreSQL + Redis', diff --git a/website/dist/main.js.map b/website/dist/main.js.map index fbfb3201..677cb981 100644 --- a/website/dist/main.js.map +++ b/website/dist/main.js.map @@ -1 +1 @@ -{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/website/dist/styles.css b/website/dist/styles.css index 3d40b46a..3dd73fad 100644 --- a/website/dist/styles.css +++ b/website/dist/styles.css @@ -1,5 +1,5 @@ /* ============================================================ - Stacker Website — Styles + Stacker Website - Styles TryDirect brand colors + modern visual effects ============================================================ */ @@ -801,6 +801,28 @@ code { line-height: 1.7; } +/* Highlight cards for features new since v0.3.0 */ +.release-card--fresh { + position: relative; + border-color: rgba(126, 87, 194, 0.45); + background: linear-gradient(160deg, rgba(126, 87, 194, 0.08), var(--color-bg-card) 55%); +} + +.release-card--fresh::after { + content: 'NEW'; + position: absolute; + top: var(--space-lg); + right: var(--space-lg); + padding: 2px 8px; + border-radius: 999px; + background: var(--color-accent); + color: #fff; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.08em; +} + /* ============================================================ AI SECTION ============================================================ */ diff --git a/website/src/index.html b/website/src/index.html index ec9de490..99d19127 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -3,8 +3,8 @@ - Stacker v0.2.9 — AI Infrastructure CLI by TryDirect - + Stacker v0.3.1 - AI Infrastructure CLI by TryDirect + @@ -28,6 +28,7 @@ Release AI Engine Examples + Test Stacks Commands Providers
@@ -52,16 +53,17 @@
- New Release v0.2.9 · AI-Powered · Cloud + Bare Metal + New Release v0.3.1 · AI-Powered · Cloud + Bare Metal

Ship your stack from code to server
with one CLI.

- Stacker scans your project, generates a production-ready stacker.yml, - deploys to cloud or bare metal, manages remote agents, links apps with pipes, - and ships marketplace-ready stacks — all from your terminal. + Stacker scans your project - or clones it straight from GitHub - generates a + production-ready stacker.yml, deploys to cloud or bare metal, manages + remote agents, links apps with authenticated pipes, and ships marketplace-ready + stacks - all from your terminal.

-
v0.2.9
+
v0.3.1

- Latest release includes AI config setup CLI, OAuth device auth, - .env bootstrapping, Hetzner location fixes, and hardened agent health & logs. + Since v0.3.0: init straight from GitHub, authenticated app pipes, + non-interactive pipe create, a service password generator, + one-command marketplace install, and more reliable config bundling.

- AI setup CLI - Device auth - .env bootstrap - Preflight checks - npm networking - Agent health - Config promote - Hetzner fixes + Init from GitHub + Authenticated pipes + Pipe --source-url + Non-interactive create + Service passwords + Marketplace install + Bind-mount fixes + OpenSSH keys
@@ -138,6 +141,14 @@

AI-Powered Configuration

Deep Project Scanning

Automatically detects Dockerfile, docker-compose.yml, package.json, Cargo.toml, requirements.txt, and the rest of the stack before it writes config.

+
+
+ +
+

Init Straight from GitHub

+

Run stacker init --from-github <owner/repo> to clone a repository, detect its type and Compose services, infer healthchecks, and generate stacker.yml, .env.example, and a generate-secrets.sh helper - no local checkout required.

+
New in v0.3.1
+
@@ -170,8 +181,8 @@

Agent Control + Firewall

-

Pipes & Container Linking

-

Discover endpoints, map fields, trigger flows, replay executions, and connect apps together with stacker pipe automation.

+

Authenticated App Pipes

+

Discover endpoints, map fields, and connect apps with stacker pipe. Now with target_headers for authenticated delivery and --source-url so the agent-executor fetches inputs over HTTP - no curl inside the container.

@@ -185,14 +196,14 @@

Marketplace Shipping

Service Catalog

-

20+ built-in service templates — Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

+

20+ built-in service templates - Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

SSH Key Management

-

Generate, view, and upload SSH keys for your servers — all stored securely in HashiCorp Vault. No manual key juggling.

+

Generate, view, and upload SSH keys for your servers - all stored securely in HashiCorp Vault. No manual key juggling.

@@ -203,14 +214,54 @@

SSH Key Management

-

Stacker v0.2.9
brings smarter onboarding & deploy reliability.

-

The newest release adds AI config setup from the CLI, OAuth device auth, .env bootstrapping on deploy, improved Hetzner support, and hardened agent health and log workflows.

+

Stacker v0.3.1
connects apps and starts from anywhere.

+

New since v0.3.0: initialize projects straight from a GitHub URL, wire apps together with authenticated pipes, generate service passwords automatically, install marketplace stacks in one command, and ship more reliable config bundling for remote deploys.

+
+ Init +

stacker init --from-github

+

Clone a repo with --from-github <owner/repo> (or -g), auto-detect the project type and Compose services, infer healthchecks for Postgres, Redis, MySQL, Mongo, RabbitMQ, and Elasticsearch, and write stacker.yml plus .env.example and scripts/generate-secrets.sh.

+
+
+ Pipes +

Authenticated pipe delivery

+

Attach target_headers to a pipe so deliveries to downstream apps carry auth tokens or signatures. Combined with --source-url, the agent-executor fetches inputs over HTTP and forwards them with the right credentials.

+
+
+ Pipes +

Non-interactive pipe create

+

Pass --name to stacker pipe create for fully scripted, non-interactive setup. Activating a pipe now spins up the agent-executor automatically and probes the target container before wiring the flow.

+
+
+ Services +

Service password generator

+

stacker service add now generates strong passwords for services that need them, so templates like Postgres, MySQL, and MinIO come with secure credentials out of the box instead of placeholder defaults.

+
+
+ Marketplace +

One-command marketplace install

+

Install a marketplace stack directly from its Compose definition and pass cloud parameters inline on the CLI command, so provisioning and deploying a published stack is a single step.

+
+
+ Deploy +

Reliable config bundling

+

Directory bind mounts such as ./library, ./assets, and ./config now pass through to remote targets, env_file references stay project-relative, and deploy-time files are mirrored into the installer runtime contract before Compose starts.

+
+
+ Security +

OpenSSH-format SSH keys

+

Generated Ed25519 keypairs are validated in canonical OpenSSH format and covered by dedicated key-format tests, so keys uploaded to your servers and cloud providers work without manual conversion.

+
+
+ Cloud +

Web UI firewall port fix

+

Cloud firewall port handling is fixed for the web UI path, so public ports opened from the dashboard and the CLI stay consistent across provider firewall rules.

+
AI Setup

stacker config setup ai

-

Configure your AI provider, endpoint, model, and tasks directly from the CLI — Ollama-friendly with --provider, --endpoint, and --model flags.

+

Configure your AI provider, endpoint, model, and tasks directly from the CLI - Ollama-friendly with --provider, --endpoint, and --model flags.

Auth @@ -277,7 +328,7 @@

Marketplace workflows

Your infrastructure,
understood by AI.

-

Stacker's AI engine doesn't just guess — it deeply analyzes your project to generate the perfect deployment configuration.

+

Stacker's AI engine doesn't just guess - it deeply analyzes your project to generate the perfect deployment configuration.

@@ -400,6 +451,27 @@

Ship a docs-driven open source site

+ +
+
+
+ + +

Real stacks.
Real deploys. Test them yourself.

+

We curate awesome-selfhosted-stacker - a growing catalog of ready-to-run, self-hosted apps with stacker.yml configs you can actually deploy. Clone one, run stacker deploy, and see the whole workflow end to end on your own cloud or bare-metal server.

+ +
+
+
+
@@ -506,9 +578,9 @@

21 top-level commands.
stacker init - --with-ai + --from-github
-

Scan your project and generate stacker.yml — with optional AI assistance.

+

Scan a local project or clone one with --from-github <owner/repo> to generate stacker.yml - with optional --with-ai assistance.

@@ -559,14 +631,14 @@

21 top-level commands.
stacker service
add <name>

-

Add services from 20+ built-in templates — Postgres, Redis, WordPress, and more. Auto-adds dependencies.

+

Add services from 20+ built-in templates - Postgres, Redis, WordPress, and more. Auto-adds dependencies.

stacker pipe - create + create --name
-

Discover endpoints, connect apps, activate flows, replay executions, and inspect automation history.

+

Connect apps and activate flows non-interactively with --name, authenticate delivery via target_headers, and fetch inputs over HTTP with --source-url.

@@ -685,7 +757,7 @@

How do I talk to the right agent?

Ready to deploy
smarter?

-

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

+

Ship with Stacker v0.3.1: init from GitHub, authenticated app pipes, generated service passwords, one-command marketplace install, bare-metal deploys, and agent ops in one place.

@@ -737,6 +809,7 @@

Product

diff --git a/website/src/main.ts b/website/src/main.ts index ce6a42ef..bd6520a9 100644 --- a/website/src/main.ts +++ b/website/src/main.ts @@ -1,5 +1,5 @@ // ============================================================ -// Stacker Website — TypeScript Interactions +// Stacker Website - TypeScript Interactions // Particle system, typing effect, scroll animations, counters // ============================================================ @@ -248,7 +248,7 @@ class ScrollAnimator { document.querySelectorAll('[data-animate]').forEach((el) => { const rect = (el as HTMLElement).getBoundingClientRect(); if (rect.top < vh) { - // Element is already in the viewport — show it immediately + // Element is already in the viewport - show it immediately const delay = parseInt((el as HTMLElement).dataset.delay || '0', 10); setTimeout(() => (el as HTMLElement).classList.add('is-visible'), delay); } else { @@ -418,6 +418,7 @@ document.addEventListener('DOMContentLoaded', () => { heroCommand, heroOutput, [ + 'stacker init --from-github trydirect/awesome-selfhosted-stacker', 'stacker init --with-ai', 'stacker service add wordpress', 'stacker deploy --target server --runtime kata', @@ -426,6 +427,12 @@ document.addEventListener('DOMContentLoaded', () => { 'stacker agent configure-firewall --public-ports 80/tcp,443/tcp', ], [ + [ + '▸ Cloning github.com/trydirect/awesome-selfhosted-stacker...', + '✓ Detected: Node.js + PostgreSQL + Redis from compose', + '✓ Wrote stacker.yml, .env.example, generate-secrets.sh', + '✓ Ready to deploy - run: stacker deploy', + ], [ '▸ Scanning project structure...', '✓ Detected: Python + FastAPI + PostgreSQL + Redis', diff --git a/website/src/styles.css b/website/src/styles.css index 3d40b46a..3dd73fad 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -1,5 +1,5 @@ /* ============================================================ - Stacker Website — Styles + Stacker Website - Styles TryDirect brand colors + modern visual effects ============================================================ */ @@ -801,6 +801,28 @@ code { line-height: 1.7; } +/* Highlight cards for features new since v0.3.0 */ +.release-card--fresh { + position: relative; + border-color: rgba(126, 87, 194, 0.45); + background: linear-gradient(160deg, rgba(126, 87, 194, 0.08), var(--color-bg-card) 55%); +} + +.release-card--fresh::after { + content: 'NEW'; + position: absolute; + top: var(--space-lg); + right: var(--space-lg); + padding: 2px 8px; + border-radius: 999px; + background: var(--color-accent); + color: #fff; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.08em; +} + /* ============================================================ AI SECTION ============================================================ */ From eda36eb3537cdf798c8c61bd4875c9a8801b7471 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 21 Jul 2026 17:56:16 +0300 Subject: [PATCH 17/29] stack_definition fix when install from marketplace --- src/cli/stacker_client.rs | 103 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 4d351cf0..4f190a4e 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -229,7 +229,10 @@ pub struct AuthorizePublicKeyResponse { // Marketplace response types // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -/// Marketplace template summary as returned by `GET /api/templates` +/// Marketplace template as returned by `GET /api/templates` (summary; list +/// form) and `GET /api/templates/{slug}` (detail). In the list form +/// `stack_definition` is absent; `get_marketplace_template` populates it from +/// the detail response's `latest_version.stack_definition`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MarketplaceTemplate { pub id: Option, @@ -1861,13 +1864,26 @@ impl StackerClient { let Some(item) = api.item else { return Ok(None); }; + // The server nests the full definition under `latest_version` + // (see marketplace `detail_handler`), while the summary fields live + // under `template`. Pull the definition across so callers that read + // `MarketplaceTemplate.stack_definition` (e.g. the service catalog) + // actually see it instead of always getting `None`. + let latest_stack_definition = item + .get("latest_version") + .and_then(|lv| lv.get("stack_definition")) + .filter(|sd| !sd.is_null()) + .cloned(); let template = item.get("template").cloned().unwrap_or(item); - serde_json::from_value(template) - .map(Some) - .map_err(|e| CliError::DeployFailed { + let mut template: MarketplaceTemplate = + serde_json::from_value(template).map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid marketplace template response: {}", e), - }) + })?; + if template.stack_definition.is_none() { + template.stack_definition = latest_stack_definition; + } + Ok(Some(template)) } /// Create a Stacker project from a marketplace template. @@ -5139,6 +5155,83 @@ mod tests { ); } + #[tokio::test] + async fn test_get_marketplace_template_pulls_stack_definition_from_latest_version() { + let server = MockServer::start().await; + + // Mirror the server's `detail_handler` shape: summary fields under + // `template`, full definition under `latest_version.stack_definition`. + Mock::given(method("GET")) + .and(path("/api/templates/n8n")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "_status": "OK", + "item": { + "template": { + "id": "abc-123", + "slug": "n8n", + "name": "n8n", + "status": "approved" + }, + "latest_version": { + "definition_format": "yaml", + "stack_definition": "version: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest" + } + } + }))) + .mount(&server) + .await; + + let client = StackerClient::new(&server.uri(), "token"); + let template = client + .get_marketplace_template("n8n") + .await + .expect("request should succeed") + .expect("template should be present"); + + assert_eq!(template.slug, "n8n"); + assert_eq!( + template.stack_definition, + Some(serde_json::json!( + "version: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest" + )), + "stack_definition must be lifted out of latest_version", + ); + } + + #[tokio::test] + async fn test_get_marketplace_template_leaves_definition_none_when_absent() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/templates/bare")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "_status": "OK", + "item": { + "template": { + "id": "def-456", + "slug": "bare", + "name": "bare", + "status": "approved" + }, + "latest_version": { + "definition_format": "yaml", + "stack_definition": null + } + } + }))) + .mount(&server) + .await; + + let client = StackerClient::new(&server.uri(), "token"); + let template = client + .get_marketplace_template("bare") + .await + .expect("request should succeed") + .expect("template should be present"); + + assert_eq!(template.stack_definition, None); + } + #[tokio::test] async fn test_list_projects_retries_api_v1_after_forbidden_legacy_proxy_response() { let server = MockServer::start().await; From e4afea8dd7aec95325592cd70d048eb9a7717d2d Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 21 Jul 2026 20:14:11 +0300 Subject: [PATCH 18/29] return eror on unapproved package --- src/db/marketplace.rs | 15 ++++++++ src/routes/marketplace/install.rs | 21 +++++++++++ src/routes/project/deploy.rs | 60 +++++++++++++++++++++++++++---- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 04c07375..c63d9fcc 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -377,6 +377,21 @@ pub async fn get_by_slug_with_latest( Ok((template, version)) } +/// Returns the raw `status` of a template by slug, regardless of approval +/// state. Used to distinguish "exists but not approved" from "unknown slug" +/// so install doesn't silently fall through to the catalog path (which would +/// deploy an empty stack) for a real-but-unapproved template. +pub async fn status_by_slug(pool: &PgPool, slug: &str) -> Result, SlugLookupError> { + sqlx::query_scalar::<_, String>("SELECT status FROM stack_template WHERE slug = $1") + .bind(slug) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("status_by_slug error: {:?}", e); + SlugLookupError::Internal + }) +} + pub async fn get_by_id( pool: &PgPool, template_id: uuid::Uuid, diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index e97f98e9..37edd7e0 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -681,6 +681,27 @@ pub async fn install_handler( .internal_server_error("Internal Server Error")); } Err(db::marketplace::SlugLookupError::NotFound) => { + // `get_by_slug_with_latest` only matches approved templates. If a + // row exists for this slug but isn't approved, fall through to the + // catalog path would silently deploy an empty stack (the real + // stack_definition lives on the unapproved row and is never read). + // Fail loudly instead. + if let Some(status) = db::marketplace::status_by_slug(pg_pool.get_ref(), &slug) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Internal Server Error") + })? + { + return Err( + JsonResponse::::build().bad_request(format!( + "Template '{}' exists but is not approved for install (status: {}). \ + Only approved templates can be installed.", + slug, status + )), + ); + } + install_catalog_application( &slug, &request, diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index e0b28bdc..e1e81827 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -1321,6 +1321,21 @@ fn embedded_marketplace_compose(metadata: &serde_json::Value) -> Option compose_content_from_config_files(cf).ok().flatten() } +/// Whether a docker-compose YAML string defines at least one service. +/// +/// Guards against shipping an empty stack (only `version:`, or no `services:` +/// block) to the remote host, which otherwise fails deep in ansible with an +/// opaque "empty compose file". +fn compose_defines_services(compose: &str) -> bool { + serde_yaml::from_str::(compose) + .ok() + .as_ref() + .and_then(|v| v.get("services")) + .and_then(|s| s.as_mapping()) + .map(|m| !m.is_empty()) + .unwrap_or(false) +} + async fn load_project_template_version( pg_pool: &PgPool, project: &models::Project, @@ -1379,6 +1394,21 @@ async fn execute_deployment( .map_err(|err| JsonResponse::::build().internal_server_error(err)) })?; + // Refuse to provision and ship a compose that defines no services. Without + // this guard an empty stack (e.g. an unapproved or catalog-only template + // that yielded no definition) reaches the remote host and fails deep in + // ansible with an opaque "empty compose file", after a server has already + // been provisioned and must be torn down. + if !compose_defines_services(&fc) { + return Err( + JsonResponse::::build().bad_request(format!( + "Deployment aborted: project #{} produced a docker-compose with no services. \ + This usually means the stack template has no installable definition.", + id + )), + ); + } + let mut new_public_key: Option = None; let mut bootstrap_private_key: Option = None; let provided_keypair = resolve_provided_ssh_keypair(&form.server) @@ -2295,12 +2325,12 @@ pub async fn rollback( 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, - 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, + compose_defines_services, default_status_panel_npm_credentials, + derive_public_ports_from_metadata, 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, }; use crate::configuration::Settings; use crate::connectors::app_service_catalog::ServerCapacity; @@ -2309,6 +2339,24 @@ mod tests { use serde_json::json; use uuid::Uuid; + #[test] + fn compose_defines_services_detects_real_and_empty_composes() { + // Real compose with services. + assert!(compose_defines_services( + "version: '3.8'\nservices:\n ghost:\n image: ghost:5-alpine\n" + )); + + // Only an obsolete version key, no services (the ghost-deploy failure). + assert!(!compose_defines_services("version: '3.8'\n")); + + // Explicit but empty services map. + assert!(!compose_defines_services("services: {}\n")); + + // Empty / whitespace / invalid input. + assert!(!compose_defines_services("")); + assert!(!compose_defines_services(" \n")); + } + fn build_template(slug: &str) -> models::StackTemplate { models::StackTemplate { id: Uuid::new_v4(), From d06749920f5480327a117f4e891767a0bff9ed76 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 21 Jul 2026 20:39:08 +0300 Subject: [PATCH 19/29] =?UTF-8?q?Added=20config=5Fhas=5Fbuildable=5Fapp:?= =?UTF-8?q?=20skip=20the=20synthesized=20app=20service=20when=20config.ori?= =?UTF-8?q?gin=20=3D=3D=20MarketplaceGenerated=20(the=20file=20carries=20t?= =?UTF-8?q?he=20#=20@stacker-origin:=20marketplace=20marker)=20unless=20th?= =?UTF-8?q?e=20app=20section=20declares=20an=20explicit=20source=20(image/?= =?UTF-8?q?dockerfile/build).=20User-authored=20configs=20are=20completely?= =?UTF-8?q?=20unaffected=20=E2=80=94=20their=20app=20is=20always=20kept.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/generator/compose.rs | 94 +++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index f4624c64..80bcf445 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -4,7 +4,8 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, + AppType, ComposeHealthcheck, ConfigOrigin, DomainConfig, ProxyType, ServiceDefinition, + StackerConfig, }; use crate::cli::error::CliError; @@ -115,15 +116,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,6 +193,19 @@ 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. +/// +/// User-authored configs always keep their app (it builds from the project +/// directory). Marketplace-generated configs express the entire stack in +/// `services:` and have no local app to build, so the default `app` is skipped +/// unless it declares an explicit source (image / dockerfile / build). +fn config_has_buildable_app(config: &StackerConfig) -> bool { + if config.origin != ConfigOrigin::MarketplaceGenerated { + return true; + } + config.app.image.is_some() || config.app.dockerfile.is_some() || config.app.build.is_some() +} + fn build_app_service(config: &StackerConfig) -> ComposeService { let mut svc = ComposeService { name: "app".to_string(), @@ -563,6 +585,64 @@ mod tests { assert!(app.image.is_none()); } + fn ghost_service() -> ServiceDefinition { + ServiceDefinition { + name: "ghost".to_string(), + image: "ghost:5-alpine".to_string(), + ports: vec!["2368:2368".to_string()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + command: None, + healthcheck: None, + } + } + + #[test] + fn marketplace_config_without_app_source_omits_phantom_app_service() { + let mut config = minimal_config(AppType::Static); + config.origin = ConfigOrigin::MarketplaceGenerated; + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + // No phantom `app` (which would build from an unshipped .stacker/Dockerfile). + assert!( + !names.contains(&"app"), + "marketplace stack must not synthesize an app service, got {:?}", + names + ); + assert!(names.contains(&"ghost")); + } + + #[test] + fn marketplace_config_with_explicit_app_image_keeps_app_service() { + let mut config = minimal_config(AppType::Static); + config.origin = ConfigOrigin::MarketplaceGenerated; + config.app.image = Some("myorg/app:1.0".to_string()); + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"app")); + assert!(names.contains(&"ghost")); + } + + #[test] + fn user_authored_config_with_services_still_gets_app_service() { + let mut config = minimal_config(AppType::Static); + // Default origin is UserAuthored. + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"app"), "user app must be preserved"); + assert!(names.contains(&"ghost")); + } + #[test] fn test_compose_app_service_with_explicit_image() { let config = ConfigBuilder::new() From cbf11c34bf0b64a97f9fae2a76c093713a9de955 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 22 Jul 2026 11:59:26 +0300 Subject: [PATCH 20/29] per-install payment model, website updates --- .github/workflows/docker.yml | 35 - Cargo.lock | 1238 ++++++++++------- Cargo.toml | 8 +- Dockerfile.agent-executor | 29 - Dockerfile.agent-executor-amd64 | 27 - configuration.yaml.dist | 9 - docs/DAG_PIPES_PART1_CLI_GUIDE.md | 28 - ...marketplace_install_authorization.down.sql | 1 + ...0_marketplace_install_authorization.up.sql | 31 + ...9120000_dag_step_exec_correlation.down.sql | 1 - ...719120000_dag_step_exec_correlation.up.sql | 7 - src/bin/stacker.rs | 63 +- src/cli/generator/compose.rs | 94 +- src/cli/install_runner.rs | 10 +- src/cli/stacker_client.rs | 137 +- src/configuration.rs | 62 +- src/connectors/errors.rs | 11 + src/connectors/user_service/client.rs | 123 +- src/connectors/user_service/connector.rs | 55 +- src/connectors/user_service/mock.rs | 55 +- src/connectors/user_service/mod.rs | 4 +- src/connectors/user_service/types.rs | 26 + src/console/commands/cli/init.rs | 7 +- src/console/commands/cli/marketplace.rs | 52 +- src/console/commands/cli/pipe.rs | 180 +-- src/db/dag.rs | 42 - src/db/marketplace.rs | 15 - src/db/marketplace_billing.rs | 216 +++ src/db/mod.rs | 1 + src/forms/status_panel.rs | 12 - src/helpers/json.rs | 7 + src/helpers/mq_manager.rs | 4 +- src/routes/marketplace/install.rs | 457 +++++- src/routes/marketplace/public.rs | 54 + src/routes/project/deploy.rs | 63 +- src/services/install_authorization_sweeper.rs | 119 ++ src/services/marketplace_access.rs | 298 +++- src/services/mod.rs | 1 + src/startup.rs | 9 + ...tplace_per_install_billing.feature.pending | 64 + website/dist/index.html | 135 +- website/dist/main.js | 11 +- website/dist/main.js.map | 2 +- website/dist/styles.css | 24 +- website/src/index.html | 135 +- website/src/main.ts | 11 +- website/src/styles.css | 24 +- 47 files changed, 2381 insertions(+), 1616 deletions(-) delete mode 100644 Dockerfile.agent-executor delete mode 100644 Dockerfile.agent-executor-amd64 create mode 100644 migrations/20260718130000_marketplace_install_authorization.down.sql create mode 100644 migrations/20260718130000_marketplace_install_authorization.up.sql delete mode 100644 migrations/20260719120000_dag_step_exec_correlation.down.sql delete mode 100644 migrations/20260719120000_dag_step_exec_correlation.up.sql create mode 100644 src/db/marketplace_billing.rs create mode 100644 src/services/install_authorization_sweeper.rs create mode 100644 tests/features/marketplace_per_install_billing.feature.pending diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e5b5c5d0..4a2e46d0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -273,38 +273,3 @@ jobs: file: ./stackerdb/Dockerfile push: true tags: ${{ steps.stackerdb_tags.outputs.tags }} - - agent-executor-docker: - name: Agent Executor Docker - runs-on: ubuntu-latest - needs: cicd-docker - steps: - - name: Checkout sources - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Set Docker tags - id: agent_tags - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "tags=trydirect/status-executor:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" - else - echo "tags=trydirect/status-executor:latest" >> "$GITHUB_OUTPUT" - fi - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile.agent-executor - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.agent_tags.outputs.tags }} diff --git a/Cargo.lock b/Cargo.lock index 1a014d31..db3d7dda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "actix-macros", "actix-rt", "actix_derive", - "bitflags 2.13.1", + "bitflags 2.11.0", "bytes", "crossbeam-channel", "futures-core", @@ -45,7 +45,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -81,7 +81,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags 2.13.1", + "bitflags 2.11.0", "bytes", "derive_more", "futures-core", @@ -96,23 +96,23 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.1" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.13.1", - "brotli 8.0.4", + "bitflags 2.11.0", + "brotli 8.0.2", "bytes", "bytestring", "derive_more", "encoding_rs", "flate2", - "foldhash 0.2.0", + "foldhash", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -124,8 +124,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.2", - "sha1 0.11.0", + "rand 0.9.2", + "sha1 0.10.6", "smallvec", "tokio", "tokio-util", @@ -140,7 +140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -207,9 +207,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.0" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" dependencies = [ "actix-codec", "actix-http", @@ -226,7 +226,7 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash 0.2.0", + "foldhash", "futures-core", "futures-util", "impl-more", @@ -242,7 +242,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.5", + "socket2 0.6.3", "time", "tracing", "url", @@ -275,7 +275,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -286,7 +286,7 @@ checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -409,9 +409,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] @@ -531,15 +531,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -558,9 +558,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -568,7 +568,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.18", "time", ] @@ -580,7 +580,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] @@ -592,7 +592,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -607,9 +607,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.2.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" dependencies = [ "anstyle", "bstr", @@ -673,7 +673,7 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.5.0", + "fastrand 2.4.1", "futures-lite 2.6.1", "pin-project-lite", "slab", @@ -721,9 +721,9 @@ dependencies = [ [[package]] name = "async-imap" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570" +checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" dependencies = [ "async-channel 2.5.0", "async-compression", @@ -923,7 +923,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -934,13 +934,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] @@ -960,15 +960,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -977,15 +977,14 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] @@ -1039,7 +1038,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand 2.5.0", + "fastrand 2.4.1", ] [[package]] @@ -1097,9 +1096,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -1110,7 +1109,7 @@ version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -1124,9 +1123,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", "zeroize", @@ -1186,13 +1185,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.3", + "brotli-decompressor 5.0.0", ] [[package]] @@ -1207,9 +1206,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1217,20 +1216,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", - "serde_core", + "serde", ] [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecount" @@ -1246,24 +1245,24 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" dependencies = [ "bytes", ] [[package]] name = "camino" -version = "1.2.4" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -1342,9 +1341,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -1360,9 +1359,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" @@ -1389,9 +1388,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1439,7 +1438,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "crypto-common 0.2.2", "inout 0.2.2", "zeroize", @@ -1447,9 +1446,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -1457,36 +1456,36 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] @@ -1610,9 +1609,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.4" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", @@ -1739,9 +1738,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" @@ -1754,18 +1753,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1773,27 +1772,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" @@ -1821,7 +1820,7 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.3", + "getrandom 0.4.2", "hybrid-array", "num-traits", "rand_core 0.10.1", @@ -1847,7 +1846,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "hybrid-array", "rand_core 0.10.1", ] @@ -1898,7 +1897,7 @@ checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" dependencies = [ "anyhow", "clap", - "console 0.16.4", + "console 0.16.3", "cucumber-codegen", "cucumber-expressions", "derive_more", @@ -1929,7 +1928,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.119", + "syn 2.0.117", "synthez", ] @@ -1971,7 +1970,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.3.0", "curve25519-dalek-derive", - "digest 0.11.3", + "digest 0.11.2", "fiat-crypto 0.3.0", "rustc_version", "subtle", @@ -1986,14 +1985,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ "darling_core", "darling_macro", @@ -2001,27 +2000,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim", - "syn 2.0.119", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 1.0.109", ] [[package]] @@ -2039,9 +2038,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deadpool" @@ -2096,7 +2095,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2114,9 +2113,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -2145,7 +2144,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2153,36 +2152,70 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] [[package]] name = "derive_builder" -version = "0.20.2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.13.1", ] [[package]] name = "derive_builder_core" -version = "0.20.2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.119", + "syn 1.0.109", ] [[package]] name = "derive_builder_macro" -version = "0.20.2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" dependencies = [ - "derive_builder_core", - "syn 2.0.119", + "derive_builder_core 0.13.1", + "syn 1.0.109", ] [[package]] @@ -2204,7 +2237,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn 2.0.117", "unicode-xid", ] @@ -2234,14 +2267,15 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "dialoguer" -version = "0.12.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console 0.16.4", + "console 0.15.11", "fuzzy-matcher", "shell-words", "tempfile", + "thiserror 1.0.69", "zeroize", ] @@ -2265,11 +2299,11 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -2277,13 +2311,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2300,11 +2334,11 @@ checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] name = "docker-compose-types" -version = "0.24.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdfd601efc90644958510466e8904ca90da65a98fdfe4d21f940644a21c026b4" +checksum = "6d6fdd6fa1c9e8e716f5f73406b868929f468702449621e7397066478b9bf89c" dependencies = [ - "derive_builder", + "derive_builder 0.13.1", "indexmap 2.14.0", "serde", "serde_yaml", @@ -2342,8 +2376,8 @@ version = "0.17.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" dependencies = [ - "der 0.8.1", - "digest 0.11.3", + "der 0.8.0", + "digest 0.11.2", "elliptic-curve 0.14.0-rc.33", "rfc6979 0.5.0", "signature 3.0.0", @@ -2400,9 +2434,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -2435,7 +2469,7 @@ dependencies = [ "base16ct 1.0.0", "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "digest 0.11.3", + "digest 0.11.2", "ff 0.14.0", "group 0.14.0", "hkdf 0.13.0", @@ -2489,7 +2523,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2584,9 +2618,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "ff" @@ -2622,12 +2656,13 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "filetime" -version = "0.2.29" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", + "libredox", ] [[package]] @@ -2675,7 +2710,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.9", + "spin 0.9.8", ] [[package]] @@ -2690,12 +2725,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -2728,9 +2757,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2743,9 +2772,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2753,15 +2782,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2781,9 +2810,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2806,7 +2835,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.5.0", + "fastrand 2.4.1", "futures-core", "futures-io", "parking", @@ -2815,38 +2844,38 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.4" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2881,9 +2910,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.4.4" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" dependencies = [ "generic-array 0.14.7", "rustversion", @@ -2938,15 +2967,17 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3", "wasm-bindgen", ] @@ -2966,7 +2997,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.3", + "polyval 0.7.1", ] [[package]] @@ -2980,23 +3011,23 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.119", + "syn 2.0.117", "textwrap", - "thiserror 2.0.19", + "thiserror 2.0.18", "typed-builder", ] [[package]] name = "glob" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -3011,7 +3042,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "ignore", "walkdir", ] @@ -3071,16 +3102,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.2", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -3114,7 +3145,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -3204,7 +3235,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -3229,9 +3260,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -3250,24 +3281,24 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.2", + "http 1.4.0", ] [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.2", - "http-body 1.1.0", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3321,9 +3352,9 @@ dependencies = [ [[package]] name = "humantime" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" @@ -3363,17 +3394,17 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.15", - "http 1.4.2", - "http-body 1.1.0", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3414,9 +3445,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", - "http 1.4.2", - "http-body 1.1.0", - "hyper 1.11.0", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "pin-project-lite", "tokio", ] @@ -3527,6 +3558,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3546,9 +3583,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -3556,9 +3593,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.31" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -3581,9 +3618,9 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.3.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" @@ -3669,7 +3706,7 @@ checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" dependencies = [ "num-integer", "num-traits", - "rand 0.10.2", + "rand 0.10.1", "rand_core 0.10.1", ] @@ -3760,22 +3797,23 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -3854,9 +3892,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.9", + "spin 0.9.8", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lettre" version = "0.11.22" @@ -3867,7 +3911,7 @@ dependencies = [ "base64 0.22.1", "email-encoding", "email_address", - "fastrand 2.5.0", + "fastrand 2.4.1", "futures-io", "futures-util", "httpdate", @@ -3876,19 +3920,19 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls 0.23.42", - "socket2 0.6.5", + "rustls 0.23.37", + "socket2 0.6.3", "tokio", "tokio-rustls 0.26.4", "url", - "webpki-roots 1.0.9", + "webpki-roots 1.0.6", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -3898,14 +3942,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "libc", "plain", - "redox_syscall 0.9.0", + "redox_syscall 0.7.3", ] [[package]] @@ -3976,9 +4020,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] @@ -4021,15 +4065,15 @@ dependencies = [ [[package]] name = "md5" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -4090,9 +4134,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -4124,14 +4168,14 @@ dependencies = [ "bytes", "colored", "futures-core", - "http 1.4.2", - "http-body 1.1.0", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper 1.11.0", + "hyper 1.9.0", "hyper-util", "log", "pin-project-lite", - "rand 0.9.5", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -4185,7 +4229,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -4247,9 +4291,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -4266,16 +4310,16 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.5", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -4288,10 +4332,11 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.46" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ + "autocfg", "num-integer", "num-traits", ] @@ -4360,14 +4405,15 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.81" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", + "once_cell", "openssl-macros", "openssl-sys", ] @@ -4380,7 +4426,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4397,9 +4443,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.117" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -4431,11 +4477,11 @@ dependencies = [ "hmac 0.12.1", "pkcs12", "pkcs5 0.7.1", - "rand 0.9.5", + "rand 0.9.2", "rc2", - "sha1 0.10.7", + "sha1 0.10.6", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.18", "x509-parser", ] @@ -4520,19 +4566,18 @@ dependencies = [ [[package]] name = "pageant" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" +checksum = "1b537f975f6d8dcf48db368d7ec209d583b015713b5df0f5d92d2631e4ff5595" dependencies = [ - "base16ct 1.0.0", "byteorder", "bytes", "delegate", "futures", "log", - "rand 0.10.2", - "sha2 0.11.0", - "thiserror 2.0.19", + "rand 0.8.5", + "sha2 0.10.9", + "thiserror 1.0.69", "tokio", "windows", "windows-strings", @@ -4613,7 +4658,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", "hmac 0.13.0", ] @@ -4670,9 +4715,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -4680,9 +4725,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -4690,24 +4735,25 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", + "sha2 0.10.9", ] [[package]] @@ -4756,7 +4802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.7", + "rand 0.8.5", ] [[package]] @@ -4770,22 +4816,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4848,7 +4894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand 2.5.0", + "fastrand 2.4.1", "futures-io", ] @@ -4869,7 +4915,7 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.1", + "der 0.8.0", "spki 0.8.0", ] @@ -4912,7 +4958,7 @@ dependencies = [ "aes 0.9.1", "aes-gcm 0.11.0", "cbc 0.2.1", - "der 0.8.1", + "der 0.8.0", "pbkdf2 0.13.0", "rand_core 0.10.1", "scrypt 0.12.0", @@ -4936,7 +4982,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.1", + "der 0.8.0", "pkcs5 0.8.1", "rand_core 0.10.1", "spki 0.8.0", @@ -4944,9 +4990,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plain" @@ -4986,9 +5032,9 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" dependencies = [ "cpufeatures 0.3.0", "universal-hash 0.6.1", @@ -5009,9 +5055,9 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ "cpubits", "cpufeatures 0.3.0", @@ -5020,9 +5066,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" @@ -5085,7 +5131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5146,9 +5192,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -5159,7 +5205,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "hex", "lazy_static", "procfs-core", @@ -5172,7 +5218,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "hex", ] @@ -5220,7 +5266,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.119", + "syn 2.0.117", "tempfile", ] @@ -5234,7 +5280,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5322,16 +5368,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "memchr", "unicase", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -5369,9 +5415,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5380,9 +5426,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.5" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5390,12 +5436,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -5523,43 +5569,43 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.9.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -5569,9 +5615,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -5586,9 +5632,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -5660,12 +5706,12 @@ dependencies = [ [[package]] name = "rhai" -version = "1.25.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" dependencies = [ "ahash 0.8.12", - "bitflags 2.13.1", + "bitflags 2.11.0", "no-std-compat", "num-traits", "once_cell", @@ -5679,13 +5725,13 @@ dependencies = [ [[package]] name = "rhai_codegen" -version = "3.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5743,7 +5789,7 @@ dependencies = [ "const-oid 0.10.2", "crypto-bigint 0.7.5", "crypto-primes", - "digest 0.11.3", + "digest 0.11.2", "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0", "rand_core 0.10.1", @@ -5761,7 +5807,7 @@ checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" dependencies = [ "aes 0.9.1", "aws-lc-rs", - "bitflags 2.13.1", + "bitflags 2.11.0", "block-padding 0.4.2", "byteorder", "bytes", @@ -5772,16 +5818,16 @@ dependencies = [ "curve25519-dalek 5.0.0-rc.0", "data-encoding", "delegate", - "der 0.8.1", - "digest 0.11.3", + "der 0.8.0", + "digest 0.11.2", "ecdsa 0.17.0-rc.18", "ed25519-dalek 3.0.0-rc.0", "elliptic-curve 0.14.0-rc.33", "enum_dispatch", "flate2", "futures", - "generic-array 1.4.4", - "getrandom 0.4.3", + "generic-array 1.3.5", + "getrandom 0.4.2", "ghash 0.6.0", "hex-literal", "hmac 0.13.0", @@ -5801,8 +5847,8 @@ dependencies = [ "pkcs1 0.8.0-rc.4", "pkcs5 0.8.1", "pkcs8 0.11.0", - "polyval 0.7.3", - "rand 0.10.2", + "polyval 0.7.1", + "rand 0.10.1", "rand_core 0.10.1", "rsa 0.10.0-rc.18", "russh-cryptovec", @@ -5818,7 +5864,7 @@ dependencies = [ "ssh-encoding 0.3.0-rc.9", "ssh-key 0.7.0-rc.10", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "typenum", "universal-hash 0.6.1", @@ -5897,7 +5943,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5910,7 +5956,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5933,15 +5979,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -5953,10 +5999,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" dependencies = [ "log", - "rustls 0.23.42", + "rustls 0.23.37", "rustls-native-certs", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.10", ] [[package]] @@ -5992,9 +6038,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] @@ -6012,9 +6058,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -6023,9 +6069,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -6107,7 +6153,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6132,7 +6178,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.1", + "der 0.8.0", "hybrid-array", "subtle", "zeroize", @@ -6144,7 +6190,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -6157,7 +6203,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6176,9 +6222,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.3.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" @@ -6192,9 +6238,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -6202,29 +6248,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -6297,8 +6343,8 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "strsim", - "syn 2.0.119", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -6326,9 +6372,9 @@ dependencies = [ [[package]] name = "serdect" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" dependencies = [ "base16ct 1.0.0", "serde", @@ -6336,9 +6382,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -6353,7 +6399,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -6381,7 +6427,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -6390,7 +6436,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", "keccak", ] @@ -6411,9 +6457,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -6441,15 +6487,15 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", "rand_core 0.10.1", ] [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "similar" @@ -6459,9 +6505,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skeptic" @@ -6534,9 +6580,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -6549,7 +6595,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6566,9 +6612,9 @@ dependencies = [ [[package]] name = "smawk" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" @@ -6592,9 +6638,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -6608,9 +6654,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.9" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -6632,7 +6678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.1", + "der 0.8.0", ] [[package]] @@ -6686,12 +6732,12 @@ dependencies = [ "native-tls", "once_cell", "percent-encoding", - "rustls 0.23.42", + "rustls 0.23.37", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -6710,7 +6756,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6733,7 +6779,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.119", + "syn 2.0.117", "tokio", "url", ] @@ -6746,7 +6792,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -6768,15 +6814,15 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.7", + "rand 0.8.5", "rsa 0.9.10", "serde", - "sha1 0.10.7", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -6790,7 +6836,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -6809,14 +6855,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.7", + "rand 0.8.5", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -6842,7 +6888,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -6899,7 +6945,7 @@ dependencies = [ "bytes", "crypto-bigint 0.7.5", "ctutils", - "digest 0.11.3", + "digest 0.11.2", "pem-rfc7468 1.0.0", "zeroize", ] @@ -6981,7 +7027,7 @@ dependencies = [ "config", "cucumber", "deadpool-lapin", - "derive_builder", + "derive_builder 0.12.0", "dialoguer", "docker-compose-types", "dotenvy", @@ -6990,7 +7036,7 @@ dependencies = [ "futures-lite 2.6.1", "futures-util", "glob", - "hmac 0.13.0", + "hmac 0.12.1", "indexmap 2.14.0", "indicatif", "lapin", @@ -7003,7 +7049,7 @@ dependencies = [ "prost", "prost-types", "protoc-bin-vendored", - "rand 0.8.7", + "rand 0.8.5", "redis", "regex", "reqwest", @@ -7067,6 +7113,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -7092,20 +7144,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7126,7 +7167,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7135,7 +7176,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" dependencies = [ - "syn 2.0.119", + "syn 2.0.117", "synthez-codegen", "synthez-core", ] @@ -7146,7 +7187,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" dependencies = [ - "syn 2.0.119", + "syn 2.0.117", "synthez-core", ] @@ -7159,7 +7200,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7197,9 +7238,9 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "tar" -version = "0.4.46" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -7224,8 +7265,8 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.5.0", - "getrandom 0.4.3", + "fastrand 2.4.1", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7245,7 +7286,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand 0.8.7", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -7291,9 +7332,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.18" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" +checksum = "da322882471314edc77fa5232c587bcb87c9df52bfd0d7d4826f8868ead61899" dependencies = [ "serde", ] @@ -7309,11 +7350,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.18", ] [[package]] @@ -7324,36 +7365,37 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.54" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -7363,15 +7405,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -7398,9 +7440,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -7413,9 +7455,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -7423,7 +7465,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -7451,13 +7493,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7487,15 +7529,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.42", + "rustls 0.23.37", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -7518,14 +7560,13 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", - "libc", "pin-project-lite", "tokio", ] @@ -7579,7 +7620,7 @@ dependencies = [ "proc-macro2", "prost-build", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7593,7 +7634,7 @@ dependencies = [ "indexmap 1.9.3", "pin-project", "pin-project-lite", - "rand 0.8.7", + "rand 0.8.5", "slab", "tokio", "tokio-util", @@ -7628,9 +7669,9 @@ dependencies = [ [[package]] name = "tracing-actix-web" -version = "0.7.22" +version = "0.7.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36bb7a33ce7f0807d44124b5119ea3581fd59028db56477a7aa01741c869ca6a" +checksum = "1ca6b15407f9bfcb35f82d0e79e603e1629ece4e91cc6d9e58f890c184dd20af" dependencies = [ "actix-web", "mutually_exclusive_features", @@ -7647,7 +7688,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7720,9 +7761,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" [[package]] name = "try-lock" @@ -7739,12 +7780,12 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.2", + "http 1.4.0", "httparse", "log", "native-tls", - "rand 0.8.7", - "sha1 0.10.7", + "rand 0.8.5", + "sha1 0.10.6", "thiserror 1.0.69", "url", "utf-8", @@ -7767,7 +7808,7 @@ checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -7823,9 +7864,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -7916,11 +7957,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -7940,9 +7981,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "vcpkg" @@ -8004,9 +8045,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] @@ -8019,9 +8069,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -8032,9 +8082,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -8042,9 +8092,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8052,31 +8102,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.76" +version = "0.3.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +checksum = "941c102b3f0c15b6d72a53205e09e6646aafcf2991e18412cc331dbac1806bc0" dependencies = [ "async-trait", "cast", @@ -8096,20 +8146,42 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.76" +version = "0.3.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +checksum = "a26bd6570f39bb1440fd8f01b63461faaf2a3f6078a508e4e54efa99363108d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" +checksum = "1c29582b14d5bf030b02fa232b9b57faf2afc322d2c61964dd80bad02bf76207" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] [[package]] name = "wasm-streams" @@ -8124,11 +8196,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -8150,14 +8234,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -8256,7 +8340,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -8267,7 +8351,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -8504,9 +8588,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.57.1" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -8538,7 +8704,7 @@ dependencies = [ "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.18", "time", ] @@ -8563,9 +8729,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8580,35 +8746,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] @@ -8621,15 +8787,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" @@ -8661,14 +8827,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index c45eb970..175071f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,26 +64,26 @@ futures-util = "0.3.29" futures = "0.3.29" tokio-stream = "0.1.14" actix-http = "3.4.0" -hmac = "0.13.0" +hmac = "0.12.1" sha2 = "0.10.8" sqlx-adapter = { version = "1.8.0", default-features = false, features = ["postgres", "runtime-tokio-native-tls"]} dotenvy = "0.15" # dctypes -derive_builder = "0.20.0" +derive_builder = "0.12.0" indexmap = { version = "2.0.0", features = ["serde"], optional = true } serde_yaml = "0.9" lapin = { version = "2.3.1", features = ["serde_json"] } futures-lite = "2.2.0" clap = { version = "4.4.8", features = ["derive", "env"] } clap_complete = "4" -dialoguer = { version = "0.12", features = ["fuzzy-select"] } +dialoguer = { version = "0.11", features = ["fuzzy-select"] } indicatif = "0.17" brotli = "3.4.0" serde_path_to_error = "0.1.14" zstd = "0.13" deadpool-lapin = "0.12.1" -docker-compose-types = "0.24.0" +docker-compose-types = "0.7.0" actix-casbin-auth = { git = "https://github.com/casbin-rs/actix-casbin-auth.git"} casbin = "2.2.0" aes-gcm = "0.10.3" diff --git a/Dockerfile.agent-executor b/Dockerfile.agent-executor deleted file mode 100644 index 64f6dff3..00000000 --- a/Dockerfile.agent-executor +++ /dev/null @@ -1,29 +0,0 @@ -FROM rust:bookworm AS builder - -RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY Cargo.toml Cargo.lock ./ -COPY build.rs ./ -COPY .sqlx .sqlx/ -COPY proto ./proto -COPY src ./src -COPY crates ./crates -COPY tests ./tests -COPY scenarios ./scenarios - -ENV SQLX_OFFLINE=true - -RUN cargo build --release --bin agent-executor - -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev ca-certificates; \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY --from=builder /app/target/release/agent-executor . - -ENTRYPOINT ["/app/agent-executor"] diff --git a/Dockerfile.agent-executor-amd64 b/Dockerfile.agent-executor-amd64 deleted file mode 100644 index 2f6e2ac2..00000000 --- a/Dockerfile.agent-executor-amd64 +++ /dev/null @@ -1,27 +0,0 @@ -FROM --platform=linux/amd64 rust:bookworm AS builder - -RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY Cargo.toml Cargo.lock ./ -COPY build.rs ./ -COPY .sqlx .sqlx/ -COPY proto ./proto -COPY src ./src -COPY crates ./crates - -ENV SQLX_OFFLINE=true - -RUN cargo build --release --bin agent-executor 2>&1 - -FROM --platform=linux/amd64 debian:bookworm-slim - -RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev ca-certificates; \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY --from=builder /app/target/release/agent-executor . - -ENTRYPOINT ["/app/agent-executor"] diff --git a/configuration.yaml.dist b/configuration.yaml.dist index 80d425d6..57f2bd36 100644 --- a/configuration.yaml.dist +++ b/configuration.yaml.dist @@ -23,15 +23,6 @@ amqp: username: guest password: guest -# Pipe / DAG step execution -pipe: - # in_process (default) runs steps synchronously in the API process. - # amqp publishes StepCommand to the `pipe_execution` exchange for a standalone - # agent-executor to run; only applies to remote instances (with a deployment hash). - executor_transport: in_process - # Per-level timeout (seconds) when awaiting step results over AMQP. - step_result_timeout_secs: 120 - # Vault configuration (can be overridden by environment variables) # For production, set VAULT_ADDRESS=https://vault.try.direct (not Docker-internal IPs) # The status panel agent authenticates through the Stacker server, which validates diff --git a/docs/DAG_PIPES_PART1_CLI_GUIDE.md b/docs/DAG_PIPES_PART1_CLI_GUIDE.md index 1cd6fc21..078d8879 100644 --- a/docs/DAG_PIPES_PART1_CLI_GUIDE.md +++ b/docs/DAG_PIPES_PART1_CLI_GUIDE.md @@ -232,38 +232,10 @@ stacker pipe trigger \ | `--ai` | `create` | Use AI for smart field matching | | `--no-ai` | `create` | Use deterministic matching only | | `--manual` | `create` | Skip auto-matching entirely | -| `--source-endpoint "METHOD /path"` | `create` | Specify the source endpoint by hand (skips discovery) | -| `--target-endpoint "METHOD /path"` | `create` | Specify the target endpoint by hand (skips discovery) | -| `--source-fields a,b,c` | `create` | Source field names for the manual endpoint | -| `--target-fields x,y,z` | `create` | Target field names for the manual endpoint | | `--limit 50` | `history` | Show more results | --- -## Manual endpoints (skip discovery) - -Endpoint discovery probes the running app over HTTP, which needs the app to be -reachable and to expose a recognizable API. When that doesn't work — or you -already know the endpoints — declare them directly instead: - -```bash -stacker pipe create directus chatwoot \ - --source-endpoint "GET /items/articles" \ - --target-endpoint "POST /api/v1/accounts/1/conversations" \ - --source-fields title,body \ - --target-fields subject,content -``` - -- **Both** `--source-endpoint` and `--target-endpoint` must be given together. -- When both are set, `pipe create` does **no probing at all** — so it works even - if the app isn't deployed or can't be reached. -- Format is `"METHOD /path"` (e.g. `"POST /api/v1/items"`); a bare `/path` - defaults to `GET`. -- `--source-fields` / `--target-fields` are comma-separated and drive field - mapping just like discovered fields would. - ---- - ## Trigger Types Explained | Type | How it works | Best for | 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/migrations/20260719120000_dag_step_exec_correlation.down.sql b/migrations/20260719120000_dag_step_exec_correlation.down.sql deleted file mode 100644 index 5af3acf1..00000000 --- a/migrations/20260719120000_dag_step_exec_correlation.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS idx_dag_step_exec_lookup; diff --git a/migrations/20260719120000_dag_step_exec_correlation.up.sql b/migrations/20260719120000_dag_step_exec_correlation.up.sql deleted file mode 100644 index ffd19049..00000000 --- a/migrations/20260719120000_dag_step_exec_correlation.up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Correlation key for the AMQP agent-executor path. --- StepResultMsg carries (execution_id, step_id); the platform must map that --- back to exactly one pipe_dag_step_executions row to persist the result. --- A unique index guarantees the mapping is unambiguous and lets us look rows --- up by (pipe_execution_id, step_id) instead of the surrogate row id. -CREATE UNIQUE INDEX idx_dag_step_exec_lookup - ON pipe_dag_step_executions (pipe_execution_id, step_id); diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b232d8e6..53b591db 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -1232,22 +1232,6 @@ enum PipeCommands { /// Use ML-based field matching (n-gram cosine similarity) #[arg(long, conflicts_with_all = ["ai", "no_ai"])] ml: bool, - /// Source endpoint as "METHOD /path" (e.g. "POST /api/v1/webhook"). - /// Requires --target-endpoint; when both are set, discovery is skipped. - #[arg(long)] - source_endpoint: Option, - /// Target endpoint as "METHOD /path" (e.g. "POST /api/v1/items"). - #[arg(long)] - target_endpoint: Option, - /// Comma-separated source field names (used with --source-endpoint). - #[arg(long, value_delimiter = ',')] - source_fields: Vec, - /// Comma-separated target field names (used with --target-endpoint). - #[arg(long, value_delimiter = ',')] - target_fields: Vec, - /// Pipe name (skips interactive prompt) - #[arg(long)] - name: Option, /// Output in JSON format #[arg(long)] json: bool, @@ -1274,12 +1258,6 @@ enum PipeCommands { /// Poll interval in seconds (only for --trigger=poll) #[arg(long, default_value = "300")] poll_interval: u32, - /// External source URL (agent fetches via HTTP, no curl needed) - #[arg(long)] - source_url: Option, - /// Target header as "Key: Value" (repeatable, e.g. --target-header "Authorization: Bearer tok") - #[arg(long)] - target_header: Vec, /// Output in JSON format #[arg(long)] json: bool, @@ -1305,12 +1283,6 @@ enum PipeCommands { /// Optional JSON input data to feed into the pipe #[arg(long)] data: Option, - /// External source URL (agent fetches via HTTP, no curl needed) - #[arg(long)] - source_url: Option, - /// Target header as "Key: Value" (repeatable, e.g. --target-header "Authorization: Bearer tok") - #[arg(long)] - target_header: Vec, /// Output in JSON format #[arg(long)] json: bool, @@ -2462,27 +2434,10 @@ fn get_command( ai, no_ai, ml, - source_endpoint, - target_endpoint, - source_fields, - target_fields, - name, json, deployment, } => Box::new(pipe::PipeCreateCommand::new( - source, - target, - manual, - ai, - no_ai, - ml, - json, - deployment, - source_endpoint, - target_endpoint, - source_fields, - target_fields, - name, + source, target, manual, ai, no_ai, ml, json, deployment, )), PipeCommands::List { json, deployment } => { Box::new(pipe::PipeListCommand::new(json, deployment)) @@ -2491,16 +2446,12 @@ fn get_command( pipe_id, trigger, poll_interval, - source_url, - target_header, json, deployment, } => Box::new(pipe::PipeActivateCommand::new( pipe_id, trigger, poll_interval, - source_url, - target_header, json, deployment, )), @@ -2512,17 +2463,10 @@ fn get_command( PipeCommands::Trigger { pipe_id, data, - source_url, - target_header, json, deployment, } => Box::new(pipe::PipeTriggerCommand::new( - pipe_id, - data, - source_url, - target_header, - json, - deployment, + pipe_id, data, json, deployment, )), PipeCommands::History { instance_id, @@ -2784,8 +2728,7 @@ fn get_command( provider, } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceInstallCommand::new( - template, name, file, force, json, domain, set_values, key, key_id, region, size, - provider, + 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/generator/compose.rs b/src/cli/generator/compose.rs index 80bcf445..f4624c64 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -4,8 +4,7 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, ConfigOrigin, DomainConfig, ProxyType, ServiceDefinition, - StackerConfig, + AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, }; use crate::cli::error::CliError; @@ -116,23 +115,15 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { let mut named_volumes: Vec = Vec::new(); // --- Main app service --- - // 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); - } + 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 { @@ -193,19 +184,6 @@ 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. -/// -/// User-authored configs always keep their app (it builds from the project -/// directory). Marketplace-generated configs express the entire stack in -/// `services:` and have no local app to build, so the default `app` is skipped -/// unless it declares an explicit source (image / dockerfile / build). -fn config_has_buildable_app(config: &StackerConfig) -> bool { - if config.origin != ConfigOrigin::MarketplaceGenerated { - return true; - } - config.app.image.is_some() || config.app.dockerfile.is_some() || config.app.build.is_some() -} - fn build_app_service(config: &StackerConfig) -> ComposeService { let mut svc = ComposeService { name: "app".to_string(), @@ -585,64 +563,6 @@ mod tests { assert!(app.image.is_none()); } - fn ghost_service() -> ServiceDefinition { - ServiceDefinition { - name: "ghost".to_string(), - image: "ghost:5-alpine".to_string(), - ports: vec!["2368:2368".to_string()], - environment: HashMap::new(), - volumes: vec![], - depends_on: vec![], - command: None, - healthcheck: None, - } - } - - #[test] - fn marketplace_config_without_app_source_omits_phantom_app_service() { - let mut config = minimal_config(AppType::Static); - config.origin = ConfigOrigin::MarketplaceGenerated; - config.services = vec![ghost_service()]; - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - - // No phantom `app` (which would build from an unshipped .stacker/Dockerfile). - assert!( - !names.contains(&"app"), - "marketplace stack must not synthesize an app service, got {:?}", - names - ); - assert!(names.contains(&"ghost")); - } - - #[test] - fn marketplace_config_with_explicit_app_image_keeps_app_service() { - let mut config = minimal_config(AppType::Static); - config.origin = ConfigOrigin::MarketplaceGenerated; - config.app.image = Some("myorg/app:1.0".to_string()); - config.services = vec![ghost_service()]; - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - - assert!(names.contains(&"app")); - assert!(names.contains(&"ghost")); - } - - #[test] - fn user_authored_config_with_services_still_gets_app_service() { - let mut config = minimal_config(AppType::Static); - // Default origin is UserAuthored. - config.services = vec![ghost_service()]; - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - - assert!(names.contains(&"app"), "user app must be preserved"); - assert!(names.contains(&"ghost")); - } - #[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 95d66d9d..adcfed15 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1875,10 +1875,7 @@ pub(crate) fn resolve_docker_registry_credentials( } 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.insert("docker_registry".to_string(), serde_json::Value::String(String::new())); } creds @@ -3547,7 +3544,10 @@ mod tests { #[test] fn test_resolve_docker_registry_credentials_sends_empty_when_no_config() { - let config = ConfigBuilder::new().name("public-app").build().unwrap(); + let config = ConfigBuilder::new() + .name("public-app") + .build() + .unwrap(); let creds = resolve_docker_registry_credentials(&config); assert!(creds.get("docker_username").is_none()); diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 4f190a4e..d2989de3 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -229,10 +229,7 @@ pub struct AuthorizePublicKeyResponse { // Marketplace response types // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -/// Marketplace template as returned by `GET /api/templates` (summary; list -/// form) and `GET /api/templates/{slug}` (detail). In the list form -/// `stack_definition` is absent; `get_marketplace_template` populates it from -/// the detail response's `latest_version.stack_definition`. +/// Marketplace template summary as returned by `GET /api/templates` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MarketplaceTemplate { pub id: Option, @@ -259,6 +256,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` @@ -1864,38 +1880,37 @@ impl StackerClient { let Some(item) = api.item else { return Ok(None); }; - // The server nests the full definition under `latest_version` - // (see marketplace `detail_handler`), while the summary fields live - // under `template`. Pull the definition across so callers that read - // `MarketplaceTemplate.stack_definition` (e.g. the service catalog) - // actually see it instead of always getting `None`. - let latest_stack_definition = item - .get("latest_version") - .and_then(|lv| lv.get("stack_definition")) - .filter(|sd| !sd.is_null()) - .cloned(); let template = item.get("template").cloned().unwrap_or(item); - let mut template: MarketplaceTemplate = - serde_json::from_value(template).map_err(|e| CliError::DeployFailed { + serde_json::from_value(template) + .map(Some) + .map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid marketplace template response: {}", e), - })?; - if template.stack_definition.is_none() { - template.stack_definition = latest_stack_definition; - } - Ok(Some(template)) + }) } /// 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( @@ -1908,6 +1923,7 @@ impl StackerClient { .http .post(&url) .bearer_auth(&self.token) + .header("Idempotency-Key", idempotency_key) .json(&body) .send() .await @@ -5155,83 +5171,6 @@ mod tests { ); } - #[tokio::test] - async fn test_get_marketplace_template_pulls_stack_definition_from_latest_version() { - let server = MockServer::start().await; - - // Mirror the server's `detail_handler` shape: summary fields under - // `template`, full definition under `latest_version.stack_definition`. - Mock::given(method("GET")) - .and(path("/api/templates/n8n")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "_status": "OK", - "item": { - "template": { - "id": "abc-123", - "slug": "n8n", - "name": "n8n", - "status": "approved" - }, - "latest_version": { - "definition_format": "yaml", - "stack_definition": "version: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest" - } - } - }))) - .mount(&server) - .await; - - let client = StackerClient::new(&server.uri(), "token"); - let template = client - .get_marketplace_template("n8n") - .await - .expect("request should succeed") - .expect("template should be present"); - - assert_eq!(template.slug, "n8n"); - assert_eq!( - template.stack_definition, - Some(serde_json::json!( - "version: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest" - )), - "stack_definition must be lifted out of latest_version", - ); - } - - #[tokio::test] - async fn test_get_marketplace_template_leaves_definition_none_when_absent() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/api/templates/bare")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "_status": "OK", - "item": { - "template": { - "id": "def-456", - "slug": "bare", - "name": "bare", - "status": "approved" - }, - "latest_version": { - "definition_format": "yaml", - "stack_definition": null - } - } - }))) - .mount(&server) - .await; - - let client = StackerClient::new(&server.uri(), "token"); - let template = client - .get_marketplace_template("bare") - .await - .expect("request should succeed") - .expect("template should be present"); - - assert_eq!(template.stack_definition, None); - } - #[tokio::test] async fn test_list_projects_retries_api_v1_after_forbidden_legacy_proxy_response() { let server = MockServer::start().await; diff --git a/src/configuration.rs b/src/configuration.rs index 8e7d5966..a02205c6 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -34,8 +34,12 @@ pub struct Settings { pub marketplace_assets: MarketplaceAssetSettings, #[serde(default)] pub payouts: PayoutSettings, - #[serde(default)] - pub pipe: PipeSettings, + /// 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 { @@ -68,7 +72,6 @@ impl std::fmt::Debug for Settings { .field("deployment", &self.deployment) .field("marketplace_assets", &self.marketplace_assets) .field("payouts", &self.payouts) - .field("pipe", &self.pipe) .finish() } } @@ -94,7 +97,7 @@ impl Default for Settings { deployment: DeploymentSettings::default(), marketplace_assets: MarketplaceAssetSettings::default(), payouts: PayoutSettings::default(), - pipe: PipeSettings::default(), + per_install_billing_enabled: Self::default_per_install_billing_enabled(), } } } @@ -124,6 +127,10 @@ impl Settings { true } + fn default_per_install_billing_enabled() -> bool { + false + } + fn default_casbin_reload_interval_secs() -> u64 { 10 } @@ -210,53 +217,6 @@ impl Default for DeploymentSettings { } } -/// How DAG/pipe steps are executed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExecutorTransport { - /// Steps run synchronously inside the API process (default, always safe). - InProcess, - /// Steps are published to the `pipe_execution` exchange and run by a - /// standalone agent-executor scoped to the deployment hash. - Amqp, -} - -impl Default for ExecutorTransport { - fn default() -> Self { - Self::InProcess - } -} - -/// Pipe/DAG execution settings. -#[derive(Debug, serde::Deserialize, Clone)] -pub struct PipeSettings { - /// Selects the transport for DAG step execution. `amqp` only takes effect - /// for remote instances (those with a deployment hash); local instances - /// always run in-process regardless of this setting. - #[serde(default)] - pub executor_transport: ExecutorTransport, - /// Per-level timeout (seconds) when awaiting `StepResultMsg` over AMQP. - /// A level whose results do not all arrive within this window is failed, - /// preventing a lost message from stranding a step in `running` forever. - #[serde(default = "PipeSettings::default_step_result_timeout_secs")] - pub step_result_timeout_secs: u64, -} - -impl Default for PipeSettings { - fn default() -> Self { - Self { - executor_transport: ExecutorTransport::default(), - step_result_timeout_secs: Self::default_step_result_timeout_secs(), - } - } -} - -impl PipeSettings { - fn default_step_result_timeout_secs() -> u64 { - 120 - } -} - #[derive(serde::Deserialize, Clone)] pub struct PayoutSettings { #[serde(default = "PayoutSettings::default_provider")] 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/client.rs b/src/connectors/user_service/client.rs index adf54387..f3ae34d2 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,126 @@ impl UserServiceConnector for UserServiceClient { ) .await } + + 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..f7295817 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,56 @@ pub trait UserServiceConnector: Send + Sync { page: Option, max_results: Option, ) -> 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/mock.rs b/src/connectors/user_service/mock.rs index b0be40b8..69ea50fb 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,55 @@ impl UserServiceConnector for MockUserServiceConnector { Ok(items) } + + 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/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/init.rs b/src/console/commands/cli/init.rs index a57784e0..2b6ab7cd 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -2024,9 +2024,10 @@ impl CallableTrait for InitCommand { if self.with_cloud { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); - let applied = crate::console::commands::cli::config::run_setup_cloud_interactive( - &path_str, None, - )?; + let applied = + crate::console::commands::cli::config::run_setup_cloud_interactive( + &path_str, None, + )?; for item in applied { eprintln!(" - {}", item); } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 2b4a5488..02e124b2 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -234,12 +234,7 @@ impl MarketplaceInstallCommand { } // Apply provider override - if let Some(provider) = self - .provider - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - { + 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)); @@ -247,24 +242,14 @@ impl MarketplaceInstallCommand { } // Apply region override - if let Some(region) = self - .region - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - { + 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(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)); } @@ -446,6 +431,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( @@ -453,6 +447,7 @@ impl CallableTrait for MarketplaceInstallCommand { self.name.as_deref(), deploy_form, install_inputs, + &idempotency_key, ) .await })?; @@ -810,7 +805,10 @@ async fn generate_minimal_install_config( .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)) + CliError::ConfigValidation(format!( + "Unsupported cloud provider '{}'.", + provider_code + )) })?; eprintln!( "Using cloud connection '{}' (provider: {}).", @@ -820,14 +818,8 @@ async fn generate_minimal_install_config( let cloud_config = CloudConfig { provider, orchestrator: Default::default(), - 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), + 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, @@ -1142,6 +1134,7 @@ 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); @@ -1312,6 +1305,8 @@ mod tests { template: marketplace_template("dify"), latest_version, deployment_id: None, + authorization: None, + idempotency_key: None, } } @@ -1388,6 +1383,9 @@ mod tests { 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 75835e43..239b1e94 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -37,16 +37,6 @@ use pipe_adapter_sdk::{ use regex::Regex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; - -fn parse_target_headers(raw: &[String]) -> std::collections::HashMap { - let mut headers = std::collections::HashMap::new(); - for entry in raw { - if let Some((key, value)) = entry.split_once(':') { - headers.insert(key.trim().to_string(), value.trim().to_string()); - } - } - headers -} use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, IsTerminal}; use std::path::{Path, PathBuf}; @@ -2157,17 +2147,9 @@ pub struct PipeCreateCommand { pub ml: bool, pub json: bool, pub deployment: Option, - /// Manual source endpoint "METHOD /path"; when both source+target are set, - /// discovery is skipped entirely (Fix 3). - pub source_endpoint: Option, - pub target_endpoint: Option, - pub source_fields: Vec, - pub target_fields: Vec, - pub name: Option, } impl PipeCreateCommand { - #[allow(clippy::too_many_arguments)] pub fn new( source: String, target: String, @@ -2177,11 +2159,6 @@ impl PipeCreateCommand { ml: bool, json: bool, deployment: Option, - source_endpoint: Option, - target_endpoint: Option, - source_fields: Vec, - target_fields: Vec, - name: Option, ) -> Self { Self { source, @@ -2192,48 +2169,8 @@ impl PipeCreateCommand { ml, json, deployment, - source_endpoint, - target_endpoint, - source_fields, - target_fields, - name, } } - - /// True when both endpoints are explicitly specified — skip all discovery. - fn manual_endpoints(&self) -> bool { - self.source_endpoint.is_some() && self.target_endpoint.is_some() - } -} - -/// Parse a manual endpoint spec: `"METHOD /path"` (e.g. `"POST /api/v1/items"`), -/// or a bare `"/path"` which defaults to GET. -fn parse_endpoint_spec(spec: &str) -> Result<(String, String), CliError> { - let spec = spec.trim(); - let (first, rest) = match spec.split_once(char::is_whitespace) { - Some((m, p)) => (m.trim(), p.trim()), - None => ("GET", spec), - }; - if !rest.starts_with('/') { - return Err(CliError::ConfigValidation(format!( - "Invalid endpoint '{spec}'. Use 'METHOD /path', e.g. 'POST /api/v1/items'." - ))); - } - Ok((first.to_uppercase(), rest.to_string())) -} - -/// Build a `SelectableOperation` from a manual endpoint spec + field list. -fn manual_operation(spec: &str, fields: &[String]) -> Result { - let (method, path) = parse_endpoint_spec(spec)?; - Ok(SelectableOperation { - container: None, - adapter: None, - method, - path, - summary: "manually specified endpoint".to_string(), - fields: fields.to_vec(), - sample: None, - }) } #[derive(Debug, Clone)] @@ -2906,51 +2843,6 @@ mod selectable_operation_tests { use serde_json::json; use tempfile::tempdir; - #[test] - fn parse_endpoint_spec_method_and_path() { - assert_eq!( - parse_endpoint_spec("POST /api/v1/items").unwrap(), - ("POST".to_string(), "/api/v1/items".to_string()) - ); - // Lowercase method is normalized; extra whitespace tolerated. - assert_eq!( - parse_endpoint_spec(" get /health ").unwrap(), - ("GET".to_string(), "/health".to_string()) - ); - } - - #[test] - fn parse_endpoint_spec_bare_path_defaults_to_get() { - assert_eq!( - parse_endpoint_spec("/graphql").unwrap(), - ("GET".to_string(), "/graphql".to_string()) - ); - } - - #[test] - fn parse_endpoint_spec_rejects_non_path() { - assert!(parse_endpoint_spec("POST items").is_err()); - assert!(parse_endpoint_spec("nonsense").is_err()); - } - - #[test] - fn manual_operation_carries_fields_and_no_adapter() { - let op = manual_operation( - "POST /api/v1/webhook", - &["name".to_string(), "email".to_string()], - ) - .unwrap(); - assert_eq!(op.method, "POST"); - assert_eq!(op.path, "/api/v1/webhook"); - assert_eq!(op.fields, vec!["name".to_string(), "email".to_string()]); - assert!(op.adapter.is_none()); - assert!(op.container.is_none()); - // Feeds the existing template builder as a plain {path, method}. - let tmpl = template_endpoint_for_operation(&op); - assert_eq!(tmpl["method"], "POST"); - assert_eq!(tmpl["path"], "/api/v1/webhook"); - } - #[test] fn extract_operations_includes_html_forms_and_container() { let info = AgentCommandInfo { @@ -3254,22 +3146,12 @@ impl CallableTrait for PipeCreateCommand { }; let create_protocols = default_pipe_create_protocols(); - // Fix 3: manual endpoints. Both sides must be given together; when set, - // discovery is skipped entirely and the pipe is built from the flags — - // no probing, so it works even if the app can't be reached. - if self.source_endpoint.is_some() != self.target_endpoint.is_some() { - return Err(Box::new(CliError::ConfigValidation( - "--source-endpoint and --target-endpoint must be provided together.".to_string(), - ))); - } - let manual_mode = self.manual_endpoints(); - let source_adapter_meta = builtin_adapter_for_selector(&self.source, PipeAdapterRole::Source); let target_adapter_meta = builtin_adapter_for_selector(&self.target, PipeAdapterRole::Target); - let source_run = if !manual_mode && source_adapter_meta.is_none() { + let source_run = if source_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for source '{}'...", @@ -3302,7 +3184,7 @@ impl CallableTrait for PipeCreateCommand { } else { None }; - let target_run = if !manual_mode && target_adapter_meta.is_none() { + let target_run = if target_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for target '{}'...", @@ -3374,23 +3256,13 @@ impl CallableTrait for PipeCreateCommand { return Ok(()); } - // Step 2: Extract endpoints — manual flags take precedence over discovery. - let source_ops = if manual_mode { - vec![manual_operation( - self.source_endpoint.as_deref().expect("source endpoint"), - &self.source_fields, - )?] - } else if let Some(metadata) = &source_adapter_meta { + // Step 2: Extract discovered endpoints + let source_ops = if let Some(metadata) = &source_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&source_run.as_ref().expect("source discovery").info) }; - let target_ops = if manual_mode { - vec![manual_operation( - self.target_endpoint.as_deref().expect("target endpoint"), - &self.target_fields, - )?] - } else if let Some(metadata) = &target_adapter_meta { + let target_ops = if let Some(metadata) = &target_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&target_run.as_ref().expect("target discovery").info) @@ -3549,15 +3421,11 @@ impl CallableTrait for PipeCreateCommand { }; // Step 6: Ask for pipe name - let pipe_name: String = if let Some(name) = &self.name { - name.clone() - } else { - let default_name = format!("{}-to-{}", self.source, self.target); - dialoguer::Input::new() - .with_prompt("Pipe name") - .default(default_name) - .interact_text()? - }; + let default_name = format!("{}-to-{}", self.source, self.target); + let pipe_name: String = dialoguer::Input::new() + .with_prompt("Pipe name") + .default(default_name) + .interact_text()?; // Step 7: Create template via API — include matching metadata in config let mut config = serde_json::json!({"retry_count": 3}); @@ -4366,8 +4234,6 @@ pub struct PipeActivateCommand { pub pipe_id: String, pub trigger: String, pub poll_interval: u32, - pub source_url: Option, - pub target_headers: Vec, pub json: bool, pub deployment: Option, } @@ -4377,8 +4243,6 @@ impl PipeActivateCommand { pipe_id: String, trigger: String, poll_interval: u32, - source_url: Option, - target_headers: Vec, json: bool, deployment: Option, ) -> Self { @@ -4386,8 +4250,6 @@ impl PipeActivateCommand { pipe_id, trigger, poll_interval, - source_url, - target_headers, json, deployment, } @@ -4500,17 +4362,10 @@ impl CallableTrait for PipeActivateCommand { progress::finish_success(&pb, "Status: active"); // 2. Send activate_pipe command to agent - let target_headers = if self.target_headers.is_empty() { - None - } else { - Some(parse_target_headers(&self.target_headers)) - }; - let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, "source_adapter": pipe.source_adapter.clone(), "source_container": pipe.source_container.clone(), - "source_url": self.source_url, "source_endpoint": source_endpoint, "source_method": source_method, "target_adapter": pipe.target_adapter.clone(), @@ -4518,7 +4373,6 @@ impl CallableTrait for PipeActivateCommand { "target_url": pipe.target_url.clone(), "target_endpoint": target_endpoint, "target_method": target_method, - "target_headers": target_headers, "field_mapping": field_mapping, "trigger_type": self.trigger, "poll_interval_secs": self.poll_interval, @@ -4639,8 +4493,6 @@ impl CallableTrait for PipeDeactivateCommand { pub struct PipeTriggerCommand { pub pipe_id: String, pub data: Option, - pub source_url: Option, - pub target_headers: Vec, pub json: bool, pub deployment: Option, } @@ -4649,16 +4501,12 @@ impl PipeTriggerCommand { pub fn new( pipe_id: String, data: Option, - source_url: Option, - target_headers: Vec, json: bool, deployment: Option, ) -> Self { Self { pipe_id, data, - source_url, - target_headers, json, deployment, } @@ -4756,17 +4604,9 @@ impl CallableTrait for PipeTriggerCommand { }; ensure_remote_pipe_command_capability(&ctx, &hash)?; - let target_headers = if self.target_headers.is_empty() { - None - } else { - Some(parse_target_headers(&self.target_headers)) - }; - let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, "input_data": input_data, - "source_url": self.source_url, - "target_headers": target_headers, }); let request = AgentEnqueueRequest::new(&hash, "trigger_pipe").with_raw_parameters(params); diff --git a/src/db/dag.rs b/src/db/dag.rs index 37425db8..3bf04a48 100644 --- a/src/db/dag.rs +++ b/src/db/dag.rs @@ -319,45 +319,3 @@ pub async fn update_step_execution( format!("Failed to update step execution: {}", err) }) } - -/// Update a step execution located by its correlation key `(pipe_execution_id, step_id)` -/// rather than the surrogate row id. Used by the AMQP result consumer, which only -/// knows the pair carried on `StepResultMsg`. Relies on the unique index -/// `idx_dag_step_exec_lookup` to resolve to a single row. -#[tracing::instrument(name = "Update step execution status by step", skip(pool))] -pub async fn update_step_execution_by_step( - pool: &PgPool, - pipe_execution_id: &Uuid, - step_id: &Uuid, - status: &str, - output_data: Option<&serde_json::Value>, - error: Option<&str>, -) -> Result { - let span = tracing::info_span!("Updating DAG step execution status by step"); - let now = chrono::Utc::now(); - sqlx::query_as::<_, DagStepExecution>( - r#" - UPDATE pipe_dag_step_executions SET - status = $3, - output_data = COALESCE($4, output_data), - error = COALESCE($5, error), - started_at = CASE WHEN $3 = 'running' AND started_at IS NULL THEN $6 ELSE started_at END, - completed_at = CASE WHEN $3 IN ('completed', 'failed', 'skipped') THEN $6 ELSE completed_at END - WHERE pipe_execution_id = $1 AND step_id = $2 - RETURNING id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at - "#, - ) - .bind(pipe_execution_id) - .bind(step_id) - .bind(status) - .bind(output_data) - .bind(error) - .bind(now) - .fetch_one(pool) - .instrument(span) - .await - .map_err(|err| { - tracing::error!("Failed to update step execution by step: {:?}", err); - format!("Failed to update step execution by step: {}", err) - }) -} diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index c63d9fcc..04c07375 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -377,21 +377,6 @@ pub async fn get_by_slug_with_latest( Ok((template, version)) } -/// Returns the raw `status` of a template by slug, regardless of approval -/// state. Used to distinguish "exists but not approved" from "unknown slug" -/// so install doesn't silently fall through to the catalog path (which would -/// deploy an empty stack) for a real-but-unapproved template. -pub async fn status_by_slug(pool: &PgPool, slug: &str) -> Result, SlugLookupError> { - sqlx::query_scalar::<_, String>("SELECT status FROM stack_template WHERE slug = $1") - .bind(slug) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!("status_by_slug error: {:?}", e); - SlugLookupError::Internal - }) -} - 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..7238206a --- /dev/null +++ b/src/db/marketplace_billing.rs @@ -0,0 +1,216 @@ +//! 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/forms/status_panel.rs b/src/forms/status_panel.rs index 12d1f36a..8e47f104 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -1043,9 +1043,6 @@ pub struct ActivatePipeCommandRequest { /// Source container name #[serde(default)] pub source_container: Option, - /// External source URL (agent fetches via HTTP, no curl needed) - #[serde(default)] - pub source_url: Option, /// Source endpoint path to watch #[serde(default = "default_pipe_source_endpoint")] pub source_endpoint: String, @@ -1079,9 +1076,6 @@ pub struct ActivatePipeCommandRequest { /// Target HTTP method #[serde(default = "default_target_method")] pub target_method: String, - /// Optional target headers (auth tokens, API keys, etc.) - #[serde(default)] - pub target_headers: Option>, /// Field mapping (JSONPath expressions) #[serde(default)] pub field_mapping: Option, @@ -1136,9 +1130,6 @@ pub struct TriggerPipeCommandRequest { /// Optional source container override #[serde(default)] pub source_container: Option, - /// Optional external source URL (agent fetches via HTTP, no curl needed) - #[serde(default)] - pub source_url: Option, /// Optional source endpoint override #[serde(default = "default_pipe_source_endpoint")] pub source_endpoint: String, @@ -1160,9 +1151,6 @@ pub struct TriggerPipeCommandRequest { /// Optional target method override #[serde(default = "default_target_method")] pub target_method: String, - /// Optional target headers (auth tokens, API keys, etc.) - #[serde(default)] - pub target_headers: Option>, /// Optional field mapping override #[serde(default)] pub field_mapping: Option, 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/mq_manager.rs b/src/helpers/mq_manager.rs index aba74c61..e7e31c1c 100644 --- a/src/helpers/mq_manager.rs +++ b/src/helpers/mq_manager.rs @@ -178,9 +178,7 @@ impl MqManager { msg })?; - // Return the SAME channel the queue was declared and bound on. Previously - // this created a fresh channel, which has no knowledge of the binding, so - // a `basic_consume` on it received nothing. Callers consume on this channel. + let channel = self.create_channel().await?; Ok(channel) } } diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index 37edd7e0..e653d4a1 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 { .. } @@ -108,8 +136,9 @@ fn classify_definition( 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))?; + 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!( @@ -475,6 +504,83 @@ 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, @@ -491,15 +597,147 @@ 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| { @@ -507,6 +745,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, @@ -523,6 +775,70 @@ 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() @@ -533,11 +849,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, }) } @@ -617,6 +940,8 @@ async fn install_catalog_application( .await?; Ok(InstallTemplateResponse { + authorization: None, + idempotency_key: None, project, template: application, latest_version: serde_json::json!({ @@ -681,27 +1006,6 @@ pub async fn install_handler( .internal_server_error("Internal Server Error")); } Err(db::marketplace::SlugLookupError::NotFound) => { - // `get_by_slug_with_latest` only matches approved templates. If a - // row exists for this slug but isn't approved, fall through to the - // catalog path would silently deploy an empty stack (the real - // stack_definition lives on the unapproved row and is never read). - // Fail loudly instead. - if let Some(status) = db::marketplace::status_by_slug(pg_pool.get_ref(), &slug) - .await - .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Internal Server Error") - })? - { - return Err( - JsonResponse::::build().bad_request(format!( - "Template '{}' exists but is not approved for install (status: {}). \ - Only approved templates can be installed.", - slug, status - )), - ); - } - install_catalog_application( &slug, &request, @@ -729,9 +1033,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, validate_installable_form, + amount_minor_for, build_project_form, catalog_application_project_form, + ensure_catalog_application_has_deploy_context, is_per_install_effective, summarize, + 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; @@ -875,6 +1182,7 @@ mod tests { name: None, deploy: None, install_inputs: Map::new(), + idempotency_key: None, }; assert!(ensure_catalog_application_has_deploy_context("dify", &request).is_err()); @@ -927,9 +1235,7 @@ mod tests { 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`", - ); + .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()) @@ -1076,4 +1382,95 @@ 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/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/project/deploy.rs b/src/routes/project/deploy.rs index e1e81827..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 { .. } @@ -1321,21 +1324,6 @@ fn embedded_marketplace_compose(metadata: &serde_json::Value) -> Option compose_content_from_config_files(cf).ok().flatten() } -/// Whether a docker-compose YAML string defines at least one service. -/// -/// Guards against shipping an empty stack (only `version:`, or no `services:` -/// block) to the remote host, which otherwise fails deep in ansible with an -/// opaque "empty compose file". -fn compose_defines_services(compose: &str) -> bool { - serde_yaml::from_str::(compose) - .ok() - .as_ref() - .and_then(|v| v.get("services")) - .and_then(|s| s.as_mapping()) - .map(|m| !m.is_empty()) - .unwrap_or(false) -} - async fn load_project_template_version( pg_pool: &PgPool, project: &models::Project, @@ -1394,21 +1382,6 @@ async fn execute_deployment( .map_err(|err| JsonResponse::::build().internal_server_error(err)) })?; - // Refuse to provision and ship a compose that defines no services. Without - // this guard an empty stack (e.g. an unapproved or catalog-only template - // that yielded no definition) reaches the remote host and fails deep in - // ansible with an opaque "empty compose file", after a server has already - // been provisioned and must be torn down. - if !compose_defines_services(&fc) { - return Err( - JsonResponse::::build().bad_request(format!( - "Deployment aborted: project #{} produced a docker-compose with no services. \ - This usually means the stack template has no installable definition.", - id - )), - ); - } - let mut new_public_key: Option = None; let mut bootstrap_private_key: Option = None; let provided_keypair = resolve_provided_ssh_keypair(&form.server) @@ -2325,12 +2298,12 @@ pub async fn rollback( mod tests { use super::{ apply_deploy_bundle, build_runtime_artifact_bundle, compose_content_from_config_files, - compose_defines_services, default_status_panel_npm_credentials, - derive_public_ports_from_metadata, 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, + default_status_panel_npm_credentials, derive_public_ports_from_metadata, + 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, }; use crate::configuration::Settings; use crate::connectors::app_service_catalog::ServerCapacity; @@ -2339,24 +2312,6 @@ mod tests { use serde_json::json; use uuid::Uuid; - #[test] - fn compose_defines_services_detects_real_and_empty_composes() { - // Real compose with services. - assert!(compose_defines_services( - "version: '3.8'\nservices:\n ghost:\n image: ghost:5-alpine\n" - )); - - // Only an obsolete version key, no services (the ghost-deploy failure). - assert!(!compose_defines_services("version: '3.8'\n")); - - // Explicit but empty services map. - assert!(!compose_defines_services("services: {}\n")); - - // Empty / whitespace / invalid input. - assert!(!compose_defines_services("")); - assert!(!compose_defines_services(" \n")); - } - fn build_template(slug: &str) -> models::StackTemplate { models::StackTemplate { id: Uuid::new_v4(), diff --git a/src/services/install_authorization_sweeper.rs b/src/services/install_authorization_sweeper.rs new file mode 100644 index 00000000..b305f297 --- /dev/null +++ b/src/services/install_authorization_sweeper.rs @@ -0,0 +1,119 @@ +//! 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..b40bfa3a 100644 --- a/src/services/marketplace_access.rs +++ b/src/services/marketplace_access.rs @@ -11,6 +11,10 @@ pub enum MarketplaceAccessError { InsufficientFeaturePlan, 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 +36,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 +113,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 +159,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 +214,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 +231,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 +343,94 @@ mod tests { ) -> 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 +664,121 @@ 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/website/dist/index.html b/website/dist/index.html index 99d19127..ec9de490 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -3,8 +3,8 @@ - Stacker v0.3.1 - AI Infrastructure CLI by TryDirect - + Stacker v0.2.9 — AI Infrastructure CLI by TryDirect + @@ -28,7 +28,6 @@ Release AI Engine Examples - Test Stacks Commands Providers
@@ -53,17 +52,16 @@
- New Release v0.3.1 · AI-Powered · Cloud + Bare Metal + New Release v0.2.9 · AI-Powered · Cloud + Bare Metal

Ship your stack from code to server
with one CLI.

- Stacker scans your project - or clones it straight from GitHub - generates a - production-ready stacker.yml, deploys to cloud or bare metal, manages - remote agents, links apps with authenticated pipes, and ships marketplace-ready - stacks - all from your terminal. + Stacker scans your project, generates a production-ready stacker.yml, + deploys to cloud or bare metal, manages remote agents, links apps with pipes, + and ships marketplace-ready stacks — all from your terminal.

-
v0.3.1
+
v0.2.9

- Since v0.3.0: init straight from GitHub, authenticated app pipes, - non-interactive pipe create, a service password generator, - one-command marketplace install, and more reliable config bundling. + Latest release includes AI config setup CLI, OAuth device auth, + .env bootstrapping, Hetzner location fixes, and hardened agent health & logs.

- Init from GitHub - Authenticated pipes - Pipe --source-url - Non-interactive create - Service passwords - Marketplace install - Bind-mount fixes - OpenSSH keys + AI setup CLI + Device auth + .env bootstrap + Preflight checks + npm networking + Agent health + Config promote + Hetzner fixes
@@ -141,14 +138,6 @@

AI-Powered Configuration

Deep Project Scanning

Automatically detects Dockerfile, docker-compose.yml, package.json, Cargo.toml, requirements.txt, and the rest of the stack before it writes config.

-
-
- -
-

Init Straight from GitHub

-

Run stacker init --from-github <owner/repo> to clone a repository, detect its type and Compose services, infer healthchecks, and generate stacker.yml, .env.example, and a generate-secrets.sh helper - no local checkout required.

-
New in v0.3.1
-
@@ -181,8 +170,8 @@

Agent Control + Firewall

-

Authenticated App Pipes

-

Discover endpoints, map fields, and connect apps with stacker pipe. Now with target_headers for authenticated delivery and --source-url so the agent-executor fetches inputs over HTTP - no curl inside the container.

+

Pipes & Container Linking

+

Discover endpoints, map fields, trigger flows, replay executions, and connect apps together with stacker pipe automation.

@@ -196,14 +185,14 @@

Marketplace Shipping

Service Catalog

-

20+ built-in service templates - Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

+

20+ built-in service templates — Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

SSH Key Management

-

Generate, view, and upload SSH keys for your servers - all stored securely in HashiCorp Vault. No manual key juggling.

+

Generate, view, and upload SSH keys for your servers — all stored securely in HashiCorp Vault. No manual key juggling.

@@ -214,54 +203,14 @@

SSH Key Management

-

Stacker v0.3.1
connects apps and starts from anywhere.

-

New since v0.3.0: initialize projects straight from a GitHub URL, wire apps together with authenticated pipes, generate service passwords automatically, install marketplace stacks in one command, and ship more reliable config bundling for remote deploys.

+

Stacker v0.2.9
brings smarter onboarding & deploy reliability.

+

The newest release adds AI config setup from the CLI, OAuth device auth, .env bootstrapping on deploy, improved Hetzner support, and hardened agent health and log workflows.

-
- Init -

stacker init --from-github

-

Clone a repo with --from-github <owner/repo> (or -g), auto-detect the project type and Compose services, infer healthchecks for Postgres, Redis, MySQL, Mongo, RabbitMQ, and Elasticsearch, and write stacker.yml plus .env.example and scripts/generate-secrets.sh.

-
-
- Pipes -

Authenticated pipe delivery

-

Attach target_headers to a pipe so deliveries to downstream apps carry auth tokens or signatures. Combined with --source-url, the agent-executor fetches inputs over HTTP and forwards them with the right credentials.

-
-
- Pipes -

Non-interactive pipe create

-

Pass --name to stacker pipe create for fully scripted, non-interactive setup. Activating a pipe now spins up the agent-executor automatically and probes the target container before wiring the flow.

-
-
- Services -

Service password generator

-

stacker service add now generates strong passwords for services that need them, so templates like Postgres, MySQL, and MinIO come with secure credentials out of the box instead of placeholder defaults.

-
-
- Marketplace -

One-command marketplace install

-

Install a marketplace stack directly from its Compose definition and pass cloud parameters inline on the CLI command, so provisioning and deploying a published stack is a single step.

-
-
- Deploy -

Reliable config bundling

-

Directory bind mounts such as ./library, ./assets, and ./config now pass through to remote targets, env_file references stay project-relative, and deploy-time files are mirrored into the installer runtime contract before Compose starts.

-
-
- Security -

OpenSSH-format SSH keys

-

Generated Ed25519 keypairs are validated in canonical OpenSSH format and covered by dedicated key-format tests, so keys uploaded to your servers and cloud providers work without manual conversion.

-
-
- Cloud -

Web UI firewall port fix

-

Cloud firewall port handling is fixed for the web UI path, so public ports opened from the dashboard and the CLI stay consistent across provider firewall rules.

-
AI Setup

stacker config setup ai

-

Configure your AI provider, endpoint, model, and tasks directly from the CLI - Ollama-friendly with --provider, --endpoint, and --model flags.

+

Configure your AI provider, endpoint, model, and tasks directly from the CLI — Ollama-friendly with --provider, --endpoint, and --model flags.

Auth @@ -328,7 +277,7 @@

Marketplace workflows

Your infrastructure,
understood by AI.

-

Stacker's AI engine doesn't just guess - it deeply analyzes your project to generate the perfect deployment configuration.

+

Stacker's AI engine doesn't just guess — it deeply analyzes your project to generate the perfect deployment configuration.

@@ -451,27 +400,6 @@

Ship a docs-driven open source site

- -
-
-
- - -

Real stacks.
Real deploys. Test them yourself.

-

We curate awesome-selfhosted-stacker - a growing catalog of ready-to-run, self-hosted apps with stacker.yml configs you can actually deploy. Clone one, run stacker deploy, and see the whole workflow end to end on your own cloud or bare-metal server.

- -
-
-
-
@@ -578,9 +506,9 @@

21 top-level commands.
stacker init - --from-github + --with-ai
-

Scan a local project or clone one with --from-github <owner/repo> to generate stacker.yml - with optional --with-ai assistance.

+

Scan your project and generate stacker.yml — with optional AI assistance.

@@ -631,14 +559,14 @@

21 top-level commands.
stacker service add <name>

-

Add services from 20+ built-in templates - Postgres, Redis, WordPress, and more. Auto-adds dependencies.

+

Add services from 20+ built-in templates — Postgres, Redis, WordPress, and more. Auto-adds dependencies.

stacker pipe - create --name + create
-

Connect apps and activate flows non-interactively with --name, authenticate delivery via target_headers, and fetch inputs over HTTP with --source-url.

+

Discover endpoints, connect apps, activate flows, replay executions, and inspect automation history.

@@ -757,7 +685,7 @@

How do I talk to the right agent?

Ready to deploy
smarter?

-

Ship with Stacker v0.3.1: init from GitHub, authenticated app pipes, generated service passwords, one-command marketplace install, bare-metal deploys, and agent ops in one place.

+

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

@@ -809,7 +737,6 @@

Product

diff --git a/website/dist/main.js b/website/dist/main.js index 0473fcf5..f136a6df 100644 --- a/website/dist/main.js +++ b/website/dist/main.js @@ -1,6 +1,6 @@ "use strict"; // ============================================================ -// Stacker Website - TypeScript Interactions +// Stacker Website — TypeScript Interactions // Particle system, typing effect, scroll animations, counters // ============================================================ class ParticleSystem { @@ -198,7 +198,7 @@ class ScrollAnimator { document.querySelectorAll('[data-animate]').forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.top < vh) { - // Element is already in the viewport - show it immediately + // Element is already in the viewport — show it immediately const delay = parseInt(el.dataset.delay || '0', 10); setTimeout(() => el.classList.add('is-visible'), delay); } @@ -343,7 +343,6 @@ document.addEventListener('DOMContentLoaded', () => { const heroOutput = document.getElementById('heroOutput'); if (heroCommand && heroOutput) { new TypeWriter(heroCommand, heroOutput, [ - 'stacker init --from-github trydirect/awesome-selfhosted-stacker', 'stacker init --with-ai', 'stacker service add wordpress', 'stacker deploy --target server --runtime kata', @@ -351,12 +350,6 @@ document.addEventListener('DOMContentLoaded', () => { './stacker ai ask "how do I connect my metal bare server"', 'stacker agent configure-firewall --public-ports 80/tcp,443/tcp', ], [ - [ - '▸ Cloning github.com/trydirect/awesome-selfhosted-stacker...', - '✓ Detected: Node.js + PostgreSQL + Redis from compose', - '✓ Wrote stacker.yml, .env.example, generate-secrets.sh', - '✓ Ready to deploy - run: stacker deploy', - ], [ '▸ Scanning project structure...', '✓ Detected: Python + FastAPI + PostgreSQL + Redis', diff --git a/website/dist/main.js.map b/website/dist/main.js.map index 677cb981..fbfb3201 100644 --- a/website/dist/main.js.map +++ b/website/dist/main.js.map @@ -1 +1 @@ -{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/website/dist/styles.css b/website/dist/styles.css index 3dd73fad..3d40b46a 100644 --- a/website/dist/styles.css +++ b/website/dist/styles.css @@ -1,5 +1,5 @@ /* ============================================================ - Stacker Website - Styles + Stacker Website — Styles TryDirect brand colors + modern visual effects ============================================================ */ @@ -801,28 +801,6 @@ code { line-height: 1.7; } -/* Highlight cards for features new since v0.3.0 */ -.release-card--fresh { - position: relative; - border-color: rgba(126, 87, 194, 0.45); - background: linear-gradient(160deg, rgba(126, 87, 194, 0.08), var(--color-bg-card) 55%); -} - -.release-card--fresh::after { - content: 'NEW'; - position: absolute; - top: var(--space-lg); - right: var(--space-lg); - padding: 2px 8px; - border-radius: 999px; - background: var(--color-accent); - color: #fff; - font-family: var(--font-mono); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.08em; -} - /* ============================================================ AI SECTION ============================================================ */ diff --git a/website/src/index.html b/website/src/index.html index 99d19127..ec9de490 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -3,8 +3,8 @@ - Stacker v0.3.1 - AI Infrastructure CLI by TryDirect - + Stacker v0.2.9 — AI Infrastructure CLI by TryDirect + @@ -28,7 +28,6 @@ Release AI Engine Examples - Test Stacks Commands Providers
@@ -53,17 +52,16 @@
- New Release v0.3.1 · AI-Powered · Cloud + Bare Metal + New Release v0.2.9 · AI-Powered · Cloud + Bare Metal

Ship your stack from code to server
with one CLI.

- Stacker scans your project - or clones it straight from GitHub - generates a - production-ready stacker.yml, deploys to cloud or bare metal, manages - remote agents, links apps with authenticated pipes, and ships marketplace-ready - stacks - all from your terminal. + Stacker scans your project, generates a production-ready stacker.yml, + deploys to cloud or bare metal, manages remote agents, links apps with pipes, + and ships marketplace-ready stacks — all from your terminal.

-
v0.3.1
+
v0.2.9

- Since v0.3.0: init straight from GitHub, authenticated app pipes, - non-interactive pipe create, a service password generator, - one-command marketplace install, and more reliable config bundling. + Latest release includes AI config setup CLI, OAuth device auth, + .env bootstrapping, Hetzner location fixes, and hardened agent health & logs.

- Init from GitHub - Authenticated pipes - Pipe --source-url - Non-interactive create - Service passwords - Marketplace install - Bind-mount fixes - OpenSSH keys + AI setup CLI + Device auth + .env bootstrap + Preflight checks + npm networking + Agent health + Config promote + Hetzner fixes
@@ -141,14 +138,6 @@

AI-Powered Configuration

Deep Project Scanning

Automatically detects Dockerfile, docker-compose.yml, package.json, Cargo.toml, requirements.txt, and the rest of the stack before it writes config.

-
-
- -
-

Init Straight from GitHub

-

Run stacker init --from-github <owner/repo> to clone a repository, detect its type and Compose services, infer healthchecks, and generate stacker.yml, .env.example, and a generate-secrets.sh helper - no local checkout required.

-
New in v0.3.1
-
@@ -181,8 +170,8 @@

Agent Control + Firewall

-

Authenticated App Pipes

-

Discover endpoints, map fields, and connect apps with stacker pipe. Now with target_headers for authenticated delivery and --source-url so the agent-executor fetches inputs over HTTP - no curl inside the container.

+

Pipes & Container Linking

+

Discover endpoints, map fields, trigger flows, replay executions, and connect apps together with stacker pipe automation.

@@ -196,14 +185,14 @@

Marketplace Shipping

Service Catalog

-

20+ built-in service templates - Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

+

20+ built-in service templates — Postgres, Redis, WordPress, Elasticsearch, and more. Add with stacker service add or let AI update the stack for you.

SSH Key Management

-

Generate, view, and upload SSH keys for your servers - all stored securely in HashiCorp Vault. No manual key juggling.

+

Generate, view, and upload SSH keys for your servers — all stored securely in HashiCorp Vault. No manual key juggling.

@@ -214,54 +203,14 @@

SSH Key Management

-

Stacker v0.3.1
connects apps and starts from anywhere.

-

New since v0.3.0: initialize projects straight from a GitHub URL, wire apps together with authenticated pipes, generate service passwords automatically, install marketplace stacks in one command, and ship more reliable config bundling for remote deploys.

+

Stacker v0.2.9
brings smarter onboarding & deploy reliability.

+

The newest release adds AI config setup from the CLI, OAuth device auth, .env bootstrapping on deploy, improved Hetzner support, and hardened agent health and log workflows.

-
- Init -

stacker init --from-github

-

Clone a repo with --from-github <owner/repo> (or -g), auto-detect the project type and Compose services, infer healthchecks for Postgres, Redis, MySQL, Mongo, RabbitMQ, and Elasticsearch, and write stacker.yml plus .env.example and scripts/generate-secrets.sh.

-
-
- Pipes -

Authenticated pipe delivery

-

Attach target_headers to a pipe so deliveries to downstream apps carry auth tokens or signatures. Combined with --source-url, the agent-executor fetches inputs over HTTP and forwards them with the right credentials.

-
-
- Pipes -

Non-interactive pipe create

-

Pass --name to stacker pipe create for fully scripted, non-interactive setup. Activating a pipe now spins up the agent-executor automatically and probes the target container before wiring the flow.

-
-
- Services -

Service password generator

-

stacker service add now generates strong passwords for services that need them, so templates like Postgres, MySQL, and MinIO come with secure credentials out of the box instead of placeholder defaults.

-
-
- Marketplace -

One-command marketplace install

-

Install a marketplace stack directly from its Compose definition and pass cloud parameters inline on the CLI command, so provisioning and deploying a published stack is a single step.

-
-
- Deploy -

Reliable config bundling

-

Directory bind mounts such as ./library, ./assets, and ./config now pass through to remote targets, env_file references stay project-relative, and deploy-time files are mirrored into the installer runtime contract before Compose starts.

-
-
- Security -

OpenSSH-format SSH keys

-

Generated Ed25519 keypairs are validated in canonical OpenSSH format and covered by dedicated key-format tests, so keys uploaded to your servers and cloud providers work without manual conversion.

-
-
- Cloud -

Web UI firewall port fix

-

Cloud firewall port handling is fixed for the web UI path, so public ports opened from the dashboard and the CLI stay consistent across provider firewall rules.

-
AI Setup

stacker config setup ai

-

Configure your AI provider, endpoint, model, and tasks directly from the CLI - Ollama-friendly with --provider, --endpoint, and --model flags.

+

Configure your AI provider, endpoint, model, and tasks directly from the CLI — Ollama-friendly with --provider, --endpoint, and --model flags.

Auth @@ -328,7 +277,7 @@

Marketplace workflows

Your infrastructure,
understood by AI.

-

Stacker's AI engine doesn't just guess - it deeply analyzes your project to generate the perfect deployment configuration.

+

Stacker's AI engine doesn't just guess — it deeply analyzes your project to generate the perfect deployment configuration.

@@ -451,27 +400,6 @@

Ship a docs-driven open source site

- -
-
-
- - -

Real stacks.
Real deploys. Test them yourself.

-

We curate awesome-selfhosted-stacker - a growing catalog of ready-to-run, self-hosted apps with stacker.yml configs you can actually deploy. Clone one, run stacker deploy, and see the whole workflow end to end on your own cloud or bare-metal server.

- -
-
-
-
@@ -578,9 +506,9 @@

21 top-level commands.
stacker init - --from-github + --with-ai
-

Scan a local project or clone one with --from-github <owner/repo> to generate stacker.yml - with optional --with-ai assistance.

+

Scan your project and generate stacker.yml — with optional AI assistance.

@@ -631,14 +559,14 @@

21 top-level commands.
stacker service
add <name>

-

Add services from 20+ built-in templates - Postgres, Redis, WordPress, and more. Auto-adds dependencies.

+

Add services from 20+ built-in templates — Postgres, Redis, WordPress, and more. Auto-adds dependencies.

stacker pipe - create --name + create
-

Connect apps and activate flows non-interactively with --name, authenticate delivery via target_headers, and fetch inputs over HTTP with --source-url.

+

Discover endpoints, connect apps, activate flows, replay executions, and inspect automation history.

@@ -757,7 +685,7 @@

How do I talk to the right agent?

Ready to deploy
smarter?

-

Ship with Stacker v0.3.1: init from GitHub, authenticated app pipes, generated service passwords, one-command marketplace install, bare-metal deploys, and agent ops in one place.

+

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

@@ -809,7 +737,6 @@

Product

diff --git a/website/src/main.ts b/website/src/main.ts index bd6520a9..ce6a42ef 100644 --- a/website/src/main.ts +++ b/website/src/main.ts @@ -1,5 +1,5 @@ // ============================================================ -// Stacker Website - TypeScript Interactions +// Stacker Website — TypeScript Interactions // Particle system, typing effect, scroll animations, counters // ============================================================ @@ -248,7 +248,7 @@ class ScrollAnimator { document.querySelectorAll('[data-animate]').forEach((el) => { const rect = (el as HTMLElement).getBoundingClientRect(); if (rect.top < vh) { - // Element is already in the viewport - show it immediately + // Element is already in the viewport — show it immediately const delay = parseInt((el as HTMLElement).dataset.delay || '0', 10); setTimeout(() => (el as HTMLElement).classList.add('is-visible'), delay); } else { @@ -418,7 +418,6 @@ document.addEventListener('DOMContentLoaded', () => { heroCommand, heroOutput, [ - 'stacker init --from-github trydirect/awesome-selfhosted-stacker', 'stacker init --with-ai', 'stacker service add wordpress', 'stacker deploy --target server --runtime kata', @@ -427,12 +426,6 @@ document.addEventListener('DOMContentLoaded', () => { 'stacker agent configure-firewall --public-ports 80/tcp,443/tcp', ], [ - [ - '▸ Cloning github.com/trydirect/awesome-selfhosted-stacker...', - '✓ Detected: Node.js + PostgreSQL + Redis from compose', - '✓ Wrote stacker.yml, .env.example, generate-secrets.sh', - '✓ Ready to deploy - run: stacker deploy', - ], [ '▸ Scanning project structure...', '✓ Detected: Python + FastAPI + PostgreSQL + Redis', diff --git a/website/src/styles.css b/website/src/styles.css index 3dd73fad..3d40b46a 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -1,5 +1,5 @@ /* ============================================================ - Stacker Website - Styles + Stacker Website — Styles TryDirect brand colors + modern visual effects ============================================================ */ @@ -801,28 +801,6 @@ code { line-height: 1.7; } -/* Highlight cards for features new since v0.3.0 */ -.release-card--fresh { - position: relative; - border-color: rgba(126, 87, 194, 0.45); - background: linear-gradient(160deg, rgba(126, 87, 194, 0.08), var(--color-bg-card) 55%); -} - -.release-card--fresh::after { - content: 'NEW'; - position: absolute; - top: var(--space-lg); - right: var(--space-lg); - padding: 2px 8px; - border-radius: 999px; - background: var(--color-accent); - color: #fff; - font-family: var(--font-mono); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.08em; -} - /* ============================================================ AI SECTION ============================================================ */ From 6666d8c57196aeefaa20eb3aaf898853d0b7bfab Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 22 Jul 2026 13:36:35 +0300 Subject: [PATCH 21/29] stack author/creator (vendor) column for stack templates command --- src/cli/stacker_client.rs | 2 ++ src/console/commands/cli/marketplace.rs | 23 +++++++++++------------ src/routes/marketplace/search.rs | 6 +++++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index d2989de3..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, } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 02e124b2..4e5af9bb 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -59,15 +59,16 @@ impl CallableTrait for MarketplaceTemplatesCommand { } println!( - "{:<24} {:<28} {:<14} {}", - "ITEM", "NAME", "PLAN", "DESCRIPTION" + "{:<24} {:<28} {:<20} {:<14} {}", + "ITEM", "NAME", "VENDOR", "PLAN", "DESCRIPTION" ); - println!("{}", "\u{2500}".repeat(92)); + println!("{}", "\u{2500}".repeat(114)); for template in &templates { println!( - "{:<24} {:<28} {:<14} {}", + "{:<24} {:<28} {:<20} {:<14} {}", truncate(&template.slug, 22), truncate(&template.name, 26), + truncate(template.creator_name.as_deref().unwrap_or("-"), 18), display_plan(template), truncate(template.description.as_deref().unwrap_or(""), 26), ); @@ -84,10 +85,6 @@ impl CallableTrait for MarketplaceTemplatesCommand { } } -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// marketplace find/install -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - pub struct MarketplaceFindCommand { query: String, json: bool, @@ -140,15 +137,16 @@ impl CallableTrait for MarketplaceFindCommand { } println!( - "{:<24} {:<28} {:<14} {}", - "ITEM", "NAME", "PLAN", "DESCRIPTION" + "{:<24} {:<28} {:<20} {:<14} {}", + "ITEM", "NAME", "VENDOR", "PLAN", "DESCRIPTION" ); - println!("{}", "\u{2500}".repeat(92)); + println!("{}", "\u{2500}".repeat(114)); for template in &templates { println!( - "{:<24} {:<28} {:<14} {}", + "{:<24} {:<28} {:<20} {:<14} {}", truncate(&template.slug, 22), truncate(&template.name, 26), + truncate(template.creator_name.as_deref().unwrap_or("-"), 18), display_plan(template), truncate(template.description.as_deref().unwrap_or(""), 26), ); @@ -1323,6 +1321,7 @@ mod tests { price: None, billing_cycle: None, is_from_marketplace: None, + creator_name: None, stack_definition: None, } } 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); + } } } } From c5b67c5683825887153c4bd3e069bdd2de603f0a Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 22 Jul 2026 15:14:30 +0300 Subject: [PATCH 22/29] phantom-app fix --- src/cli/generator/compose.rs | 113 ++++++++++- src/connectors/user_service/client.rs | 11 ++ src/connectors/user_service/connector.rs | 10 + src/connectors/user_service/mock.rs | 8 + src/routes/marketplace/install.rs | 242 ++++++++++++++++++++++- src/services/marketplace_access.rs | 8 + 6 files changed, 382 insertions(+), 10 deletions(-) diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index f4624c64..176bed43 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -4,7 +4,8 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, + AppType, ComposeHealthcheck, ConfigOrigin, DomainConfig, ProxyType, ServiceDefinition, + StackerConfig, }; use crate::cli::error::CliError; @@ -115,15 +116,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,6 +193,25 @@ 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. +/// +/// User-authored configs always keep their app (it builds from the project +/// directory). Marketplace-generated configs express the entire stack in +/// `services:` and have no local app to build, so the default `app` is skipped +/// — but only when it declares no explicit source AND the config actually has +/// services to deploy. Skipping it for a service-less marketplace config would +/// yield an empty compose, so we keep the app there as a fallback. +fn config_has_buildable_app(config: &StackerConfig) -> bool { + if config.origin != ConfigOrigin::MarketplaceGenerated { + return true; + } + if config.app.image.is_some() || config.app.dockerfile.is_some() || config.app.build.is_some() + { + return true; + } + config.services.is_empty() +} + fn build_app_service(config: &StackerConfig) -> ComposeService { let mut svc = ComposeService { name: "app".to_string(), @@ -563,6 +591,77 @@ mod tests { assert!(app.image.is_none()); } + fn ghost_service() -> ServiceDefinition { + ServiceDefinition { + name: "ghost".to_string(), + image: "ghost:5-alpine".to_string(), + ports: vec!["2368:2368".to_string()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + command: None, + healthcheck: None, + } + } + + #[test] + fn marketplace_config_without_app_source_omits_phantom_app_service() { + let mut config = minimal_config(AppType::Static); + config.origin = ConfigOrigin::MarketplaceGenerated; + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + // No phantom `app` (which would build from an unshipped .stacker/Dockerfile). + assert!( + !names.contains(&"app"), + "marketplace stack must not synthesize an app service, got {:?}", + names + ); + assert!(names.contains(&"ghost")); + } + + #[test] + fn marketplace_config_with_explicit_app_image_keeps_app_service() { + let mut config = minimal_config(AppType::Static); + config.origin = ConfigOrigin::MarketplaceGenerated; + config.app.image = Some("myorg/app:1.0".to_string()); + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"app")); + assert!(names.contains(&"ghost")); + } + + #[test] + fn marketplace_config_without_services_keeps_app_to_avoid_empty_compose() { + let mut config = minimal_config(AppType::Static); + config.origin = ConfigOrigin::MarketplaceGenerated; + // No services and no explicit app source: dropping the app would leave + // an empty compose, so the fallback app must be kept. + assert!(config.services.is_empty()); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"app")); + } + + #[test] + fn user_authored_config_with_services_still_gets_app_service() { + let mut config = minimal_config(AppType::Static); + // Default origin is UserAuthored. + config.services = vec![ghost_service()]; + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"app"), "user app must be preserved"); + assert!(names.contains(&"ghost")); + } + #[test] fn test_compose_app_service_with_explicit_image() { let config = ConfigBuilder::new() diff --git a/src/connectors/user_service/client.rs b/src/connectors/user_service/client.rs index f3ae34d2..880affce 100644 --- a/src/connectors/user_service/client.rs +++ b/src/connectors/user_service/client.rs @@ -637,6 +637,17 @@ 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, diff --git a/src/connectors/user_service/connector.rs b/src/connectors/user_service/connector.rs index f7295817..b3933861 100644 --- a/src/connectors/user_service/connector.rs +++ b/src/connectors/user_service/connector.rs @@ -81,6 +81,16 @@ pub trait UserServiceConnector: Send + Sync { 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 diff --git a/src/connectors/user_service/mock.rs b/src/connectors/user_service/mock.rs index 69ea50fb..b8b69829 100644 --- a/src/connectors/user_service/mock.rs +++ b/src/connectors/user_service/mock.rs @@ -243,6 +243,14 @@ 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, diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index e653d4a1..baf101d0 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -275,6 +275,136 @@ fn build_form_from_yaml_embed( }) } +/// 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", + ] { + 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 image = application + .get("docker_image") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty())?; + + // Ports: prefer an explicit `default_ports` list, else `default_port`. + let mut ports: Vec = Vec::new(); + if let Some(arr) = application.get("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) = application.get("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 application.get("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()]), + ); + + let mut services = serde_yaml::Mapping::new(); + services.insert(service_code.into(), serde_yaml::Value::Mapping(service)); + + 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, @@ -290,6 +420,18 @@ 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 one-service compose + // from the application's docker image so the deploy has something to ship. + // Embedded as `marketplace_config_files` (same channel DB templates use), + // it is picked up by `embedded_marketplace_compose` at deploy time. + let marketplace_config_files = match synthesize_catalog_compose(application, &stack_code) { + Some(compose) => serde_json::json!([{ + "name": "docker-compose.yml", + "content": compose, + }]), + None => serde_json::json!([]), + }; + let form_value = serde_json::json!({ "custom": { "custom_stack_code": stack_code, @@ -307,6 +449,7 @@ fn catalog_application_project_form( "feature": [], "service": [], "networks": [], + "marketplace_config_files": marketplace_config_files, "catalog_application": application } }); @@ -896,7 +1039,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(|| { @@ -906,6 +1049,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!( @@ -1034,8 +1192,8 @@ pub async fn install_handler( mod tests { use super::{ amount_minor_for, build_project_form, catalog_application_project_form, - ensure_catalog_application_has_deploy_context, is_per_install_effective, summarize, - validate_installable_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; @@ -1160,6 +1318,84 @@ 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 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( diff --git a/src/services/marketplace_access.rs b/src/services/marketplace_access.rs index b40bfa3a..23c0d006 100644 --- a/src/services/marketplace_access.rs +++ b/src/services/marketplace_access.rs @@ -344,6 +344,14 @@ mod tests { unimplemented!() } + async fn get_catalog_application( + &self, + _user_token: &str, + _code: &str, + ) -> Result, ConnectorError> { + unimplemented!() + } + async fn can_charge( &self, _user_token: &str, From e174a53534bc46a0ab126a9ce6f598e46e475ef0 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 22 Jul 2026 21:23:18 +0300 Subject: [PATCH 23/29] =?UTF-8?q?Implemented=20the=20Stacker-side=20webhoo?= =?UTF-8?q?k=20federation=20(items=201=E2=80=934)=20+=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/config_parser.rs | 27 +++ src/cli/generator/compose.rs | 168 ++++++++----- .../user_service/marketplace_webhook.rs | 226 +++++++++++++++++- src/console/commands/cli/init.rs | 2 + src/console/commands/cli/secrets.rs | 1 + src/db/marketplace.rs | 36 +++ src/routes/marketplace/admin.rs | 12 + 7 files changed, 408 insertions(+), 64 deletions(-) diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 25788264..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, }) } } diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 176bed43..3157b7c2 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -4,8 +4,7 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, ConfigOrigin, DomainConfig, ProxyType, ServiceDefinition, - StackerConfig, + AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, }; use crate::cli::error::CliError; @@ -195,20 +194,28 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { /// Whether the config's `app` section should be materialized as a service. /// -/// User-authored configs always keep their app (it builds from the project -/// directory). Marketplace-generated configs express the entire stack in -/// `services:` and have no local app to build, so the default `app` is skipped -/// — but only when it declares no explicit source AND the config actually has -/// services to deploy. Skipping it for a service-less marketplace config would -/// yield an empty compose, so we keep the app there as a fallback. +/// `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.origin != ConfigOrigin::MarketplaceGenerated { - return true; - } 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() } @@ -524,7 +531,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; @@ -591,75 +598,110 @@ mod tests { assert!(app.image.is_none()); } - fn ghost_service() -> ServiceDefinition { - ServiceDefinition { - name: "ghost".to_string(), - image: "ghost:5-alpine".to_string(), - ports: vec!["2368:2368".to_string()], - environment: HashMap::new(), - volumes: vec![], - depends_on: vec![], - command: None, - healthcheck: 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 marketplace_config_without_app_source_omits_phantom_app_service() { - let mut config = minimal_config(AppType::Static); - config.origin = ConfigOrigin::MarketplaceGenerated; - config.services = vec![ghost_service()]; - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - - // No phantom `app` (which would build from an unshipped .stacker/Dockerfile). + 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"), - "marketplace stack must not synthesize an app service, got {:?}", + !names.contains(&"app".to_string()), + "services-only config must not synthesize an app service, got {:?}", names ); - assert!(names.contains(&"ghost")); + assert!(names.contains(&"screego".to_string())); } + // Same shape, but marketplace-origin (the ghost case) — identical result. #[test] - fn marketplace_config_with_explicit_app_image_keeps_app_service() { - let mut config = minimal_config(AppType::Static); - config.origin = ConfigOrigin::MarketplaceGenerated; - config.app.image = Some("myorg/app:1.0".to_string()); - config.services = vec![ghost_service()]; - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - - assert!(names.contains(&"app")); - assert!(names.contains(&"ghost")); + 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 marketplace_config_without_services_keeps_app_to_avoid_empty_compose() { - let mut config = minimal_config(AppType::Static); - config.origin = ConfigOrigin::MarketplaceGenerated; - // No services and no explicit app source: dropping the app would leave - // an empty compose, so the fallback app must be kept. - assert!(config.services.is_empty()); - - let compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); - assert!(names.contains(&"app")); + 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 user_authored_config_with_services_still_gets_app_service() { + fn config_with_explicit_app_image_keeps_app_service() { let mut config = minimal_config(AppType::Static); - // Default origin is UserAuthored. - config.services = vec![ghost_service()]; + 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 compose = ComposeDefinition::try_from(&config).unwrap(); - let names: Vec<&str> = compose.services.iter().map(|s| s.name.as_str()).collect(); + let names = service_names(&config); + assert!(names.contains(&"app".to_string())); + assert!(names.contains(&"db".to_string())); + } - assert!(names.contains(&"app"), "user app must be preserved"); - assert!(names.contains(&"ghost")); + // 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] 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/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 2b6ab7cd..8379705c 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1659,6 +1659,8 @@ fn convert_compose_to_stacker(ai_output: &str, repo_name: &str) -> Option 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/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 5d53c1be..6ba21690 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -134,6 +134,17 @@ 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 +160,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 From d50d7a1a6022157e3cfbeae12fafbd494d09ca4e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 22 Jul 2026 21:55:20 +0300 Subject: [PATCH 24/29] tests fixed. cargo fmt --all --- tests/marketplace_integration.rs | 4 ++++ 1 file changed, 4 insertions(+) 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"); From e6d317ebe31345bc0da23095f5119a8258ead631 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 23 Jul 2026 12:28:40 +0300 Subject: [PATCH 25/29] legacy stack services --- src/bin/stacker.rs | 3 +- src/cli/generator/compose.rs | 9 +- src/cli/install_runner.rs | 10 +- src/connectors/user_service/app.rs | 31 +++ src/connectors/user_service/client.rs | 5 +- src/connectors/user_service/connector.rs | 5 +- src/connectors/user_service/mock.rs | 5 +- src/connectors/user_service/stack.rs | 69 +++++- src/console/commands/cli/init.rs | 7 +- src/console/commands/cli/marketplace.rs | 36 ++- src/db/marketplace_billing.rs | 11 +- src/routes/marketplace/admin.rs | 5 +- src/routes/marketplace/install.rs | 205 +++++++++++++----- src/services/install_authorization_sweeper.rs | 9 +- src/services/marketplace_access.rs | 55 +++-- website/dist/index.html | 9 + website/src/index.html | 9 + 17 files changed, 340 insertions(+), 143 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 53b591db..f00072b5 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -2728,7 +2728,8 @@ fn get_command( provider, } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceInstallCommand::new( - template, name, file, force, json, domain, set_values, key, key_id, region, size, provider, + 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/generator/compose.rs b/src/cli/generator/compose.rs index 3157b7c2..44fe2a20 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -209,8 +209,7 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { /// 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() - { + if config.app.image.is_some() || config.app.dockerfile.is_some() || config.app.build.is_some() { return true; } if config.app_present { @@ -666,7 +665,11 @@ services: 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(&"app".to_string()), + "declared app must be kept: {:?}", + names + ); assert!(names.contains(&"db".to_string())); } diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index adcfed15..95d66d9d 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -1875,7 +1875,10 @@ pub(crate) fn resolve_docker_registry_credentials( } 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.insert( + "docker_registry".to_string(), + serde_json::Value::String(String::new()), + ); } creds @@ -3544,10 +3547,7 @@ mod tests { #[test] fn test_resolve_docker_registry_credentials_sends_empty_when_no_config() { - let config = ConfigBuilder::new() - .name("public-app") - .build() - .unwrap(); + let config = ConfigBuilder::new().name("public-app").build().unwrap(); let creds = resolve_docker_registry_credentials(&config); assert!(creds.get("docker_username").is_none()); 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 880affce..f394fe27 100644 --- a/src/connectors/user_service/client.rs +++ b/src/connectors/user_service/client.rs @@ -648,10 +648,7 @@ impl UserServiceConnector for UserServiceClient { .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) } - async fn can_charge( - &self, - user_token: &str, - ) -> Result { + 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 diff --git a/src/connectors/user_service/connector.rs b/src/connectors/user_service/connector.rs index b3933861..f66a1629 100644 --- a/src/connectors/user_service/connector.rs +++ b/src/connectors/user_service/connector.rs @@ -103,10 +103,7 @@ pub trait UserServiceConnector: Send + Sync { /// 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; + 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 diff --git a/src/connectors/user_service/mock.rs b/src/connectors/user_service/mock.rs index b8b69829..09a07853 100644 --- a/src/connectors/user_service/mock.rs +++ b/src/connectors/user_service/mock.rs @@ -251,10 +251,7 @@ impl UserServiceConnector for MockUserServiceConnector { Ok(None) } - async fn can_charge( - &self, - _user_token: &str, - ) -> Result { + async fn can_charge(&self, _user_token: &str) -> Result { Ok(BillingCapability { can_charge: true, reason: None, 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/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 8379705c..7c4e33df 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -2026,10 +2026,9 @@ impl CallableTrait for InitCommand { if self.with_cloud { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); - let applied = - crate::console::commands::cli::config::run_setup_cloud_interactive( - &path_str, None, - )?; + let applied = crate::console::commands::cli::config::run_setup_cloud_interactive( + &path_str, None, + )?; for item in applied { eprintln!(" - {}", item); } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 4e5af9bb..cb02a655 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -232,7 +232,12 @@ impl MarketplaceInstallCommand { } // Apply provider override - if let Some(provider) = self.provider.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + 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)); @@ -240,14 +245,24 @@ impl MarketplaceInstallCommand { } // Apply region override - if let Some(region) = self.region.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + 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(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)); } @@ -803,10 +818,7 @@ async fn generate_minimal_install_config( .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 - )) + CliError::ConfigValidation(format!("Unsupported cloud provider '{}'.", provider_code)) })?; eprintln!( "Using cloud connection '{}' (provider: {}).", @@ -816,8 +828,14 @@ async fn generate_minimal_install_config( let cloud_config = CloudConfig { provider, orchestrator: Default::default(), - 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), + 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, diff --git a/src/db/marketplace_billing.rs b/src/db/marketplace_billing.rs index 7238206a..5b9c33f6 100644 --- a/src/db/marketplace_billing.rs +++ b/src/db/marketplace_billing.rs @@ -102,11 +102,7 @@ pub async fn insert_authorization( } /// Link the authorization to the freshly-inserted project row. -pub async fn attach_project( - pool: &PgPool, - auth_id: Uuid, - project_id: i32, -) -> Result<(), String> { +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() @@ -143,10 +139,7 @@ pub async fn attach_deployment_hash( /// 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> { +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() diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 6ba21690..40f2e8e1 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -141,7 +141,10 @@ pub async fn approve_handler( 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); + tracing::warn!( + "Failed to load latest version for webhook federation: {}", + err + ); None }); diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index baf101d0..f1307d88 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -136,9 +136,8 @@ fn classify_definition( 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) - })?; + 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!( @@ -316,15 +315,71 @@ fn json_scalar_to_string(value: &Value) -> Option { /// 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) = application.get("default_ports").and_then(|v| v.as_array()) { + 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}"))); @@ -339,7 +394,7 @@ fn synthesize_catalog_compose(application: &Value, service_code: &str) -> Option } } if ports.is_empty() { - if let Some(n) = application.get("default_port").and_then(|v| v.as_i64()) { + if let Some(n) = default_port.and_then(|v| v.as_i64()) { ports.push(serde_yaml::Value::String(format!("{n}:{n}"))); } } @@ -348,7 +403,7 @@ fn synthesize_catalog_compose(application: &Value, service_code: &str) -> Option // "KEY=value" entries. Anything unrecognized is skipped rather than // guessed at. let mut env = serde_yaml::Mapping::new(); - match application.get("default_env") { + match default_env { Some(Value::Object(map)) => { for (k, v) in map { if let Some(s) = json_scalar_to_string(v) { @@ -359,10 +414,9 @@ fn synthesize_catalog_compose(application: &Value, service_code: &str) -> Option 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(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()); } @@ -388,15 +442,20 @@ fn synthesize_catalog_compose(application: &Value, service_code: &str) -> Option "networks".into(), serde_yaml::Value::Sequence(vec!["default_network".into()]), ); + serde_yaml::Value::Mapping(service) +} - let mut services = serde_yaml::Mapping::new(); - services.insert(service_code.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)); + 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)); @@ -420,16 +479,26 @@ 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 one-service compose - // from the application's docker image so the deploy has something to ship. - // Embedded as `marketplace_config_files` (same channel DB templates use), - // it is picked up by `embedded_marketplace_compose` at deploy time. + // 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 => serde_json::json!([]), + None => { + return Err(JsonResponse::::build().bad_request(format!( + "Catalog application '{}' cannot be installed: no container image was available to synthesize a docker-compose file. This usually means the catalog entry (or its member apps) is missing a dockerhub_image.", + slug + ))); + } }; let form_value = serde_json::json!({ @@ -666,17 +735,21 @@ fn is_per_install_effective(template: &models::StackTemplate, settings: &Setting 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 - ))); + 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 - ))); + return Err( + JsonResponse::::build().bad_request(format!( + "Template '{}' price rounds to zero cents", + template.slug + )), + ); } Ok(cents) } @@ -714,7 +787,9 @@ fn spawn_void( }); } -fn summarize(handle: &crate::connectors::user_service::AuthorizationHandle) -> AuthorizationSummary { +fn summarize( + handle: &crate::connectors::user_service::AuthorizationHandle, +) -> AuthorizationSummary { AuthorizationSummary { authorization_id: handle.authorization_id.clone(), status: handle.status.clone(), @@ -749,7 +824,10 @@ async fn install_stack_template( .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 currency = template + .currency + .clone() + .unwrap_or_else(|| "USD".to_string()); let idempotency_key = request .idempotency_key .clone() @@ -774,14 +852,10 @@ async fn install_stack_template( ) .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)) - } + 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() @@ -944,31 +1018,22 @@ async fn install_stack_template( deploy_id, auth_row.id ), - Err(err) => tracing::warn!( - "deployment lookup failed for {}: {}", - deploy_id, - err - ), + 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; + 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; + let _ = + db::marketplace_billing::mark_captured(pg_pool, &handle.authorization_id) + .await; *handle = new_handle; } Err(err) => { @@ -1334,10 +1399,16 @@ mod tests { 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["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)); + assert_eq!( + parsed["networks"]["default_network"]["external"], + serde_yaml::Value::from(true) + ); } #[test] @@ -1354,8 +1425,14 @@ mod tests { }); 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")); + 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] @@ -1369,7 +1446,9 @@ mod tests { 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 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")) @@ -1471,7 +1550,9 @@ mod tests { 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`"); + .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()) @@ -1680,7 +1761,11 @@ mod tests { 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"); + 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 diff --git a/src/services/install_authorization_sweeper.rs b/src/services/install_authorization_sweeper.rs index b305f297..bc80bb5e 100644 --- a/src/services/install_authorization_sweeper.rs +++ b/src/services/install_authorization_sweeper.rs @@ -40,9 +40,7 @@ pub fn spawn( per_install_enabled: bool, ) { if !per_install_enabled { - tracing::info!( - "install_authorization_sweeper skipped: per_install billing disabled" - ); + tracing::info!("install_authorization_sweeper skipped: per_install billing disabled"); return; } tokio::spawn(async move { @@ -56,10 +54,7 @@ pub fn spawn( }); } -async fn tick_once( - pool: &PgPool, - user_service: &dyn UserServiceConnector, -) -> Result<(), String> { +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?; diff --git a/src/services/marketplace_access.rs b/src/services/marketplace_access.rs index 23c0d006..b269703f 100644 --- a/src/services/marketplace_access.rs +++ b/src/services/marketplace_access.rs @@ -9,12 +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 }, + NoPaymentMethod { + reason: String, + }, ValidationFailed(String), } @@ -352,10 +356,7 @@ mod tests { unimplemented!() } - async fn can_charge( - &self, - _user_token: &str, - ) -> Result { + async fn can_charge(&self, _user_token: &str) -> Result { self.captured_calls .lock() .unwrap() @@ -429,10 +430,13 @@ mod tests { 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.captured_calls + .lock() + .unwrap() + .push(CapturedCall::Void { + authorization_id: authorization_id.to_string(), + reason: reason.to_string(), + }); self.void_responses .lock() .unwrap() @@ -697,9 +701,12 @@ mod tests { 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; + 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(); @@ -709,7 +716,9 @@ mod tests { calls ); assert!( - !calls.iter().any(|c| matches!(c, CapturedCall::Authorize { .. })), + !calls + .iter() + .any(|c| matches!(c, CapturedCall::Authorize { .. })), "gate must NOT authorize — the install handler does that: {:?}", calls ); @@ -723,9 +732,12 @@ mod tests { ); let user_service: Arc = svc.clone(); - let result = - validate_marketplace_template_access(&user_service, &test_user(), &per_install_template()) - .await; + let result = validate_marketplace_template_access( + &user_service, + &test_user(), + &per_install_template(), + ) + .await; assert!(matches!( result, @@ -742,9 +754,12 @@ mod tests { 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; + let result = validate_marketplace_template_access( + &user_service, + &test_user(), + &per_install_template(), + ) + .await; assert!(matches!( result, 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 @@ + + + From 0c3122e85686e97919dffc1faf353dc87b17779f Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 23 Jul 2026 13:03:38 +0300 Subject: [PATCH 26/29] test fix, new test added synthesize_catalog_compose_builds_multi_service_stack_from_members --- src/routes/marketplace/install.rs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index f1307d88..9982e333 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -1360,6 +1360,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 }); @@ -1435,6 +1439,74 @@ mod tests { ); } + #[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 catalog_form_embeds_synthesized_compose_when_image_present() { let application = json!({ From fd0cb0563cabb07fe21838d0d24abea1f29f2def Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 23 Jul 2026 14:29:36 +0300 Subject: [PATCH 27/29] error msg context fix --- src/routes/marketplace/install.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index 9982e333..ed911607 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -494,9 +494,19 @@ fn catalog_application_project_form( "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. This usually means the catalog entry (or its member apps) is missing a dockerhub_image.", - slug + "Catalog application '{}' cannot be installed: no container image was available to synthesize a docker-compose file — {}.", + slug, reason ))); } }; From 3f79a21264b90f6705222201cb70683459868127 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 23 Jul 2026 20:00:56 +0300 Subject: [PATCH 28/29] read stack catalog payload --- src/routes/marketplace/install.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index ed911607..1d7180a7 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -1517,6 +1517,45 @@ mod tests { assert!(synthesize_catalog_compose(&application, "lamp").is_none()); } + #[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!({ From 58c569fd77292d33d942631208f9e9aef5f70dcd Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 23 Jul 2026 22:09:45 +0300 Subject: [PATCH 29/29] fix(marketplace): carry services[]/kind through catalog enrichment merge install_catalog_application enriches the search summary from /applications/catalog via merge_catalog_enrichment, but that merge only overlaid single-app fields (docker_image, default_port(s), default_env, default_config_files) and dropped the stack's services[] and kind. The service-less summary then reached synthesize_catalog_compose, which took the single-app path and failed loud with "missing a dockerhub_image" for real multi-service stacks (e.g. LAMP) -- even though fetch_app_catalog fetched the 13-member payload successfully (no fallback). Overlay services[] and kind too. Add a regression test that runs the real flow (production LAMP fixture -> merge -> synthesize) and asserts a multi-service compose, plus a full-payload deserialize/synthesize test. Co-Authored-By: Claude Opus 4.8 --- src/routes/marketplace/install.rs | 58 +++++ .../marketplace/lamp_catalog_fixture.json | 202 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/routes/marketplace/lamp_catalog_fixture.json diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index 1d7180a7..96d1f6bb 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -287,6 +287,13 @@ fn merge_catalog_enrichment(application: &mut Value, enriched: &Value) { "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()); @@ -1517,6 +1524,57 @@ mod tests { 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: 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