From 38d113a083813f741b341a3ab9a5f246f327ce9f Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 15 Oct 2026 18:52:10 +0300 Subject: [PATCH 1/2] Map project.members_server_access in the Project model The column came with migration 20261014120000 and is read and written by its own queries, but the three SELECT * project queries were prepared before it existed. Preparing them against a migrated database fails to compile (no field members_server_access), so no sqlx query cache could be regenerated. The field is kept out of API bodies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- ...7087754db39a2077a054026cb2bc0c010aba218506e76110f.json | 8 +++++++- ...5a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json | 8 +++++++- ...38a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json | 8 +++++++- src/models/project.rs | 6 ++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json b/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json index cc929cb1..53db6337 100644 --- a/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json +++ b/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json @@ -67,6 +67,11 @@ "ordinal": 12, "name": "deletion_warning_sent_at", "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "members_server_access", + "type_info": "Bool" } ], "parameters": { @@ -87,7 +92,8 @@ true, false, true, - true + true, + false ] }, "hash": "2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f" diff --git a/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json b/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json index 629c9e0a..54b1aa7d 100644 --- a/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json +++ b/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json @@ -67,6 +67,11 @@ "ordinal": 12, "name": "deletion_warning_sent_at", "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "members_server_access", + "type_info": "Bool" } ], "parameters": { @@ -88,7 +93,8 @@ true, false, true, - true + true, + false ] }, "hash": "3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97" diff --git a/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json b/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json index 13c83c93..fa59919d 100644 --- a/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json +++ b/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json @@ -67,6 +67,11 @@ "ordinal": 12, "name": "deletion_warning_sent_at", "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "members_server_access", + "type_info": "Bool" } ], "parameters": { @@ -87,7 +92,8 @@ true, false, true, - true + true, + false ] }, "hash": "5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc" diff --git a/src/models/project.rs b/src/models/project.rs index 349dd45c..477a3f66 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -163,6 +163,10 @@ pub struct Project { pub is_protected: bool, // prevent deletion when true pub deletion_scheduled_at: Option>, // auto-deletion date for stale projects pub deletion_warning_sent_at: Option>, // when the last warning was sent + /// Owner's choice: members get server access and deploy reports. + /// Read and set through db::project; kept out of API bodies. + #[serde(skip)] + pub members_server_access: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] @@ -189,6 +193,7 @@ impl Project { is_protected: false, deletion_scheduled_at: None, deletion_warning_sent_at: None, + members_server_access: false, } } @@ -236,6 +241,7 @@ impl Default for Project { is_protected: false, deletion_scheduled_at: None, deletion_warning_sent_at: None, + members_server_access: false, } } } From ff95ebd9dd76c73be7ca4a070d5e73b7aefa532a Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 15 Oct 2026 18:52:23 +0300 Subject: [PATCH 2/2] stacker.yml services: restart, entrypoint, profiles, aliases, deploy A service can now say how it runs: restart ("no" for a job that runs once), entrypoint, compose profiles, network aliases, deploy.replicas and deploy.resources.reservations.devices (GPUs). Together they let one stack carry CPU and GPU variants of a service under one name. Every declared setting reaches each compose stacker writes from the service, per shared-fixtures stack-service-runtime-fields: the CLI compose, the author's own compose (deploy.compose_file), the compose the server renders from the registered project, and the one the status-panel agent applies. Tracing those paths also found settings that were silently dropped: - registering a project sent no depends_on, cap_add, cap_drop or devices, and the server form had no healthcheck, so a deploy started outside the CLI lost them; - project_app rows never got depends_on or healthcheck, and had no place for capabilities or devices (new runtime JSONB column); - the agent's compose never rendered command or entrypoint: the template asked for them but the render context did not carry them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- ...277ac4a7f94d9f0f448b5549e30fc6cc66e19.json | 6 + ...ac289299f4d03539b9c746324cd183e265553.json | 6 + ...c2cf689a650fb90bccfb80689ef3c5b73a2b0.json | 6 + ...ff3ee63ae5548ce78f244099f9d61ca694312.json | 6 + ...0095300ae781f8a828f2af71d2eee4fd7588.json} | 13 +- ...4cc825de1909a2b592f6f1f53c290da5384b.json} | 13 +- docs/STACKER_YML_REFERENCE.md | 49 ++++- ...0261016100000_project_app_runtime.down.sql | 1 + .../20261016100000_project_app_runtime.up.sql | 4 + src/cli/compose_service_sync.rs | 52 ++++- src/cli/compose_targets.rs | 1 + src/cli/config_parser.rs | 141 ++++++++++++- src/cli/generator/compose.rs | 56 +++++- src/cli/service_catalog.rs | 21 ++ src/cli/service_import.rs | 1 + src/cli/stacker_client.rs | 28 ++- src/console/commands/cli/init.rs | 4 + src/console/commands/cli/pull.rs | 1 + src/console/commands/cli/secrets.rs | 1 + src/db/project_app.rs | 7 +- src/forms/project/app.rs | 95 ++++++++- src/models/project_app.rs | 8 + src/project_app/mapping.rs | 1 + src/project_app/sync.rs | 40 ++++ src/routes/project/discover.rs | 1 + src/services/config_renderer.rs | 190 +++++++++++++++++- ...stack-service-runtime-fields.contract.json | 148 ++++++++++++++ .../stack_service_runtime_fields_contract.rs | 171 ++++++++++++++++ 28 files changed, 1044 insertions(+), 27 deletions(-) rename .sqlx/{query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json => query-b1eb696ce1c3159169bf12fa75110095300ae781f8a828f2af71d2eee4fd7588.json} (89%) rename .sqlx/{query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json => query-fa0c97ff0bfdcf152814d34888024cc825de1909a2b592f6f1f53c290da5384b.json} (92%) create mode 100644 migrations/20261016100000_project_app_runtime.down.sql create mode 100644 migrations/20261016100000_project_app_runtime.up.sql create mode 100644 tests/contracts/stack-service-runtime-fields.contract.json create mode 100644 tests/stack_service_runtime_fields_contract.rs diff --git a/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json b/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json index b4c6b1b8..68fdccad 100644 --- a/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json +++ b/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -196,6 +201,7 @@ true, true, true, + true, true ] }, diff --git a/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json b/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json index e92dfd3b..4a0f4a48 100644 --- a/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json +++ b/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -195,6 +200,7 @@ true, true, true, + true, true ] }, diff --git a/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json b/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json index 28cc2e4b..9400d4f8 100644 --- a/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json +++ b/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -195,6 +200,7 @@ true, true, true, + true, true ] }, diff --git a/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json b/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json index 6465c2ce..dcf6d3e5 100644 --- a/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json +++ b/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -196,6 +201,7 @@ true, true, true, + true, true ] }, diff --git a/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json b/.sqlx/query-b1eb696ce1c3159169bf12fa75110095300ae781f8a828f2af71d2eee4fd7588.json similarity index 89% rename from .sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json rename to .sqlx/query-b1eb696ce1c3159169bf12fa75110095300ae781f8a828f2af71d2eee4fd7588.json index a7cf2d5e..461f25ed 100644 --- a/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json +++ b/.sqlx/query-b1eb696ce1c3159169bf12fa75110095300ae781f8a828f2af71d2eee4fd7588.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO project_app (\n project_id, code, name, image, environment, ports, volumes,\n domain, ssl_enabled, resources, restart_policy, command,\n entrypoint, networks, depends_on, healthcheck, labels,\n config_files, template_source, enabled, deploy_order, parent_app_code,\n deployment_id, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, NOW(), NOW())\n RETURNING *\n ", + "query": "\n INSERT INTO project_app (\n project_id, code, name, image, environment, ports, volumes,\n domain, ssl_enabled, resources, restart_policy, command,\n entrypoint, networks, depends_on, healthcheck, labels,\n config_files, template_source, enabled, deploy_order, parent_app_code,\n deployment_id, runtime, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW(), NOW())\n RETURNING *\n ", "describe": { "columns": [ { @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -183,7 +188,8 @@ "Bool", "Int4", "Varchar", - "Int4" + "Int4", + "Jsonb" ] }, "nullable": [ @@ -217,8 +223,9 @@ true, true, true, + true, true ] }, - "hash": "09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724" + "hash": "b1eb696ce1c3159169bf12fa75110095300ae781f8a828f2af71d2eee4fd7588" } diff --git a/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json b/.sqlx/query-fa0c97ff0bfdcf152814d34888024cc825de1909a2b592f6f1f53c290da5384b.json similarity index 92% rename from .sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json rename to .sqlx/query-fa0c97ff0bfdcf152814d34888024cc825de1909a2b592f6f1f53c290da5384b.json index 9f8aa0ed..dd8db6b8 100644 --- a/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json +++ b/.sqlx/query-fa0c97ff0bfdcf152814d34888024cc825de1909a2b592f6f1f53c290da5384b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE project_app SET\n code = $2,\n name = $3,\n image = $4,\n environment = $5,\n ports = $6,\n volumes = $7,\n domain = $8,\n ssl_enabled = $9,\n resources = $10,\n restart_policy = $11,\n command = $12,\n entrypoint = $13,\n networks = $14,\n depends_on = $15,\n healthcheck = $16,\n labels = $17,\n config_files = $18,\n template_source = $19,\n enabled = $20,\n deploy_order = $21,\n parent_app_code = $22,\n deployment_id = $23,\n config_version = COALESCE(config_version, 0) + 1,\n updated_at = NOW()\n WHERE id = $1\n RETURNING *\n ", + "query": "\n UPDATE project_app SET\n code = $2,\n name = $3,\n image = $4,\n environment = $5,\n ports = $6,\n volumes = $7,\n domain = $8,\n ssl_enabled = $9,\n resources = $10,\n restart_policy = $11,\n command = $12,\n entrypoint = $13,\n networks = $14,\n depends_on = $15,\n healthcheck = $16,\n labels = $17,\n config_files = $18,\n template_source = $19,\n enabled = $20,\n deploy_order = $21,\n parent_app_code = $22,\n deployment_id = $23,\n runtime = $24,\n config_version = COALESCE(config_version, 0) + 1,\n updated_at = NOW()\n WHERE id = $1\n RETURNING *\n ", "describe": { "columns": [ { @@ -157,6 +157,11 @@ "ordinal": 30, "name": "config_contract", "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "runtime", + "type_info": "Jsonb" } ], "parameters": { @@ -183,7 +188,8 @@ "Bool", "Int4", "Varchar", - "Int4" + "Int4", + "Jsonb" ] }, "nullable": [ @@ -217,8 +223,9 @@ true, true, true, + true, true ] }, - "hash": "fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708" + "hash": "fa0c97ff0bfdcf152814d34888024cc825de1909a2b592f6f1f53c290da5384b" } diff --git a/docs/STACKER_YML_REFERENCE.md b/docs/STACKER_YML_REFERENCE.md index e705a9ca..48b2e234 100644 --- a/docs/STACKER_YML_REFERENCE.md +++ b/docs/STACKER_YML_REFERENCE.md @@ -410,8 +410,16 @@ Additional containers deployed alongside your main application — databases, ca | `environment` | `map` | no | `{}` | Environment variables | | `volumes` | `string[]` | no | `[]` | Volume mounts (`"name:/path"` or `"./host:/container"`) | | `depends_on` | `string[]` | no | `[]` | Services this depends on (started first) | -| `command` | `string` | no | — | Override the container's default command / entrypoint | +| `command` | `string` | no | — | Override the container's default command | +| `entrypoint` | `string` | no | — | Override the image's entrypoint | | `healthcheck` | `object` | no | — | Docker health check for this service | +| `restart` | `string` | no | `unless-stopped` | `no`, `always`, `unless-stopped` or `on-failure` | +| `cap_add`, `cap_drop` | `string[]` | no | `[]` | Linux capabilities to add or drop | +| `devices` | `string[]` | no | `[]` | Host devices to map (`/dev/dri`) | +| `profiles` | `string[]` | no | `[]` | Start the service only when one of these compose profiles is active | +| `aliases` | `string[]` | no | `[]` | Other names the service answers to, on every network it joins | +| `deploy.replicas` | `integer` | no | `1` | `0` keeps the service defined but not started | +| `deploy.resources.reservations.devices` | `object[]` | no | `[]` | Devices to reserve, e.g. a GPU | #### `services[].command` @@ -500,6 +508,45 @@ services: > **Note:** Stacker detects port conflicts across services during validation. > If two services bind the same host port, you'll get a warning (`W001`). +#### One-shot jobs, profiles and GPUs + +`restart: "no"` keeps a job that runs once from being restarted after it +finishes. `profiles`, `aliases` and `deploy` let one stack carry a CPU and a GPU +variant of the same service: the CPU variant runs by default, the GPU variant +only under its profile, and both answer to the same name. + +```yaml +services: + - name: ollama-cpu + image: ollama/ollama:0.40.4 + aliases: [ollama] + deploy: + replicas: ${OLLAMA_CPU_REPLICAS} # 0 on a GPU machine + + - name: ollama-gpu + image: ollama/ollama:0.40.4 + profiles: [gpu-nvidia] + aliases: [ollama] + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + - name: model-pull + image: ollama/ollama:0.40.4 + restart: "no" + entrypoint: /bin/sh + command: -c "ollama pull nomic-embed-text" +``` + +On a GPU machine set `OLLAMA_CPU_REPLICAS=0` and `COMPOSE_PROFILES=gpu-nvidia`. +These settings reach every compose stacker writes from the service: the one the +CLI deploys, the author's own compose (`deploy.compose_file`), the one the +server renders from the project, and the one the status-panel agent applies. + --- ## `proxy` diff --git a/migrations/20261016100000_project_app_runtime.down.sql b/migrations/20261016100000_project_app_runtime.down.sql new file mode 100644 index 00000000..96bf01cb --- /dev/null +++ b/migrations/20261016100000_project_app_runtime.down.sql @@ -0,0 +1 @@ +ALTER TABLE project_app DROP COLUMN IF EXISTS runtime; diff --git a/migrations/20261016100000_project_app_runtime.up.sql b/migrations/20261016100000_project_app_runtime.up.sql new file mode 100644 index 00000000..bc057b72 --- /dev/null +++ b/migrations/20261016100000_project_app_runtime.up.sql @@ -0,0 +1,4 @@ +-- Compose settings of an app beyond its image and wiring: cap_add, cap_drop, +-- devices, profiles, aliases, deploy (replicas, device reservations). +-- See shared-fixtures/api-contracts/stack-service-runtime-fields.json. +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS runtime JSONB; diff --git a/src/cli/compose_service_sync.rs b/src/cli/compose_service_sync.rs index 17541f0d..3fc6321f 100644 --- a/src/cli/compose_service_sync.rs +++ b/src/cli/compose_service_sync.rs @@ -375,13 +375,56 @@ fn service_to_compose_value( serde_yaml::Value::String(platform.clone()), ); } - if !project_networks.is_empty() { - insert_string_sequence(&mut map, "networks", project_networks); + let runtime = &service.runtime; + if runtime.aliases.is_empty() { + if !project_networks.is_empty() { + insert_string_sequence(&mut map, "networks", project_networks); + } + } else { + // Aliases need the long form, on every network the service joins. + let default_network = ["default".to_string()]; + let networks = if project_networks.is_empty() { + &default_network[..] + } else { + project_networks + }; + let mut settings = serde_yaml::Mapping::new(); + insert_string_sequence(&mut settings, "aliases", &runtime.aliases); + let networks: serde_yaml::Mapping = networks + .iter() + .map(|n| { + ( + serde_yaml::Value::String(n.clone()), + serde_yaml::Value::Mapping(settings.clone()), + ) + }) + .collect(); + map.insert( + serde_yaml::Value::String("networks".to_string()), + serde_yaml::Value::Mapping(networks), + ); } map.insert( serde_yaml::Value::String("restart".to_string()), - serde_yaml::Value::String("unless-stopped".to_string()), + serde_yaml::Value::String( + runtime + .restart + .map_or("unless-stopped", |r| r.as_str()) + .to_string(), + ), ); + if let Some(entrypoint) = &runtime.entrypoint { + map.insert( + serde_yaml::Value::String("entrypoint".to_string()), + serde_yaml::Value::String(entrypoint.clone()), + ); + } + insert_string_sequence(&mut map, "profiles", &runtime.profiles); + if let Some(deploy) = &runtime.deploy { + if let Ok(value) = serde_yaml::to_value(deploy) { + map.insert(serde_yaml::Value::String("deploy".to_string()), value); + } + } serde_yaml::Value::Mapping(map) } @@ -732,6 +775,7 @@ mod tests { config: None, }, services: vec![ServiceDefinition { + runtime: Default::default(), name: service_name.to_string(), image: "myapp:latest".to_string(), ports: vec!["3000:3000".to_string()], @@ -787,6 +831,7 @@ mod tests { // proxy points to "api" but we are syncing "smtp" let mut config = npm_stacker_config(dir.path(), "api"); config.services = vec![ServiceDefinition { + runtime: Default::default(), name: "smtp".to_string(), image: "trydirect/smtp".to_string(), ports: vec![], @@ -852,6 +897,7 @@ volumes: ..Default::default() }, services: vec![ServiceDefinition { + runtime: Default::default(), name: "smtp".to_string(), image: "trydirect/smtp".to_string(), ports: vec!["1025:25".to_string()], diff --git a/src/cli/compose_targets.rs b/src/cli/compose_targets.rs index 6891490a..f19a1a87 100644 --- a/src/cli/compose_targets.rs +++ b/src/cli/compose_targets.rs @@ -175,6 +175,7 @@ fn collect_compose_services( }; services.push(ServiceDefinition { + runtime: Default::default(), name: service_name.to_string(), image, ports: mapping_sequence(definition, "ports") diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 2a3964da..0aeef512 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -293,7 +293,7 @@ fn default_health_retries_compose() -> u32 { /// /// This is distinct from `MonitoringConfig::healthcheck`, which is an /// app-level HTTP endpoint polling configuration. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct ComposeHealthcheck { pub test: String, #[serde(default = "default_health_interval")] @@ -340,6 +340,140 @@ pub struct ServiceDefinition { pub platform: Option, #[serde(default)] pub devices: Vec, + + /// restart, entrypoint, profiles, aliases, deploy. + #[serde(flatten)] + pub runtime: ServiceRuntime, +} + +/// How a service runs, beyond its image and wiring. Each maps to the compose +/// key of the same name (`aliases` to `networks..aliases`); left +/// unset, the compose keeps its defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ServiceRuntime { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub restart: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entrypoint: Option, + /// The service starts only when one of these compose profiles is active. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profiles: Vec, + /// Other names the service answers to on every network it joins. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deploy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RestartPolicy { + No, + Always, + UnlessStopped, + OnFailure, +} + +impl RestartPolicy { + pub fn as_str(&self) -> &'static str { + match self { + RestartPolicy::No => "no", + RestartPolicy::Always => "always", + RestartPolicy::UnlessStopped => "unless-stopped", + RestartPolicy::OnFailure => "on-failure", + } + } +} + +/// The subset of compose `deploy:` a single host honours. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceDeploy { + /// `0` keeps the service defined but not started. Also read from a + /// `${VAR}` reference, which arrives as a string. + #[serde( + default, + deserialize_with = "deserialize_optional_count", + skip_serializing_if = "Option::is_none" + )] + pub replicas: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceResources { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reservations: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceReservations { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub devices: Vec, +} + +/// A device the container reserves, e.g. an NVIDIA GPU: +/// `{driver: nvidia, count: 1, capabilities: [gpu]}`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeviceRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub device_ids: Vec, + pub capabilities: Vec, +} + +/// `count: 2` or `count: all`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceCount { + Count(u32), + All, +} + +impl Serialize for DeviceCount { + fn serialize(&self, serializer: S) -> Result { + match self { + DeviceCount::Count(n) => serializer.serialize_u32(*n), + DeviceCount::All => serializer.serialize_str("all"), + } + } +} + +impl<'de> Deserialize<'de> for DeviceCount { + fn deserialize>(deserializer: D) -> Result { + match serde_yaml::Value::deserialize(deserializer)? { + serde_yaml::Value::String(s) if s == "all" => Ok(DeviceCount::All), + other => count_from_value(&other) + .map(DeviceCount::Count) + .ok_or_else(|| serde::de::Error::custom("count must be a number or `all`")), + } + } +} + +fn count_from_value(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()), + serde_yaml::Value::String(s) => s.trim().parse().ok(), + _ => None, + } +} + +fn deserialize_optional_count<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match Option::::deserialize(deserializer)? { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(value) => count_from_value(&value) + .map(Some) + .ok_or_else(|| serde::de::Error::custom("replicas must be a whole number, 0 or more")), + } } fn deserialize_services<'de, D>(deserializer: D) -> Result, D::Error> @@ -4180,6 +4314,7 @@ app: #[test] fn e007_names_the_offending_service() { let svc = ServiceDefinition { + runtime: Default::default(), name: "db".to_string(), image: "postgres:16".to_string(), ports: vec!["5432:5432".to_string(), "99999:5432".to_string()], @@ -4691,6 +4826,7 @@ deploy: .app_type(AppType::Node) .app_path("./src") .add_service(ServiceDefinition { + runtime: Default::default(), name: "postgres".to_string(), image: "postgres:16".to_string(), ports: vec!["5432:5432".to_string()], @@ -4770,6 +4906,7 @@ deploy: let config = ConfigBuilder::new() .name("multi") .add_service(ServiceDefinition { + runtime: Default::default(), name: "pg".to_string(), image: "postgres:16".to_string(), ports: vec![], @@ -4785,6 +4922,7 @@ deploy: devices: Vec::new(), }) .add_service(ServiceDefinition { + runtime: Default::default(), name: "redis".to_string(), image: "redis:7".to_string(), ports: vec![], @@ -4800,6 +4938,7 @@ deploy: devices: Vec::new(), }) .add_service(ServiceDefinition { + runtime: Default::default(), name: "minio".to_string(), image: "minio/minio".to_string(), ports: vec![], diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 3bf8e6fd..daaa1256 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -5,7 +5,8 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, SslMode, StackerConfig, + AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, ServiceDeploy, + SslMode, StackerConfig, }; use crate::cli::error::CliError; @@ -41,6 +42,11 @@ pub struct ComposeService { pub shm_size: Option, /// Container `user:` (e.g. `"0:0"`). pub user: Option, + pub entrypoint: Option, + pub profiles: Vec, + /// Added as `aliases:` on every network in `networks`. + pub aliases: Vec, + pub deploy: Option, } impl Default for ComposeService { @@ -67,6 +73,10 @@ impl Default for ComposeService { devices: Vec::new(), shm_size: None, user: None, + entrypoint: None, + profiles: Vec::new(), + aliases: Vec::new(), + deploy: None, } } } @@ -88,8 +98,15 @@ impl From<&ServiceDefinition> for ComposeService { privileged: svc.privileged, platform: svc.platform.clone(), devices: svc.devices.clone(), + entrypoint: svc.runtime.entrypoint.clone(), + profiles: svc.runtime.profiles.clone(), + aliases: svc.runtime.aliases.clone(), + deploy: svc.runtime.deploy.clone(), ..Default::default() }; + if let Some(restart) = svc.runtime.restart { + compose_service.restart = restart.as_str().to_string(); + } crate::helpers::stacker_labels::insert_runtime_labels( &mut compose_service.labels, None::, @@ -712,6 +729,7 @@ impl ComposeDefinition { ("cap_add", &svc.cap_add), ("cap_drop", &svc.cap_drop), ("devices", &svc.devices), + ("profiles", &svc.profiles), ] { if !values.is_empty() { out.push_str(&format!(" {key}:\n")); @@ -741,6 +759,10 @@ impl ComposeDefinition { out.push_str(&format!(" command: {}\n", yaml_quote(cmd))); } + if let Some(ref entrypoint) = svc.entrypoint { + out.push_str(&format!(" entrypoint: {}\n", yaml_quote(entrypoint))); + } + if !svc.volumes.is_empty() { out.push_str(" volumes:\n"); for v in &svc.volumes { @@ -766,12 +788,32 @@ impl ComposeDefinition { out.push_str(&format!(" retries: {}\n", hc.retries)); } - out.push_str(&format!(" restart: {}\n", svc.restart)); + // `no` unquoted is a boolean to YAML 1.1 readers. + if svc.restart == "no" { + out.push_str(" restart: \"no\"\n"); + } else { + out.push_str(&format!(" restart: {}\n", svc.restart)); + } + + if let Some(ref deploy) = svc.deploy { + out.push_str(" deploy:\n"); + let body = serde_yaml::to_string(deploy).unwrap_or_default(); + for line in body.lines().filter(|l| !l.is_empty() && *l != "---") { + out.push_str(&format!(" {}\n", line)); + } + } if !svc.networks.is_empty() { out.push_str(" networks:\n"); for n in &svc.networks { - out.push_str(&format!(" - {}\n", n)); + if svc.aliases.is_empty() { + out.push_str(&format!(" - {}\n", n)); + } else { + out.push_str(&format!(" {}:\n aliases:\n", n)); + for alias in &svc.aliases { + out.push_str(&format!(" - {}\n", yaml_quote(alias))); + } + } } } @@ -1503,6 +1545,7 @@ services: config.app_present = false; // simulate no declared app: section config.app.image = Some("myorg/app:1.0".to_string()); config.services = vec![ServiceDefinition { + runtime: Default::default(), name: "db".to_string(), image: "postgres:16".to_string(), ports: vec![], @@ -1551,6 +1594,7 @@ services: #[test] fn test_compose_includes_additional_services() { let svc = ServiceDefinition { + runtime: Default::default(), name: "postgres".into(), image: "postgres:16".into(), ports: vec!["5432:5432".into()], @@ -1883,6 +1927,7 @@ services: #[test] fn test_compose_render_contains_volumes_section() { let svc = ServiceDefinition { + runtime: Default::default(), name: "redis".into(), image: "redis:7".into(), ports: Vec::new(), @@ -1989,6 +2034,7 @@ services: #[test] fn test_service_definition_to_compose_service() { let svc_def = ServiceDefinition { + runtime: Default::default(), name: "mysql".into(), image: "mysql:8".into(), ports: vec!["3306:3306".into()], @@ -2020,6 +2066,7 @@ services: #[test] fn service_definition_adds_project_scope_labels() { let svc_def = ServiceDefinition { + runtime: Default::default(), name: "smtp".into(), image: "trydirect/smtp:latest".into(), ports: Vec::new(), @@ -2282,6 +2329,7 @@ services: #[test] fn npm_proxy_injects_default_network_into_proxied_service() { let svc = ServiceDefinition { + runtime: Default::default(), name: "api".into(), image: "myapp:latest".into(), ports: vec!["8080:8080".into()], @@ -2336,6 +2384,7 @@ services: #[test] fn npm_proxy_does_not_inject_into_unproxied_service() { let smtp = ServiceDefinition { + runtime: Default::default(), name: "smtp".into(), image: "trydirect/smtp".into(), ports: vec![], @@ -2423,6 +2472,7 @@ services: // proxied service must also join `default_network` for the managed // proxy to reach it. (Previously only NginxProxyManager did this.) let svc = ServiceDefinition { + runtime: Default::default(), name: "web".into(), image: "nginx:latest".into(), ports: vec![], diff --git a/src/cli/service_catalog.rs b/src/cli/service_catalog.rs index 20253137..cf7357cf 100644 --- a/src/cli/service_catalog.rs +++ b/src/cli/service_catalog.rs @@ -139,6 +139,7 @@ impl ServiceCatalog { if let Some(services) = stack_def.get("services") { if let Some(first_svc) = services.as_array().and_then(|arr| arr.first()) { let service = ServiceDefinition { + runtime: Default::default(), name: first_svc["name"].as_str().unwrap_or(slug).to_string(), image: first_svc["image"].as_str().unwrap_or("").to_string(), ports: first_svc["ports"] @@ -282,6 +283,7 @@ fn build_hardcoded_catalog() -> Vec { category: "database".into(), description: "Reliable open-source relational database".into(), service: ServiceDefinition { + runtime: Default::default(), name: "postgres".into(), image: "postgres:16-alpine".into(), ports: vec!["5432:5432".into()], @@ -308,6 +310,7 @@ fn build_hardcoded_catalog() -> Vec { category: "database".into(), description: "Popular open-source relational database".into(), service: ServiceDefinition { + runtime: Default::default(), name: "mysql".into(), image: "mysql:8.0".into(), ports: vec!["3306:3306".into()], @@ -338,6 +341,7 @@ fn build_hardcoded_catalog() -> Vec { category: "database".into(), description: "Document-oriented NoSQL database".into(), service: ServiceDefinition { + runtime: Default::default(), name: "mongodb".into(), image: "mongo:7".into(), ports: vec!["27017:27017".into()], @@ -369,6 +373,7 @@ fn build_hardcoded_catalog() -> Vec { category: "cache".into(), description: "In-memory data store for caching and message broker".into(), service: ServiceDefinition { + runtime: Default::default(), name: "redis".into(), image: "redis:7-alpine".into(), ports: vec!["6379:6379".into()], @@ -391,6 +396,7 @@ fn build_hardcoded_catalog() -> Vec { category: "cache".into(), description: "High-performance distributed memory caching system".into(), service: ServiceDefinition { + runtime: Default::default(), name: "memcached".into(), image: "memcached:1.6-alpine".into(), ports: vec!["11211:11211".into()], @@ -414,6 +420,7 @@ fn build_hardcoded_catalog() -> Vec { category: "queue".into(), description: "Advanced message broker with management UI".into(), service: ServiceDefinition { + runtime: Default::default(), name: "rabbitmq".into(), image: "rabbitmq:3-management-alpine".into(), ports: vec!["5672:5672".into(), "15672:15672".into()], @@ -443,6 +450,7 @@ fn build_hardcoded_catalog() -> Vec { category: "proxy".into(), description: "Cloud-native reverse proxy with automatic SSL".into(), service: ServiceDefinition { + runtime: Default::default(), name: "traefik".into(), image: "traefik:v3.0".into(), ports: vec!["80:80".into(), "443:443".into()], @@ -468,6 +476,7 @@ fn build_hardcoded_catalog() -> Vec { category: "proxy".into(), description: "High-performance web server and reverse proxy".into(), service: ServiceDefinition { + runtime: Default::default(), name: "nginx".into(), image: "nginx:1.25-alpine".into(), ports: vec!["80:80".into(), "443:443".into()], @@ -490,6 +499,7 @@ fn build_hardcoded_catalog() -> Vec { category: "proxy".into(), description: "Web UI for managing Nginx reverse proxy with SSL".into(), service: ServiceDefinition { + runtime: Default::default(), name: "nginx_proxy_manager".into(), image: "jc21/nginx-proxy-manager:latest".into(), ports: vec!["80:80".into(), "443:443".into(), "81:81".into()], @@ -516,6 +526,7 @@ fn build_hardcoded_catalog() -> Vec { category: "web".into(), description: "Popular CMS and blogging platform".into(), service: ServiceDefinition { + runtime: Default::default(), name: "wordpress".into(), image: "wordpress:latest".into(), ports: vec!["8080:80".into()], @@ -547,6 +558,7 @@ fn build_hardcoded_catalog() -> Vec { category: "search".into(), description: "Distributed search and analytics engine".into(), service: ServiceDefinition { + runtime: Default::default(), name: "elasticsearch".into(), image: "elasticsearch:8.12.0".into(), ports: vec!["9200:9200".into()], @@ -573,6 +585,7 @@ fn build_hardcoded_catalog() -> Vec { category: "search".into(), description: "Visualization dashboard for Elasticsearch".into(), service: ServiceDefinition { + runtime: Default::default(), name: "kibana".into(), image: "kibana:8.12.0".into(), ports: vec!["5601:5601".into()], @@ -599,6 +612,7 @@ fn build_hardcoded_catalog() -> Vec { category: "database".into(), description: "Vector similarity search engine for AI applications".into(), service: ServiceDefinition { + runtime: Default::default(), name: "qdrant".into(), image: "qdrant/qdrant:latest".into(), ports: vec!["6333:6333".into(), "6334:6334".into()], @@ -622,6 +636,7 @@ fn build_hardcoded_catalog() -> Vec { category: "monitoring".into(), description: "Agent for collecting and reporting metrics".into(), service: ServiceDefinition { + runtime: Default::default(), name: "telegraf".into(), image: "telegraf:1.30-alpine".into(), ports: vec![], @@ -645,6 +660,7 @@ fn build_hardcoded_catalog() -> Vec { category: "devtool".into(), description: "Web-based MySQL database management (development)".into(), service: ServiceDefinition { + runtime: Default::default(), name: "phpmyadmin".into(), image: "phpmyadmin:latest".into(), ports: vec!["8081:80".into()], @@ -671,6 +687,7 @@ fn build_hardcoded_catalog() -> Vec { description: "Attachable SMTP companion app for local delivery and relay testing" .into(), service: ServiceDefinition { + runtime: Default::default(), name: "smtp".into(), image: "trydirect/smtp".into(), ports: vec!["1025:25".into()], @@ -699,6 +716,7 @@ fn build_hardcoded_catalog() -> Vec { category: "devtool".into(), description: "Email testing tool — catches all outgoing mail (development)".into(), service: ServiceDefinition { + runtime: Default::default(), name: "mailhog".into(), image: "mailhog/mailhog:latest".into(), ports: vec!["1025:1025".into(), "8025:8025".into()], @@ -722,6 +740,7 @@ fn build_hardcoded_catalog() -> Vec { category: "storage".into(), description: "S3-compatible object storage".into(), service: ServiceDefinition { + runtime: Default::default(), name: "minio".into(), image: "minio/minio:latest".into(), ports: vec!["9000:9000".into(), "9001:9001".into()], @@ -751,6 +770,7 @@ fn build_hardcoded_catalog() -> Vec { category: "devtool".into(), description: "Docker container management web UI".into(), service: ServiceDefinition { + runtime: Default::default(), name: "portainer".into(), image: "portainer/portainer-ce:latest".into(), ports: vec!["9443:9443".into()], @@ -777,6 +797,7 @@ fn build_hardcoded_catalog() -> Vec { category: "ai".into(), description: "Personal AI assistant with multi-channel gateway".into(), service: ServiceDefinition { + runtime: Default::default(), name: "openclaw".into(), image: "ghcr.io/openclaw/openclaw:latest".into(), ports: vec!["18789:18789".into()], diff --git a/src/cli/service_import.rs b/src/cli/service_import.rs index d6188a05..d05ac2d5 100644 --- a/src/cli/service_import.rs +++ b/src/cli/service_import.rs @@ -154,6 +154,7 @@ pub fn import_plan_from_compose_str( has_mail_server_shape |= looks_like_mail_server(source_name, &image, &ports); services.push(ServiceDefinition { + runtime: Default::default(), name: destination_name.clone(), image: image.clone(), ports: ports.clone(), diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 4eb304d7..e3f4cfad 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -3900,7 +3900,7 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde "code": svc.name.to_lowercase(), "type": "web", "dockerhub_name": dockerhub_name, - "restart": "always", + "restart": svc.runtime.restart.map_or("always", |r| r.as_str()), "custom": true, "shared_ports": shared_ports, "volumes": volumes, @@ -3909,6 +3909,26 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde }); let obj = app.as_object_mut().unwrap(); + // Everything else the service declares reaches the server's compose too + // (shared-fixtures/api-contracts/stack-service-runtime-fields.json). + for (key, values) in [ + ("depends_on", &svc.depends_on), + ("cap_add", &svc.cap_add), + ("cap_drop", &svc.cap_drop), + ("devices", &svc.devices), + ("profiles", &svc.runtime.profiles), + ("aliases", &svc.runtime.aliases), + ] { + if !values.is_empty() { + obj.insert(key.to_string(), serde_json::json!(values)); + } + } + if let Some(ref entrypoint) = svc.runtime.entrypoint { + obj.insert("entrypoint".to_string(), serde_json::json!(entrypoint)); + } + if let Some(ref deploy) = svc.runtime.deploy { + obj.insert("deploy".to_string(), serde_json::json!(deploy)); + } if let Some(user) = dockerhub_user { obj.insert("dockerhub_user".to_string(), serde_json::json!(user)); } @@ -5378,6 +5398,7 @@ mod tests { #[test] fn test_build_project_body_skips_declared_npm_service_when_proxy_is_managed() { let npm_service = ServiceDefinition { + runtime: Default::default(), name: "nginx_proxy_manager".to_string(), image: "jc21/nginx-proxy-manager:latest".to_string(), ports: vec![ @@ -5397,6 +5418,7 @@ mod tests { devices: Vec::new(), }; let redis_service = ServiceDefinition { + runtime: Default::default(), name: "redis".to_string(), image: "redis:7-alpine".to_string(), ports: vec![], @@ -5436,6 +5458,7 @@ mod tests { #[test] fn test_build_project_body_skips_platform_managed_services_even_without_proxy() { let npm_service = ServiceDefinition { + runtime: Default::default(), name: "nginx_proxy_manager".to_string(), image: "jc21/nginx-proxy-manager:latest".to_string(), ports: vec![ @@ -5455,6 +5478,7 @@ mod tests { devices: Vec::new(), }; let statuspanel_service = ServiceDefinition { + runtime: Default::default(), name: "statuspanel".to_string(), image: "trydirect/status:latest".to_string(), ports: vec!["5000:5000".to_string()], @@ -5470,6 +5494,7 @@ mod tests { devices: Vec::new(), }; let smtp_service = ServiceDefinition { + runtime: Default::default(), name: "smtp".to_string(), image: "trydirect/smtp:latest".to_string(), ports: vec!["127.0.0.1:1025:25".to_string()], @@ -5530,6 +5555,7 @@ mod tests { #[test] fn test_scn_001_stacker_yml_service_serializes_as_service_target() { let upload_service = ServiceDefinition { + runtime: Default::default(), name: "upload".to_string(), image: "ghcr.io/example/upload:1.0".to_string(), ports: vec!["8081:8080".to_string()], diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 3120714f..def07c74 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1118,6 +1118,7 @@ fn compose_service_to_definition( }; Some(ServiceDefinition { + runtime: Default::default(), name: service.name.clone(), image, ports: service @@ -1609,6 +1610,7 @@ fn convert_compose_to_stacker(ai_output: &str, repo_name: &str) -> Option ServiceDefinition { ServiceDefinition { + runtime: Default::default(), name: name.to_string(), image: "optimum/syncopia-upload:latest".to_string(), ports: vec!["8000:8000".to_string()], diff --git a/src/db/project_app.rs b/src/db/project_app.rs index b612db2d..c9d73dc7 100644 --- a/src/db/project_app.rs +++ b/src/db/project_app.rs @@ -174,9 +174,9 @@ pub async fn insert(pool: &PgPool, app: &models::ProjectApp) -> Result Result Result Result, pub entrypoint: Option, + /// Compose settings a stacker.yml service declares + /// (shared-fixtures/api-contracts/stack-service-runtime-fields.json). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_on: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub healthcheck: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cap_add: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cap_drop: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub devices: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profiles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deploy: Option, pub volumes: Option>, #[serde(flatten)] pub environment: forms::project::Environment, @@ -114,7 +132,20 @@ impl App { let networks = dctypes::Networks::try_from(&self.network).unwrap_or_default(); let networks = replace_id_with_name(networks, all_networks); - service.networks = dctypes::Networks::Simple(networks); + service.networks = if self.aliases.is_empty() { + dctypes::Networks::Simple(networks) + } else { + let settings = dctypes::AdvancedNetworkSettings { + aliases: self.aliases.clone(), + ..Default::default() + }; + dctypes::Networks::Advanced(dctypes::AdvancedNetworks( + networks + .into_iter() + .map(|name| (name, dctypes::MapOrEmpty::Map(settings.clone()))) + .collect(), + )) + }; let ports: Vec = match &self.shared_ports { Some(ports) => { @@ -169,11 +200,73 @@ impl App { } service.volumes = volumes; service.environment = dctypes::Environment::KvPair(envs); + if !self.depends_on.is_empty() { + service.depends_on = dctypes::DependsOnOptions::Simple(self.depends_on.clone()); + } + service.healthcheck = self.healthcheck.as_ref().map(compose_healthcheck); + service.cap_add = self.cap_add.clone(); + service.cap_drop = self.cap_drop.clone(); + service.devices = self.devices.clone(); + service.profiles = self.profiles.clone(); + service.deploy = self.deploy.as_ref().map(compose_deploy); Ok(service) } } +fn compose_healthcheck(hc: &crate::cli::config_parser::ComposeHealthcheck) -> dctypes::Healthcheck { + let test = match crate::cli::compose_service_sync::healthcheck_test_value(&hc.test) { + serde_yaml::Value::Sequence(parts) => dctypes::HealthcheckTest::Multiple( + parts + .iter() + .filter_map(|p| p.as_str().map(str::to_string)) + .collect(), + ), + _ => dctypes::HealthcheckTest::Single(hc.test.clone()), + }; + dctypes::Healthcheck { + test: Some(test), + interval: Some(hc.interval.clone()), + timeout: Some(hc.timeout.clone()), + retries: i64::from(hc.retries), + ..Default::default() + } +} + +fn compose_deploy(deploy: &crate::cli::config_parser::ServiceDeploy) -> dctypes::Deploy { + use crate::cli::config_parser::DeviceCount; + let devices = deploy + .resources + .as_ref() + .and_then(|r| r.reservations.as_ref()) + .map(|r| { + r.devices + .iter() + .map(|d| dctypes::Device { + driver: d.driver.clone(), + count: d.count.map(|c| match c { + DeviceCount::All => dctypes::DeviceCount::All, + DeviceCount::Count(n) => dctypes::DeviceCount::Count(u64::from(n)), + }), + device_ids: (!d.device_ids.is_empty()).then(|| d.device_ids.clone()), + capabilities: Some(d.capabilities.clone()), + ..Default::default() + }) + .collect::>() + }); + dctypes::Deploy { + replicas: deploy.replicas.map(i64::from), + resources: devices.map(|devices| dctypes::Resources { + reservations: Some(dctypes::Limits { + devices: Some(devices), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + } +} + impl AsRef for App { fn as_ref(&self) -> &forms::project::DockerImage { &self.docker_image diff --git a/src/models/project_app.rs b/src/models/project_app.rs index 4c37f51d..2d60c066 100644 --- a/src/models/project_app.rs +++ b/src/models/project_app.rs @@ -108,6 +108,12 @@ pub struct ProjectApp { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(default)] pub config_contract: Option, + /// Compose settings beyond image and wiring, as declared in stacker.yml: + /// `{cap_add, cap_drop, devices, profiles, aliases, deploy}`. See + /// shared-fixtures/api-contracts/stack-service-runtime-fields.json. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub runtime: Option, } impl ProjectApp { @@ -146,6 +152,7 @@ impl ProjectApp { parent_app_code: None, deployment_id: None, config_contract: None, + runtime: None, } } @@ -218,6 +225,7 @@ impl Default for ProjectApp { parent_app_code: None, deployment_id: None, config_contract: None, + runtime: None, } } } diff --git a/src/project_app/mapping.rs b/src/project_app/mapping.rs index 99010659..55ea8ab9 100644 --- a/src/project_app/mapping.rs +++ b/src/project_app/mapping.rs @@ -370,5 +370,6 @@ pub(crate) fn merge_project_app(existing: ProjectApp, incoming: ProjectApp) -> P .config_contract .clone() .or_else(|| existing.config_contract.clone()), + runtime: incoming.runtime.or(existing.runtime), } } diff --git a/src/project_app/sync.rs b/src/project_app/sync.rs index f148393f..30431b9c 100644 --- a/src/project_app/sync.rs +++ b/src/project_app/sync.rs @@ -54,6 +54,43 @@ fn app_networks_json( } } +/// The healthcheck in the shape the config renderer reads: `test` as a list. +fn healthcheck_json(hc: &crate::cli::config_parser::ComposeHealthcheck) -> Value { + let test = match crate::cli::compose_service_sync::healthcheck_test_value(&hc.test) { + serde_yaml::Value::Sequence(parts) => parts + .iter() + .filter_map(|p| p.as_str().map(str::to_string)) + .collect::>(), + _ => vec!["CMD-SHELL".to_string(), hc.test.clone()], + }; + json!({ + "test": test, + "interval": hc.interval, + "timeout": hc.timeout, + "retries": hc.retries, + }) +} + +/// `project_app.runtime`: the settings with no column of their own. +fn app_runtime_json(app: &ProjectFormApp) -> Option { + let mut runtime = serde_json::Map::new(); + for (key, values) in [ + ("cap_add", &app.cap_add), + ("cap_drop", &app.cap_drop), + ("devices", &app.devices), + ("profiles", &app.profiles), + ("aliases", &app.aliases), + ] { + if !values.is_empty() { + runtime.insert(key.to_string(), json!(values)); + } + } + if let Some(deploy) = &app.deploy { + runtime.insert("deploy".to_string(), json!(deploy)); + } + (!runtime.is_empty()).then_some(Value::Object(runtime)) +} + fn build_project_app( project_id: i32, deploy_order: i32, @@ -84,6 +121,9 @@ fn build_project_app( project_app.command = non_empty_string(app.command.as_deref()); project_app.entrypoint = non_empty_string(app.entrypoint.as_deref()); project_app.networks = app_networks_json(app, all_networks); + project_app.depends_on = (!app.depends_on.is_empty()).then(|| json!(app.depends_on)); + project_app.healthcheck = app.healthcheck.as_ref().map(healthcheck_json); + project_app.runtime = app_runtime_json(app); project_app.enabled = Some(true); project_app.deploy_order = Some(deploy_order); project_app.config_contract = app.config_contract.clone(); diff --git a/src/routes/project/discover.rs b/src/routes/project/discover.rs index 6727ff52..2ea17256 100644 --- a/src/routes/project/discover.rs +++ b/src/routes/project/discover.rs @@ -424,6 +424,7 @@ pub async fn import_containers( parent_app_code: None, deployment_id: None, config_contract: None, + runtime: None, }; match db::project_app::insert(pg_pool.get_ref(), &app).await { diff --git a/src/services/config_renderer.rs b/src/services/config_renderer.rs index fffa7007..829a6951 100644 --- a/src/services/config_renderer.rs +++ b/src/services/config_renderer.rs @@ -434,6 +434,68 @@ pub struct AppRenderContext { pub healthcheck: Option, /// Container runtime override (e.g., "kata" for hardware isolation) pub runtime: Option, + /// command, entrypoint and the settings in `project_app.runtime`. + #[serde(flatten)] + pub run: AppRunSettings, +} + +/// What the app runs and how, beyond image and wiring. See +/// shared-fixtures/api-contracts/stack-service-runtime-fields.json. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppRunSettings { + pub command: Option, + pub entrypoint: Option, + #[serde(default)] + pub cap_add: Vec, + #[serde(default)] + pub cap_drop: Vec, + #[serde(default)] + pub devices: Vec, + #[serde(default)] + pub profiles: Vec, + #[serde(default)] + pub aliases: Vec, + pub replicas: Option, + /// `deploy.resources.reservations.devices` entries, as stored. + #[serde(default)] + pub gpu_devices: Vec, +} + +impl AppRunSettings { + fn from_app(app: &ProjectApp) -> Self { + let runtime = app.runtime.as_ref(); + let strings = |key: &str| -> Vec { + runtime + .and_then(|r| r.get(key)) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let deploy = runtime.and_then(|r| r.get("deploy")); + Self { + command: app.command.clone().filter(|c| !c.is_empty()), + entrypoint: app.entrypoint.clone().filter(|e| !e.is_empty()), + cap_add: strings("cap_add"), + cap_drop: strings("cap_drop"), + devices: strings("devices"), + profiles: strings("profiles"), + aliases: strings("aliases"), + replicas: deploy + .and_then(|d| d.get("replicas")) + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()), + gpu_devices: deploy + .and_then(|d| d.pointer("/resources/reservations/devices")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -642,6 +704,7 @@ impl ConfigRenderer { labels, healthcheck, runtime: None, + run: AppRunSettings::from_app(app), }) } @@ -1182,12 +1245,24 @@ services: runtime: {{ app.runtime }} {% endif %} {% if app.command %} - command: {{ app.command }} + command: {{ app.command | yaml_quote }} {% endif %} {% if app.entrypoint %} - entrypoint: {{ app.entrypoint }} + entrypoint: {{ app.entrypoint | yaml_quote }} +{% endif %} + restart: {{ app.restart_policy | yaml_quote }} +{% if app.profiles | length > 0 %} + profiles: {{ app.profiles | json_encode() }} +{% endif %} +{% if app.cap_add | length > 0 %} + cap_add: {{ app.cap_add | json_encode() }} +{% endif %} +{% if app.cap_drop | length > 0 %} + cap_drop: {{ app.cap_drop | json_encode() }} +{% endif %} +{% if app.devices | length > 0 %} + devices: {{ app.devices | json_encode() }} {% endif %} - restart: {{ app.restart_policy }} {% if app.environment | length > 0 %} environment: {% for key, value in app.environment %}{% set entry = key ~ "=" ~ value %} @@ -1207,13 +1282,20 @@ services: {% endfor %} {% endif %} -{% if app.networks | length > 0 %} networks: +{% if app.networks | length > 0 %} {% for network in app.networks %} +{% if app.aliases | length > 0 %} + {{ network }}: + aliases: {{ app.aliases | json_encode() }} +{% else %} - {{ network }} +{% endif %} {% endfor %} +{% elif app.aliases | length > 0 %} + {{ default_network }}: + aliases: {{ app.aliases | json_encode() }} {% else %} - networks: - {{ default_network }} {% endif %} {% if app.depends_on | length > 0 %} @@ -1244,9 +1326,16 @@ services: start_period: {{ app.healthcheck.start_period }} {% endif %} {% endif %} -{% if app.resources.memory_limit or app.resources.cpu_limit %} +{% set has_limits = app.resources.memory_limit or app.resources.cpu_limit %} +{% set has_gpu = app.gpu_devices | length > 0 %} +{% if has_limits or has_gpu or app.replicas is number %} deploy: +{% if app.replicas is number %} + replicas: {{ app.replicas }} +{% endif %} +{% if has_limits or has_gpu %} resources: +{% if has_limits %} limits: {% if app.resources.memory_limit %} memory: {{ app.resources.memory_limit }} @@ -1254,14 +1343,20 @@ services: {% if app.resources.cpu_limit %} cpus: '{{ app.resources.cpu_limit }}' {% endif %} -{% if app.resources.memory_reservation or app.resources.cpu_reservation %} +{% endif %} +{% set limit_reservations = has_limits and (app.resources.memory_reservation or app.resources.cpu_reservation) %} +{% if limit_reservations or has_gpu %} reservations: -{% if app.resources.memory_reservation %} +{% if limit_reservations and app.resources.memory_reservation %} memory: {{ app.resources.memory_reservation }} {% endif %} -{% if app.resources.cpu_reservation %} +{% if limit_reservations and app.resources.cpu_reservation %} cpus: '{{ app.resources.cpu_reservation }}' {% endif %} +{% if has_gpu %} + devices: {{ app.gpu_devices | json_encode() }} +{% endif %} +{% endif %} {% endif %} {% endif %} @@ -1842,9 +1937,78 @@ mod tests { assert_eq!(ctx.image, "nginx:latest"); } + /// Every key of `expected` is in `actual` with the same value; + /// `networks_aliases`: every network the service joins carries them. + fn assert_compose_subset(case: &str, actual: &serde_json::Value, expected: &serde_json::Value) { + for (key, want) in expected.as_object().unwrap() { + if key == "networks_aliases" { + let networks = actual["networks"].as_object().unwrap_or_else(|| { + panic!( + "{case}: networks with aliases expected, got {}", + actual["networks"] + ) + }); + assert!(!networks.is_empty(), "{case}: no network"); + for (name, net) in networks { + assert_eq!(&net["aliases"], want, "{case}: aliases on {name}"); + } + } else if want.is_object() { + assert_compose_subset(case, &actual[key], want); + } else { + assert_eq!(&actual[key], want, "{case}: `{key}`\nservice: {actual}"); + } + } + } + + /// shared-fixtures/api-contracts/stack-service-runtime-fields.json, path + /// agent_render: stacker.yml -> registered project app -> project_app row + /// -> the compose the status-panel agent applies. + #[test] + fn agent_render_carries_every_declared_setting() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/contracts/stack-service-runtime-fields.contract.json" + )) + .unwrap(); + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "runtime-fields".to_string(), + ..Project::default() + }; + for case in contract["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let service = &case["stacker_yml"]; + let yml = json!({ + "name": "runtime-fields", + "app": { "type": "custom", "image": "nginx:1.27" }, + "services": [service], + }); + let config = crate::cli::config_parser::StackerConfig::from_str( + &serde_yaml::to_string(&yml).unwrap(), + ) + .unwrap(); + let body = crate::cli::stacker_client::build_project_body(&config); + let form: crate::forms::project::ProjectForm = + serde_json::from_value(json!({ "custom": body["custom"] })).unwrap(); + let apps = crate::project_app::sync::project_level_apps_from_form(1, &form); + let code = service["name"].as_str().unwrap(); + let app = apps + .iter() + .find(|a| a.code == code) + .unwrap_or_else(|| panic!("{name}: no project_app row")); + let ctx = renderer + .project_app_to_context(app, HashMap::new()) + .unwrap(); + let compose = renderer.render_compose(&[ctx], &project).unwrap(); + let doc: serde_json::Value = serde_yaml::from_str(&compose) + .unwrap_or_else(|e| panic!("{name}: rendered compose is YAML: {e}\n{compose}")); + assert_compose_subset(name, &doc["services"][code], &case["compose"]); + } + } + #[test] fn render_compose_includes_kata_runtime() { let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), @@ -1874,6 +2038,7 @@ mod tests { #[test] fn render_compose_runtime_none_serializes_null() { let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), @@ -1916,6 +2081,7 @@ mod tests { ..Project::default() }; let app_ctx = AppRenderContext { + run: Default::default(), code: "app".to_string(), name: "app".to_string(), image: "miniflux/miniflux:latest".to_string(), @@ -1980,6 +2146,7 @@ mod tests { ..Project::default() }; let app_ctx = AppRenderContext { + run: Default::default(), code: "miniflux".to_string(), name: "miniflux".to_string(), image: "miniflux/miniflux:latest".to_string(), @@ -2021,6 +2188,7 @@ mod tests { ..Project::default() }; let postgres_ctx = AppRenderContext { + run: Default::default(), code: "postgres".to_string(), name: "postgres".to_string(), image: "postgres:16-alpine".to_string(), @@ -2115,6 +2283,7 @@ mod tests { } let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), @@ -2167,6 +2336,7 @@ mod tests { ..Project::default() }; let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), @@ -2200,6 +2370,7 @@ mod tests { ..Project::default() }; let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), @@ -2239,6 +2410,7 @@ mod tests { "web", ); let ctx = AppRenderContext { + run: Default::default(), code: "web".to_string(), name: "web".to_string(), image: "nginx:latest".to_string(), diff --git a/tests/contracts/stack-service-runtime-fields.contract.json b/tests/contracts/stack-service-runtime-fields.contract.json new file mode 100644 index 00000000..fb8f8d58 --- /dev/null +++ b/tests/contracts/stack-service-runtime-fields.contract.json @@ -0,0 +1,148 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "stack-service-runtime-fields", + "description": "Runtime settings of a service declared under `services:` in stacker.yml, and the Docker Compose they must produce on every path that renders a compose file from it.", + "_owner": "trydirect/config", + "_consumers": [ + "stacker" + ], + "_notes": [ + "A stacker.yml service reaches a compose file along four paths. cli_compose: the CLI generates the compose it deploys (local, and uploaded to the server for cloud/server targets). compose_sync: with deploy.compose_file set, the CLI writes the service into the author's own compose file. project_app: the CLI registers the service as a project app (POST /project), from which the server renders the compose for deploys started outside the CLI. agent_render: the project_app row the server keeps per app, from which the config renderer writes the compose the status-panel agent applies.", + "A field declared in stacker.yml must reach the compose on every path, or be refused before it is lost. A silently dropped field deploys a different stack from the one the author wrote: a one-shot job without restart: \"no\" restarts for ever, a GPU service without its device reservation runs on the CPU.", + "restart applies only when set. An unset restart keeps each path's existing default: unless-stopped from the CLI generator, always for a registered project app.", + "aliases are added on every network the service joins, so other services reach it by the alias whichever network they share with it. A service that names no network joins `default`, and carries its aliases there.", + "deploy.replicas: 0 keeps a service defined but not started. Together with profiles it lets one stack carry CPU and GPU variants of the same service: the CPU variant runs by default, the GPU variants only under their profile.", + "Each case gives a service as written in stacker.yml (as JSON) and the compose service it must produce. The compose subset is what a consumer must contain; other keys (labels, networks without aliases, environment) are left to each path." + ], + "version": "v1", + "paths": [ + "cli_compose", + "compose_sync", + "project_app", + "agent_render" + ], + "fields": { + "restart": { + "status": "new", + "type": "string", + "enum": ["no", "always", "unless-stopped", "on-failure"] + }, + "entrypoint": { + "status": "new", + "type": "string" + }, + "profiles": { + "status": "new", + "type": "array", + "items": {"type": "string"} + }, + "aliases": { + "status": "new", + "type": "array", + "items": {"type": "string"}, + "compose": "networks..aliases" + }, + "deploy.replicas": { + "status": "new", + "type": "integer", + "minimum": 0 + }, + "deploy.resources.reservations.devices": { + "status": "new", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "driver": {"type": "string"}, + "count": {"oneOf": [{"type": "integer", "minimum": 1}, {"const": "all"}]}, + "device_ids": {"type": "array", "items": {"type": "string"}}, + "capabilities": {"type": "array", "items": {"type": "string"}, "minItems": 1} + }, + "required": ["capabilities"] + } + }, + "depends_on": {"status": "existing", "type": "array", "items": {"type": "string"}}, + "healthcheck": {"status": "existing", "type": "object"}, + "cap_add": {"status": "existing", "type": "array", "items": {"type": "string"}}, + "cap_drop": {"status": "existing", "type": "array", "items": {"type": "string"}}, + "devices": {"status": "existing", "type": "array", "items": {"type": "string"}} + }, + "cases": [ + { + "name": "one-shot job that must not restart", + "stacker_yml": { + "name": "model-pull", + "image": "ollama/ollama:0.40.4", + "restart": "no", + "entrypoint": "/bin/sh", + "command": "-c 'ollama pull nomic-embed-text'", + "depends_on": ["ollama-cpu"] + }, + "compose": { + "restart": "no", + "entrypoint": "/bin/sh", + "depends_on": ["ollama-cpu"] + } + }, + { + "name": "CPU variant: runs by default, answers as ollama", + "stacker_yml": { + "name": "ollama-cpu", + "image": "ollama/ollama:0.40.4", + "aliases": ["ollama"], + "deploy": {"replicas": 1} + }, + "compose": { + "networks_aliases": ["ollama"], + "deploy": {"replicas": 1} + } + }, + { + "name": "NVIDIA variant behind a profile", + "stacker_yml": { + "name": "ollama-gpu", + "image": "ollama/ollama:0.40.4", + "profiles": ["gpu-nvidia"], + "aliases": ["ollama"], + "deploy": { + "resources": { + "reservations": { + "devices": [{"driver": "nvidia", "count": 1, "capabilities": ["gpu"]}] + } + } + } + }, + "compose": { + "profiles": ["gpu-nvidia"], + "networks_aliases": ["ollama"], + "deploy": { + "resources": { + "reservations": { + "devices": [{"driver": "nvidia", "count": 1, "capabilities": ["gpu"]}] + } + } + } + } + }, + { + "name": "AMD variant: devices, capabilities and healthcheck carried", + "stacker_yml": { + "name": "ollama-gpu-amd", + "image": "ollama/ollama:0.40.4-rocm", + "profiles": ["gpu-amd"], + "devices": ["/dev/kfd", "/dev/dri"], + "cap_add": ["SYS_PTRACE"], + "cap_drop": ["NET_RAW"], + "healthcheck": {"test": "CMD ollama list", "interval": "30s", "timeout": "5s", "retries": 3} + }, + "compose": { + "profiles": ["gpu-amd"], + "devices": ["/dev/kfd", "/dev/dri"], + "cap_add": ["SYS_PTRACE"], + "cap_drop": ["NET_RAW"], + "healthcheck": {"interval": "30s", "timeout": "5s", "retries": 3} + } + } + ] +} diff --git a/tests/stack_service_runtime_fields_contract.rs b/tests/stack_service_runtime_fields_contract.rs new file mode 100644 index 00000000..6d987483 --- /dev/null +++ b/tests/stack_service_runtime_fields_contract.rs @@ -0,0 +1,171 @@ +//! Runtime settings of a `services:` entry in stacker.yml reach the compose +//! file on every path that renders one. +//! +//! The contract is mirrored from +//! ../config/shared-fixtures/api-contracts/stack-service-runtime-fields.json +//! to tests/contracts/stack-service-runtime-fields.contract.json. +//! +//! cli_compose: the compose the CLI generates and deploys. +//! compose_sync: the service written into the author's own compose file +//! (`deploy.compose_file`). +//! project_app: the project app the CLI registers (POST /project), from which +//! the server renders the compose for deploys started outside the CLI. + +use serde_json::{json, Value}; +use stacker::cli::config_parser::StackerConfig; +use stacker::cli::generator::compose::ComposeDefinition; +use stacker::cli::stacker_client::build_project_body; +use stacker::forms::project::ProjectForm; + +fn contract() -> Value { + serde_json::from_str(include_str!( + "contracts/stack-service-runtime-fields.contract.json" + )) + .expect("contract JSON") +} + +fn config_with(service: &Value) -> StackerConfig { + let doc = json!({ + "name": "runtime-fields", + "app": { "type": "custom", "image": "nginx:1.27" }, + "services": [service], + }); + StackerConfig::from_str(&serde_yaml::to_string(&doc).unwrap()) + .unwrap_or_else(|e| panic!("stacker.yml with {service} must parse: {e}")) +} + +/// Every key of `expected` is present in `actual` with the same value; +/// `networks_aliases` means: every network the service joins carries them. +fn assert_contains(path: &str, case: &str, actual: &Value, expected: &Value) { + for (key, want) in expected.as_object().unwrap() { + if key == "networks_aliases" { + let networks = actual["networks"].as_object().unwrap_or_else(|| { + panic!( + "{path}/{case}: networks with aliases expected, got {}", + actual["networks"] + ) + }); + assert!(!networks.is_empty(), "{path}/{case}: no network"); + for (name, net) in networks { + assert_eq!( + &net["aliases"], want, + "{path}/{case}: aliases on network {name}" + ); + } + continue; + } + let got = &actual[key]; + if want.is_object() { + assert_contains(path, case, got, want); + } else { + assert_eq!(got, want, "{path}/{case}: `{key}`\nservice: {actual}"); + } + } +} + +fn cases() -> Vec<(String, Value, Value)> { + contract()["cases"] + .as_array() + .unwrap() + .iter() + .map(|c| { + ( + c["name"].as_str().unwrap().to_string(), + c["stacker_yml"].clone(), + c["compose"].clone(), + ) + }) + .collect() +} + +#[test] +fn cli_compose_carries_every_declared_setting() { + for (case, service, expected) in cases() { + let config = config_with(&service); + let compose = ComposeDefinition::try_from(&config) + .expect("compose") + .render(); + let doc: Value = serde_yaml::from_str(&compose).expect("rendered compose is YAML"); + let name = service["name"].as_str().unwrap(); + assert_contains("cli_compose", &case, &doc["services"][name], &expected); + } +} + +#[test] +fn compose_sync_carries_every_declared_setting() { + for (case, service, expected) in cases() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("compose.yml"), "services: {}\n").unwrap(); + let mut config = config_with(&service); + config.deploy.compose_file = Some("compose.yml".into()); + let name = service["name"].as_str().unwrap().to_string(); + stacker::cli::compose_service_sync::sync_configured_compose_services( + dir.path(), + &config, + &[name.clone()], + ) + .unwrap_or_else(|e| panic!("compose_sync/{case}: {e}")); + let written = std::fs::read_to_string(dir.path().join("compose.yml")).unwrap(); + let doc: Value = serde_yaml::from_str(&written).unwrap(); + assert_contains("compose_sync", &case, &doc["services"][&name], &expected); + } +} + +#[test] +fn project_app_carries_every_declared_setting() { + for (case, service, expected) in cases() { + let body = build_project_body(&config_with(&service)); + let form: ProjectForm = serde_json::from_value(json!({ "custom": body["custom"] })) + .unwrap_or_else(|e| { + panic!("project_app/{case}: the server must accept the registered app: {e}") + }); + let services = form.custom.services().expect("server renders services"); + let name = service["name"].as_str().unwrap(); + let rendered = services + .get(name) + .cloned() + .flatten() + .unwrap_or_else(|| panic!("project_app/{case}: service `{name}` missing")); + let actual = serde_json::to_value( + serde_yaml::from_str::(&serde_yaml::to_string(&rendered).unwrap()).unwrap(), + ) + .unwrap(); + assert_contains("project_app", &case, &actual, &expected); + } +} + +#[test] +fn an_unset_restart_keeps_each_paths_default() { + let service = json!({ "name": "plain", "image": "redis:7.4.11-alpine" }); + let config = config_with(&service); + let compose: Value = + serde_yaml::from_str(&ComposeDefinition::try_from(&config).unwrap().render()).unwrap(); + assert_eq!(compose["services"]["plain"]["restart"], "unless-stopped"); + + let body = build_project_body(&config); + let apps = body["custom"]["service"] + .as_array() + .cloned() + .unwrap_or_default(); + let app = apps + .iter() + .chain(body["custom"]["web"].as_array().into_iter().flatten()) + .find(|a| a["code"] == "plain") + .expect("registered app"); + assert_eq!(app["restart"], "always"); +} + +#[test] +fn an_unknown_restart_value_is_refused() { + let doc = json!({ + "name": "runtime-fields", + "app": { "type": "custom", "image": "nginx:1.27" }, + "services": [{ "name": "x", "image": "redis:7", "restart": "sometimes" }], + }); + let err = StackerConfig::from_str(&serde_yaml::to_string(&doc).unwrap()) + .expect_err("restart: sometimes must be refused"); + assert!( + format!("{err}").contains("unless-stopped"), + "error names the allowed values: {err}" + ); +}