Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions internal/compose/diagnostics/ignored_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ pub(super) fn ignored_service_fields(file: &ComposeFile, out: &mut Vec<String>)
attach/detach logic for `up` log streaming"
));
}
if def.credential_spec.is_some() {
out.push(format!(
"service '{service}': credential_spec is a Windows managed-service-account \
control with no rootless Podman equivalent and is not honored"
));
}
if def.isolation.is_some() {
out.push(format!(
"service '{service}': isolation has no rootless Podman equivalent and is \
not honored"
));
}
if def.provider.is_some() {
out.push(format!(
"service '{service}': provider delegates the service lifecycle to an \
external plugin that podup does not invoke; the service is not honored"
));
}
if def.use_api_socket.is_some() {
out.push(format!(
"service '{service}': use_api_socket has no podup equivalent and is not \
honored"
));
}
for entry in def.env_file.to_entries() {
if let EnvFileEntry::Config {
format: Some(fmt), ..
Expand All @@ -38,6 +62,17 @@ pub(super) fn ignored_service_fields(file: &ComposeFile, out: &mut Vec<String>)
}
}

/// Top-level `models:` (Compose v2.38) — podup runs no model runner, so any
/// declared model is parsed for fidelity but not honored.
pub(super) fn ignored_models(file: &ComposeFile, out: &mut Vec<String>) {
for name in file.models.keys() {
out.push(format!(
"model '{name}': podup runs no model runner, so the models element is not \
honored"
));
}
}

/// Long-form port fields podup parses but does not forward to Podman.
pub(super) fn ignored_port_fields(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
Expand Down
111 changes: 110 additions & 1 deletion internal/compose/diagnostics/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! Parse-time diagnostics.
//!
//! podup accepts the full compose-spec surface and, per the spec's
Expand All @@ -12,7 +12,7 @@

mod ignored_fields;
use ignored_fields::{
ignored_build_fields, ignored_network_fields, ignored_port_fields,
ignored_build_fields, ignored_models, ignored_network_fields, ignored_port_fields,
ignored_secret_config_drivers, ignored_service_fields, ignored_service_network_fields,
ignored_volume_mount_fields,
};
Expand All @@ -31,6 +31,7 @@
ignored_network_fields(file, &mut out);
ignored_service_network_fields(file, &mut out);
ignored_secret_config_drivers(file, &mut out);
ignored_models(file, &mut out);
out
}

Expand Down Expand Up @@ -76,6 +77,23 @@
);
}
}
if let Some(cred) = &def.credential_spec {
push_unknown(
&format!("service '{service}' credential_spec"),
&cred.unknown,
out,
);
}
if let Some(provider) = &def.provider {
push_unknown(
&format!("service '{service}' provider"),
&provider.unknown,
out,
);
}
}
for (name, model) in &file.models {
push_unknown(&format!("model '{name}'"), &model.unknown, out);
}
for (name, cfg) in &file.networks {
if let Some(c) = cfg {
Expand Down Expand Up @@ -384,4 +402,95 @@
"unexpected: {msgs:?}"
);
}

#[test]
fn warns_on_credential_spec_not_honored() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n credential_spec:\n config: my-spec\n",
);
assert!(
msgs.iter()
.any(|m| m.contains("credential_spec") && m.contains("not honored")),
"got: {msgs:?}"
);
// The recognized key must not also produce a generic "unknown key" warning.
assert!(
!msgs
.iter()
.any(|m| m.contains("unknown key 'credential_spec'")),
"got: {msgs:?}"
);
}

#[test]
fn warns_on_service_isolation_not_honored() {
let msgs = diagnostics_for("services:\n web:\n image: nginx\n isolation: hyperv\n");
assert!(
msgs.iter()
.any(|m| m.contains("isolation") && m.contains("not honored")),
"got: {msgs:?}"
);
assert!(!msgs.iter().any(|m| m.contains("unknown key 'isolation'")));
}

#[test]
fn warns_on_provider_not_honored() {
let msgs = diagnostics_for("services:\n db:\n provider:\n type: awesomecloud\n");
assert!(
msgs.iter()
.any(|m| m.contains("provider") && m.contains("not honored")),
"got: {msgs:?}"
);
assert!(!msgs.iter().any(|m| m.contains("unknown key 'provider'")));
}

#[test]
fn warns_on_use_api_socket_not_honored() {
let msgs =
diagnostics_for("services:\n web:\n image: nginx\n use_api_socket: true\n");
assert!(
msgs.iter()
.any(|m| m.contains("use_api_socket") && m.contains("not honored")),
"got: {msgs:?}"
);
assert!(!msgs
.iter()
.any(|m| m.contains("unknown key 'use_api_socket'")));
}

#[test]
fn warns_on_top_level_models_not_honored() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\nmodels:\n llm:\n model: ai/model\n",
);
assert!(
msgs.iter()
.any(|m| m.contains("model 'llm'") && m.contains("not honored")),
"got: {msgs:?}"
);
// `models` is now a recognized top-level element, not an unknown key.
assert!(
!msgs
.iter()
.any(|m| m.contains("unknown top-level key 'models'")),
"got: {msgs:?}"
);
}

#[test]
fn warns_on_typo_inside_provider_and_models() {
let msgs = diagnostics_for(
"services:\n db:\n provider:\n type: cloud\n optoins: {}\nmodels:\n llm:\n modle: ai/model\n",
);
assert!(
msgs.iter()
.any(|m| m.contains("provider") && m.contains("optoins")),
"got: {msgs:?}"
);
assert!(
msgs.iter()
.any(|m| m.contains("model 'llm'") && m.contains("modle")),
"got: {msgs:?}"
);
}
}
4 changes: 4 additions & 0 deletions internal/compose/extends/merge.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! Field-by-field merge of a base service into an overriding service.
//!
//! Scalar fields take the override when present, else the base. Collection
Expand Down Expand Up @@ -194,6 +194,10 @@
deploy: override_svc.deploy.or(base.deploy),
develop: override_svc.develop.or(base.develop),
gpus: override_svc.gpus.or(base.gpus),
credential_spec: override_svc.credential_spec.or(base.credential_spec),
isolation: override_svc.isolation.or(base.isolation),
provider: override_svc.provider.or(base.provider),
use_api_socket: override_svc.use_api_socket.or(base.use_api_socket),
unknown: {
// Keep unknown keys from both sides so a typo in either the base or
// the overriding service is still surfaced; the override wins on
Expand Down
27 changes: 27 additions & 0 deletions internal/compose/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ pub struct ConfigConfig {
pub template_driver: Option<String>,
}

/// Top-level `models:` entry (Compose v2.38) — declares an AI model the
/// project depends on. podup runs no model runner, so the diagnostics pass
/// reports any declared model as not honored; the fields are parsed for
/// fidelity and round-tripped by `config`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct ModelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_size: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_flags: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub model_variables: HashMap<String, String>,
/// Forward-compatible keys captured so a typo is surfaced rather than dropped.
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub unknown: IndexMap<String, serde_yaml::Value>,
}

/// Root deserialization target for a `docker-compose.yml` file.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
Expand All @@ -96,6 +118,11 @@ pub struct ComposeFile {
pub secrets: IndexMap<String, SecretConfig>,
#[serde(default)]
pub configs: IndexMap<String, ConfigConfig>,
/// Top-level `models:` element (Compose v2.38). Parsed for fidelity; podup
/// runs no model runner, so the diagnostics pass reports declared models as
/// not honored.
#[serde(default)]
pub models: IndexMap<String, ModelConfig>,
/// Top-level `x-*` extension fields — preserved and round-tripped via `config` subcommand.
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub extensions: IndexMap<String, serde_yaml::Value>,
Expand Down
55 changes: 55 additions & 0 deletions internal/compose/types/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,30 @@ pub struct Service {
#[serde(skip_serializing_if = "Option::is_none")]
pub gpus: Option<GpuSpec>,

/// `credential_spec:` — Windows managed-service-account credential source
/// (`config`/`file`/`registry`). Parsed for fidelity; podup has no rootless
/// Podman equivalent, so the diagnostics pass reports it as not honored.
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_spec: Option<CredentialSpec>,

/// `isolation:` — container isolation technology (e.g. `default`, `process`,
/// `hyperv`). Distinct from `build.isolation`. Parsed for fidelity; podup has
/// no rootless Podman equivalent, so it is reported as not honored.
#[serde(skip_serializing_if = "Option::is_none")]
pub isolation: Option<String>,

/// `provider:` (Compose v2.36) — delegates the service lifecycle to an
/// external provider plugin. Parsed for fidelity; podup invokes no provider
/// plugins, so it is reported as not honored.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<ProviderConfig>,

/// `use_api_socket:` (Compose v2.37.1) — bind-mount the Docker API socket and
/// forward credentials into the container. Parsed for fidelity; podup has no
/// equivalent, so it is reported as not honored.
#[serde(skip_serializing_if = "Option::is_none")]
pub use_api_socket: Option<bool>,

/// Keys present in the YAML that don't map to a known service field. Captured
/// (rather than silently dropped) so the parser can warn about likely typos
/// while still tolerating compose-spec `x-*` extensions and forward-compatible
Expand All @@ -224,3 +248,34 @@ pub struct Service {
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub unknown: IndexMap<String, serde_yaml::Value>,
}

/// `credential_spec:` — source of a Windows managed-service-account credential
/// spec. Exactly one of `config`/`file`/`registry` is expected per the Compose
/// Spec; podup parses all three for fidelity but honors none.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct CredentialSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
/// Forward-compatible keys captured so a typo is surfaced rather than dropped.
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub unknown: IndexMap<String, serde_yaml::Value>,
}

/// `provider:` (Compose v2.36) — names an external provider plugin (`type`) and
/// its free-form `options`. Parsed for fidelity; podup invokes no providers.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct ProviderConfig {
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub options: IndexMap<String, serde_yaml::Value>,
/// Forward-compatible keys captured so a typo is surfaced rather than dropped.
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub unknown: IndexMap<String, serde_yaml::Value>,
}
2 changes: 2 additions & 0 deletions tests/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ mod extends;
mod fields;
#[path = "parse/include.rs"]
mod include;
#[path = "parse/new_keys.rs"]
mod new_keys;
#[path = "parse/order.rs"]
mod order;
Loading
Loading