Skip to content
Merged

Dev #199

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9ea7117
'stacker service add' password generator, stacker pipe, container pro…
robotizeit Jul 17, 2026
d93305e
hint command fix
robotizeit Jul 17, 2026
28dc922
install from marketplace compose fix, cloud params in cli command
robotizeit Jul 17, 2026
9469bc1
feat: add --name flag to pipe create for non-interactive mode
robotizeit Jul 19, 2026
eaa009a
ci: add agent-executor Docker build job
robotizeit Jul 19, 2026
0a4f210
spin up agent-executor on pipe activate
robotizeit Jul 19, 2026
2ef820d
source_url: Option<String> added, optional external source URL (agent…
robotizeit Jul 20, 2026
98b788b
cargo fmt --all
robotizeit Jul 20, 2026
55e5c4d
feat: add --source-url to pipe trigger and activate CLI commands
robotizeit Jul 20, 2026
bdbbc62
target_headers implemented
robotizeit Jul 20, 2026
7e1d9c8
feat: add target_headers support for authenticated pipe delivery
robotizeit Jul 20, 2026
6c912c7
Update Rust crate derive_builder to 0.20.0
renovate[bot] Jul 21, 2026
0bb25f0
Update Rust crate dialoguer to 0.12
renovate[bot] Jul 21, 2026
d6af03c
Update Rust crate docker-compose-types to 0.24.0
renovate[bot] Jul 21, 2026
009ad5b
Update Rust crate hmac to 0.13.0
renovate[bot] Jul 21, 2026
bd41c2a
website update, with v0.3.1 features and fixes
robotizeit Jul 21, 2026
eda36eb
stack_definition fix when install from marketplace
robotizeit Jul 21, 2026
e4afea8
return eror on unapproved package
robotizeit Jul 21, 2026
d067499
Added config_has_buildable_app: skip the synthesized app service when…
robotizeit Jul 21, 2026
661335e
Merge remote-tracking branch 'origin/renovate/hmac-0.x' into dev
robotizeit Jul 22, 2026
07e58d5
Merge remote-tracking branch 'origin/renovate/docker-compose-types-0.…
robotizeit Jul 22, 2026
6dbc3de
Merge remote-tracking branch 'origin/renovate/dialoguer-0.x' into dev
robotizeit Jul 22, 2026
65873b8
libs upgrade
robotizeit Jul 22, 2026
cbf11c3
per-install payment model, website updates
robotizeit Jul 22, 2026
6666d8c
stack author/creator (vendor) column for stack templates command
robotizeit Jul 22, 2026
c5b67c5
phantom-app fix
robotizeit Jul 22, 2026
e174a53
Implemented the Stacker-side webhook federation (items 1–4) + tests.
robotizeit Jul 22, 2026
d50d7a1
tests fixed. cargo fmt --all
robotizeit Jul 22, 2026
e6d317e
legacy stack services
robotizeit Jul 23, 2026
0c3122e
test fix, new test added synthesize_catalog_compose_builds_multi_serv…
robotizeit Jul 23, 2026
fd0cb05
error msg context fix
robotizeit Jul 23, 2026
3f79a21
read stack catalog payload
robotizeit Jul 23, 2026
58c569f
fix(marketplace): carry services[]/kind through catalog enrichment merge
robotizeit Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS marketplace_install_authorization;
31 changes: 31 additions & 0 deletions migrations/20260718130000_marketplace_install_authorization.up.sql
Original file line number Diff line number Diff line change
@@ -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);
23 changes: 22 additions & 1 deletion src/bin/stacker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,21 @@ enum StackerCommands {
/// Output in JSON format
#[arg(long)]
json: bool,
/// Name of saved cloud credential to reuse (overrides auto-detected default)
#[arg(long, value_name = "KEY_NAME")]
key: Option<String>,
/// ID of saved cloud credential to reuse (from `stacker list clouds`)
#[arg(long, value_name = "CLOUD_ID")]
key_id: Option<i32>,
/// Cloud region for the new server (e.g. nbg1, fsn1)
#[arg(long, value_name = "REGION")]
region: Option<String>,
/// Cloud server size/type (e.g. cx23, cpx21)
#[arg(long, value_name = "SIZE")]
size: Option<String>,
/// Cloud provider (hetzner|digitalocean|aws|linode|vultr). Overrides provider from saved credential.
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
},
/// Show container logs
Logs {
Expand Down Expand Up @@ -2706,9 +2721,15 @@ fn get_command(
json,
domain,
set_values,
key,
key_id,
region,
size,
provider,
} => Box::new(
stacker::console::commands::cli::marketplace::MarketplaceInstallCommand::new(
template, name, file, force, json, domain, set_values,
template, name, file, force, json, domain, set_values, key, key_id, region, size,
provider,
),
),
StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/ai_scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions src/cli/compose_service_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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,
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions src/cli/config_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub fn build_config_bundle(
compose_path: &Path,
env_file: Option<&Path>,
reference_base: &Path,
attach_agent_network: bool,
) -> Result<ConfigBundleArtifacts, CliError> {
validate_environment_name(environment)?;

Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -667,6 +678,7 @@ services:
&compose_dir.join("compose.yml"),
Some(&compose_dir.join(".env")),
&compose_dir,
false,
)
.expect("bundle should be built");

Expand Down Expand Up @@ -740,6 +752,7 @@ services:
&dir.path().join("docker-compose.yml"),
None,
dir.path(),
false,
)
.expect("bundle should be built");

Expand Down Expand Up @@ -782,6 +795,7 @@ services:
&stacker_dir.join("docker-compose.yml"),
None,
dir.path(),
false,
)
.expect("bundle should be built for generated compose");

Expand Down Expand Up @@ -845,6 +859,7 @@ services:
&compose_dir.join("compose.yml"),
None,
&compose_dir,
false,
)
.expect("directory mounts should not block the bundle");

Expand All @@ -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();
Expand All @@ -887,6 +943,7 @@ services:
&compose_dir.join("compose.yml"),
None,
&compose_dir,
false,
)
.unwrap_err();

Expand Down
29 changes: 28 additions & 1 deletion src/cli/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1550,6 +1576,7 @@ impl ConfigBuilder {
env: self.env,
config_contract: ConfigContract::default(),
origin: ConfigOrigin::UserAuthored,
app_present,
})
}
}
Expand Down Expand Up @@ -2237,7 +2264,7 @@ deploy:
provider: CloudProvider::Hetzner,
orchestrator: CloudOrchestrator::Remote,
region: Some("nbg1".to_string()),
size: Some("cpx11".to_string()),
size: Some("cx23".to_string()),
install_image: None,
remote_payload_file: None,
ssh_key: None,
Expand Down
Loading
Loading