diff --git a/CHANGELOG.md b/CHANGELOG.md index dbeac51..162a28e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- `LoadProject` and `GenerateAsync` expose value-free diagnostics for unknown, unsupported, and partially applied Compose fields; strict validation rejects them before Docker Engine operations. - `UpAsync`, `CreateAsync`, and `StartAsync` include transitive service dependencies, reject cycles and missing services, and wait for effective Docker health checks or running state before starting dependents; an optional per-dependency timeout bounds readiness waits. - `GenerateAsync` returns a normalized YAML snapshot of selected loaded services alongside the project summary, with service selection and an output project-name override. - `TopAsync` returns real process listings for running project service containers through the Docker Engine process-list API. @@ -22,6 +23,17 @@ This project follows [Semantic Versioning](https://semver.org/). ### Fixed +- Resource diagnostics identify the overlay option that introduced unapplied behavior, without attributing errors to default values or extension metadata. +- Validation diagnoses unresolved environment passthrough entries in list and mapping syntax, and strict mode rejects them before Docker access. +- Validation reports implicit service-network fallback to custom project networks and rejects it in strict mode before Docker access. +- Service-network option diagnostics escape network names and identify the contributing overlay file correctly. +- Explicit network `driver: bridge` declarations no longer produce false partial-support diagnostics or fail strict validation; unapplied resource options remain diagnosed. +- Validation diagnoses Windows drive-letter volume sources and treats scalar `healthcheck.test: NONE` as an enabled shell check when evaluating dependency conditions. +- Scalar health checks are normalized before Docker creation, diagnostics identify unapplied build options and network attachments, and strict validation covers direct Engine operations. +- Validation diagnoses multiple build platforms and duplicate container-port mappings, preserves escaped resource-name provenance, and recognizes the service platform as a build fallback. +- Validation diagnoses long-form service volumes and unretained build options, honors dependency conditions that match readiness behavior, ignores default zero resource values, and attributes merged-map diagnostics to overlay values. +- Validation ignores build extension metadata and false boolean defaults, and diagnoses unsupported long-form `env_file` entries. +- Validation ignores nested extension metadata and diagnoses `service_started` when an image may provide an inherited health check. - Service-level `on-failure:N` restart policies preserve the Docker retry limit and reject malformed suffixes with contextual validation errors. - `WaitAsync` collects container exit codes without concurrent dictionary writes and chooses its aggregate code deterministically by ordinal container ID. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d31a24..122f187 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ Docker integration tests run only when a reachable Docker daemon is available. - Preserve cancellation-token behavior and avoid introducing Docker CLI process calls into the managed SDK. - Update XML comments, `README.md` / `README.zh-CN.md`, relevant `docs/*` guides, and `CHANGELOG.md` `[Unreleased]` in the same PR when public behavior or compatibility claims change. Follow the matrix below to identify the affected documents. - Include a minimal Compose file or Docker reproduction when fixing compatibility behavior. +- For every newly parsed Compose field, either apply its semantics in the Docker Engine mapping or add a stable diagnostic and focused test for the unsupported or partial behavior. Update the field matrix with links to that diagnostic and test. ## Documentation and CHANGELOG diff --git a/README.md b/README.md index 2a32fe0..430a05e 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Invalid input throws `ComposeValidationException`. Its message and structured pr `ComposeProjectContext.Profiles` selects services consistently for project loading and service-scoped lifecycle, inspection, image, log, event, and watch operations. Services without `profiles` are always selected; a profiled service is selected when any of its profiles is active. An operation that explicitly names a service can select it even when its profile is not active. An empty selection is a no-op rather than a request to operate on every project container. -The loader can retain fields that the engine does not yet apply. Consult the [Compose field support matrix](docs/compose-field-matrix.md) before relying on a Compose property at runtime. +The loader can retain fields that the engine does not fully apply. `LoadProject` and `GenerateAsync` expose stable, value-free `Diagnostics` with the source file, property path, service name when relevant, and an `unsupported-field` or `partially-supported-field` code. The default `ComposeValidationMode.Report` continues with documented behavior; `ComposeValidationMode.Strict` rejects these fields before any Docker Engine operation. Unknown non-empty project, service, and build fields are also reported. Long-form service volumes, Windows drive-letter volume sources, long-form `env_file` entries, unapplied dependency conditions, and ignored build options produce diagnostics; default zero resource values and false boolean defaults remain quiet. Bare names in environment lists and null-valued environment mapping entries produce diagnostics because process-environment passthrough is not implemented; explicit empty-string assignments remain quiet. Scalar `healthcheck.test: NONE` is an enabled shell check unless explicitly disabled; list-form `[NONE]` disables the check. Dependency-condition diagnostics use this distinction. Empty/default values and `x-*` extensions do not produce diagnostics. An explicit top-level network `driver: bridge` is quiet because it matches the driver used by the engine; other meaningful network options remain diagnosed. Services without an explicit network or network mode also produce a partial-support diagnostic when the engine would attach them to a custom project network instead of the implicit default network. Diagnostics never include field values, which may contain credentials or secrets. Consult the [Compose field support matrix](docs/compose-field-matrix.md) for field behavior and diagnostic coverage before relying on a Compose property at runtime. ## What the engine does today diff --git a/README.zh-CN.md b/README.zh-CN.md index 04c342c..df99a49 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,8 +105,8 @@ builder.Services.AddComposeSharp(); - `PublishAsync` 会把每个被选中服务的镜像打成 `repository:serviceName`,并使用 `ComposeProjectContext.RegistryAuth` 推送;返回值为每个服务一条 `PublishResult`,分别记录 tag 与 push 结果,不会在第一个失败处中断。`ComposePublishOptions` 中的 `ResolveImageDigests`、`WithEnvironment`、`OcIVersion` 和 `InsecureRegistry` 尚未应用。 - `LoadMerged` 按字段逐步合并后置文件:标量由后置值覆盖,映射递归合并,列表追加;服务资源、`command` 和 `entrypoint` 有明确的替换规则。它仍不是 Docker Compose 的完整合并算法;精确规则和不支持的 YAML 标签见[合并语义](docs/merge-semantics.md)。 - `ComposeProjectContext.Profiles` 会在加载项目,以及面向服务的生命周期、检查、镜像、日志、事件和 watch 操作中统一选择服务:未配置 `profiles` 的服务始终会被选择;配置了 profile 的服务会在任一 profile 被激活时被选择。显式指定服务的操作即使未激活该服务的 profile,也可以选择它;选择结果为空时不会退化成操作项目中的全部容器。 -- 加载器可以保留引擎尚未应用的字段。在运行时依赖某个 Compose 属性之前,请查阅 [Compose 字段支持矩阵](docs/compose-field-matrix.md)。 -- `depends_on` 会将所选服务的传递依赖纳入启动范围,并按稳定顺序启动。未启用有效 Docker 健康检查的依赖必须处于运行状态;启用了健康检查(包括从镜像继承)的依赖必须通过健康检查。依赖变为不健康或停止时会返回诊断错误;调用方可以设置 `DependencyWaitTimeoutSeconds` 或取消操作。缺失服务和依赖环也会返回诊断错误;长格式条件仍不支持。 +- 加载器可能会保留引擎尚未完全应用的字段。`LoadProject` 和 `GenerateAsync` 会通过 `Diagnostics` 返回稳定且不包含字段值的诊断,其中包含来源文件、属性路径、适用时的服务名,以及 `unsupported-field` 或 `partially-supported-field` 类型。默认的 `ComposeValidationMode.Report` 会按已记录的行为继续;`ComposeValidationMode.Strict` 会在任何 Docker Engine 操作前拒绝这些字段。非空的未知项目、服务和 build 字段也会报告。长格式服务卷、Windows 盘符卷源路径、长格式 `env_file` 条目、未应用的依赖条件和忽略的 build 选项会产生诊断;默认资源零值和布尔 `false` 默认值保持静默。环境列表中的裸变量名和环境映射中的空值条目会产生诊断,因为尚未实现从调用进程透传环境变量;显式空字符串赋值保持静默。除非显式禁用,否则标量 `healthcheck.test: NONE` 是启用的 shell 检查;列表格式 `[NONE]` 会禁用检查。依赖条件诊断会区分这两种情况。空值/默认值和 `x-*` 扩展不会产生诊断。顶层网络显式声明 `driver: bridge` 与引擎使用的驱动一致,因此保持静默;其他有实际含义的网络选项仍会被诊断。服务未显式指定网络或网络模式,而引擎会将其连接到自定义项目网络而非隐式默认网络时,也会产生部分支持诊断。诊断不会包含字段值,因为其中可能有凭据或机密。依赖某个 Compose 属性的运行时行为前,请查看 [Compose 字段支持矩阵](docs/compose-field-matrix.md)中的行为说明和诊断覆盖。 +- `depends_on` 会将所选服务的传递依赖纳入启动范围,并按稳定顺序启动。未启用有效 Docker 健康检查的依赖必须处于运行状态;启用了健康检查(包括从镜像继承)的依赖必须通过健康检查。依赖变为不健康或停止时会返回诊断错误;调用方可以设置 `DependencyWaitTimeoutSeconds` 或取消操作。缺失服务和依赖环也会返回诊断错误;长格式条件不会被保留。依赖显式启用健康检查时,`service_healthy` 与当前就绪行为一致;只有 Compose 服务显式禁用健康检查时,`service_started` 才不会产生诊断。未显式禁用时,镜像可能提供继承的健康检查,因此会报告诊断。 默认端点在 Windows 是 `npipe://./pipe/docker_engine`,Unix 是 `unix:///var/run/docker.sock`;也可以通过 `SocketPath` 显式指定。 diff --git a/docs/compose-field-matrix.md b/docs/compose-field-matrix.md index 3391215..01fd428 100644 --- a/docs/compose-field-matrix.md +++ b/docs/compose-field-matrix.md @@ -14,22 +14,24 @@ ComposeSharp is an in-process SDK, not a Docker Compose CLI replacement. The loa "Applied" describes the SDK's implementation, not full Docker Compose Specification parity. Explicit operation options can override an applied service value where the API allows it. +`ComposeProjectConfig.Diagnostics` reports non-empty unknown fields, parsed-only fields, and the incomplete portions of partially supported fields. Codes are `unsupported-field` and `partially-supported-field`; each diagnostic includes source file, property path, and service name when applicable, but never the field value. `ComposeValidationMode.Strict` rejects any such diagnostic during loading before an Engine operation. Focused cases are covered by [ComposeLoaderValidationTests](../tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs). + ## Service fields | Compose field(s) | Model member(s) | Status | Current behavior | | --- | --- | --- | --- | | Service key | `Name` | Applied | Identifies the service in resource names and project labels. | | `image` | `Image` | Applied | Used to create and pull container images. | -| `build` | `Build` | Partial | `BuildAsync` packages the local context into a temporary archive file, applying the selected Dockerfile's `.dockerignore` when present (otherwise the root file), including supported glob character classes; it preserves symbolic links and Unix file modes. Ignored directory trees are skipped unless a negated rule can restore a descendant. A selected Dockerfile outside the build context is staged at a safe archive path; archive creation observes cancellation. The operation then uses Docker Engine's build endpoint. It retains the selected Dockerfile even when an ignore rule matches it. It applies `context`, `dockerfile`, `args`, `cache_from`, `target`, `tags`, `labels`, `network`, `extra_hosts`, `shm_size`, one `platform`, `pull`, and `no_cache`; valueless build arguments inherit from the process environment when present, and operation options override build args, labels, target, platform, pull, and no-cache. Build status messages are exposed through `ComposeBuildOptions.LogConsumer`, and daemon-reported build-stream errors fail the operation. `cache_to`, multiple platforms, `privileged`, `builder`, and progress mode are not applied. The loader does not retain `secrets`, `ssh`, or `additional_contexts`. | +| `build` | `Build` | Partial | `BuildAsync` packages the local context into a temporary archive file, applying the selected Dockerfile's `.dockerignore` when present (otherwise the root file), including supported glob character classes; it preserves symbolic links and Unix file modes. Ignored directory trees are skipped unless a negated rule can restore a descendant. A selected Dockerfile outside the build context is staged at a safe archive path; archive creation observes cancellation. The operation then uses Docker Engine's build endpoint. It retains the selected Dockerfile even when an ignore rule matches it. It applies `context`, `dockerfile`, `args`, `cache_from`, `target`, `tags`, `labels`, `network`, `extra_hosts`, `shm_size`, one `platform`, `pull`, and `no_cache`; valueless build arguments inherit from the process environment when present, and operation options override build args, labels, target, platform, pull, and no-cache. Build status messages are exposed through `ComposeBuildOptions.LogConsumer`, and daemon-reported build-stream errors fail the operation. `cache_to`, multiple platforms, enabled `privileged`, `builder`, progress mode, `dockerfile_inline`, and other unretained build keys are not applied and produce diagnostics when non-default. The loader does not retain `secrets`, `ssh`, or `additional_contexts`; `x-*` extension fields are ignored by diagnostics. | | `container_name` | `ContainerName` | Applied | Used for a single replica; scaled services retain project-generated names. | -| `command`, `entrypoint` | `Command`, `Entrypoint` | Partial | List syntax is passed to Docker's create-container request. Scalar values are passed as one argument rather than split into a command and arguments. | -| `environment`, `env_file` | `Environment`, `EnvFile` | Partial | Inline environment and values read from scalar or list-of-string `env_file` entries are passed to the container, and their original paths are retained for inspection. Long `env_file` syntax and advanced Compose environment semantics are not implemented. | -| `ports` | `Ports` | Partial | Short `HOST:CONTAINER` string syntax creates a port binding when each container port has one mapping. Container-only entries expose the port but do not create a host binding. Multiple mappings for one container port, long syntax, host IP, ranges, and other per-port options are not modeled. | -| `volumes` | `Volumes` | Partial | Short POSIX bind and named-volume strings are resolved and passed as binds. Long syntax, Windows drive-letter binds, and top-level volume driver/options are not applied. | +| `command`, `entrypoint` | `Command`, `Entrypoint` | Partial | List syntax is passed to Docker's create-container request. Scalar values are passed as one argument rather than split into a command and arguments; scalar values produce diagnostics. | +| `environment`, `env_file` | `Environment`, `EnvFile` | Partial | Inline environment and values read from scalar or list-of-string `env_file` entries are passed to the container, and their original paths are retained for inspection. Process-environment passthrough is not implemented: bare list names remain unresolved and null-valued mapping entries become empty assignments in report mode. These forms produce `partially-supported-field` diagnostics at the list entry or escaped mapping key, and strict validation rejects them before Docker access. Explicit values, including empty strings, do not produce passthrough diagnostics. Long `env_file` syntax and other advanced Compose environment semantics are not implemented; long-form file entries also produce diagnostics. | +| `ports` | `Ports` | Partial | Short `HOST:CONTAINER` string syntax creates a port binding when each container port has one mapping. Container-only entries expose the port but do not create a host binding. Multiple mappings for one container port, long syntax, ranges, and other per-port options are not modeled; duplicate target mappings and host IP usage produce `partially-supported-field` diagnostics at the affected port entry. | +| `volumes` | `Volumes` | Partial | Short POSIX bind and named-volume strings are resolved and passed as binds. Long service-volume syntax and Windows drive-letter bind sources produce diagnostics because they are not applied correctly. Top-level volume driver/options are not applied. | | `restart` | `Restart`, `RestartMaxRetries` | Applied | Docker restart policy name and `on-failure:N` retry count are applied. Counts range from 0 to 2147483647; zero or omission means unlimited retries. Malformed suffixes are rejected with source/service/property diagnostics. Verified by `CreateAsync_AppliesRestartRetryCount` and focused loader/mapping tests. | -| `healthcheck` | `Healthcheck` | Partial | Disable flag, list-form tests, supported interval/timeout/start-period values, and retries are sent in the create-container request. `start_interval` is not modeled. Durations accept .NET `TimeSpan` syntax or a single whole-number `ms`, `s`, `m`, or `h` unit; fractional and multi-component Compose durations are not accepted. A scalar `test` command is not converted to `CMD-SHELL`. | -| `depends_on` | `DependsOn` | Partial | Selected services include their transitive dependencies and start in stable topological order; missing services and cycles fail with diagnostics. A dependency with an effective Docker health check, including an image-inherited check, must become healthy before its dependents start; otherwise it must be running. Readiness waits continue until ready, failure, cancellation, or the optional `DependencyWaitTimeoutSeconds` limit. Long-form conditions are not retained, and targeted lifecycle operations still do not start unrelated services. | -| `networks` | `Networks` | Partial | The first declared service network becomes the container network mode. Per-network configuration and multi-network attachment are not applied. When a service declares no network and the project has custom networks, it attaches to the first custom network rather than an implicit default network. | +| `healthcheck` | `Healthcheck` | Partial | Disable flag, list-form tests, supported interval/timeout/start-period values, and retries are sent in the create-container request. Scalar `test` strings, including `NONE`, are normalized to enabled `CMD-SHELL` checks unless explicitly disabled; list-form `[NONE]` disables the check. `start_interval` is not modeled. Durations accept .NET `TimeSpan` syntax or a single whole-number `ms`, `s`, `m`, or `h` unit; fractional and multi-component Compose durations are not accepted. | +| `depends_on` | `DependsOn` | Partial | Selected services include their transitive dependencies and start in stable topological order; missing services and cycles fail with diagnostics. A dependency with an effective Docker health check, including an image-inherited check, must become healthy before its dependents start; otherwise it must be running. Readiness waits continue until ready, failure, cancellation, or the optional `DependencyWaitTimeoutSeconds` limit. Long-form conditions are not retained; `service_healthy` with an explicit enabled health check matches current readiness behavior. `service_started` is considered matching only when the Compose service explicitly disables its health check; otherwise it produces a diagnostic because the image may supply one. Other conditions and options produce diagnostics. Targeted lifecycle operations still do not start unrelated services. | +| `networks` | `Networks` | Partial | The first declared service network becomes the container network mode. Per-network configuration and additional network attachments are not applied; multiple attachments and non-default per-network options produce diagnostics. When a service has no network attachment or network mode, it falls back to the first project network. If that network is custom rather than `default`, this differs from implicit default-network semantics and produces a `partially-supported-field` diagnostic at `services..networks`; strict validation rejects the fallback before Docker access. Explicit service attachments, explicit network modes, and projects whose first network is `default` do not produce this fallback diagnostic. | | `extra_hosts` | `ExtraHosts` | Partial | Short list entries are passed to Docker as host entries. Mapping syntax loses the host address during loading and is not supported. | | `privileged` | `Privileged` | Applied | Passed in the host configuration. | | `network_mode`, `ipc`, `shm_size` | `NetworkMode`, `Ipc`, `ShmSize` | Partial | Direct Docker modes and integer/K/M/G `shm_size` values are passed in the host configuration. Decimal byte values are not accepted. `network_mode: service:` and `ipc: service:` are not resolved to a service container. `network_mode` takes precedence over the generated project network. | @@ -37,7 +39,7 @@ ComposeSharp is an in-process SDK, not a Docker Compose CLI replacement. The loa | `deploy` | `Deploy` | Partial | Only `deploy.replicas` affects `UpAsync`; resource memory and literal `nano_cpus` values, placement constraints, restart policy, update/rollback settings, labels, and mode are parsed only. Duration fields use the same restricted duration syntax as health checks. Other placement settings, standard `deploy.resources.*.cpus` values, and scalar `endpoint_mode` are not retained. | | `secrets`, `configs` | `Secrets`, `Configs` | Unsupported | Short string entries are parsed and surfaced, but no secret or config is provisioned or mounted. Long syntax is not retained correctly. | | `labels` | `Labels` | Applied | Merged into the labels applied to service containers. | -| `logging` | `Logging` | Parsed only | Driver and options are exposed but not sent to Docker. | +| `logging` | `Logging` | Parsed only | Driver and options are exposed but not sent to Docker; a non-empty value produces an `unsupported-field` diagnostic. | | `hostname`, `domainname`, `user`, `working_dir` | `Hostname`, `Domainname`, `User`, `WorkingDir` | Applied | Passed to Docker's create-container request. | | `tty`, `stdin_open` | `Tty`, `StdinOpen` | Applied | Passed to Docker's create-container request. | | `stop_signal`, `stop_grace_period` | `StopSignal`, `StopGracePeriod` | Parsed only | Exposed by the loader when the duration uses the supported .NET `TimeSpan` or single whole-number unit syntax; the container create and stop paths do not apply them. | @@ -46,7 +48,8 @@ ComposeSharp is an in-process SDK, not a Docker Compose CLI replacement. The loa | `devices` | `Devices` | Partial | Two-segment short mappings are passed in the host configuration. Permission-bearing mappings such as `/dev/sda:/dev/xvdc:rwm` are parsed incorrectly and are not supported. | | `sysctls` | `Sysctls` | Parsed only | Exposed by the loader but not passed to Docker. | | `init` | `Init` | Applied | Enables Docker init when the value is `true`. | -| `platform`, `pull_policy` | `Platform`, `PullPolicy` | Parsed only | Exposed by the loader; platform is not passed to create or build, and pull behavior is controlled by operation options. | +| `platform` | `Platform` | Partial | For build services, supplies the fallback platform to `BuildAsync` when no build-specific platform or operation override is set. It is not passed to image pulls or container creation; diagnostics are emitted when there is no build fallback to apply it. | +| `pull_policy` | `PullPolicy` | Parsed only | Exposed by the loader but pull behavior is controlled by operation options. | | `dns`, `dns_search` | `Dns`, `DnsSearch` | Parsed only | Exposed by the loader but not passed to Docker. | | `pid`, `mac_address`, `cgroup_parent` | `Pid`, `MacAddress`, `CgroupParent` | Partial | Direct Docker PID modes, MAC address, and cgroup parent are passed to Docker. `pid: service:` is not resolved to a service container. | | `extends` | `ExtendsService`, `ExtendsFile` | Unsupported | The loader records the reference but does not resolve or merge it. A service that declares only `extends`, without its own `image` or `build`, is rejected during loading. | @@ -66,10 +69,12 @@ ComposeSharp is an in-process SDK, not a Docker Compose CLI replacement. The loa | Compose file directory | `WorkingDirectory` | Applied | Resolves Compose-relative paths and bind mounts. | | `services` | `Services` | Applied | Provides service definitions used by project operations. | | `volumes` | `Volumes` | Partial | Project-scoped volumes are created with a generated name and project label. Driver, labels, options, and external volumes are not represented; external volume names are rewritten to project-scoped names. | -| `networks` | `Networks` | Partial | Project-scoped bridge networks are created with generated names and project labels. Driver, IPAM, labels, external networks, and options are not represented. | +| `networks` | `Networks` | Partial | Project-scoped bridge networks are created with generated names and project labels. An explicit `driver: bridge` matches this behavior and produces no diagnostic. Other drivers, IPAM, labels, external networks, and options are not represented and produce diagnostics when non-default. | | `secrets`, `configs` | `Secrets`, `Configs` | Unsupported | Names are loaded and reported by `LoadProject`, but are not provisioned or mounted. | | `x-*` extensions | `Extensions` | Parsed only | String-valued extensions are retained for inspection and have no engine behavior. | +Unknown non-empty top-level and service fields produce `unsupported-field` diagnostics; `x-*` extensions and empty/default values are ignored. Parsed-only service fields in this matrix produce the same code. The [loader validation tests](../tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs) cover unknown project and service fields, parsed-only `logging`, partial command, network, and build behavior, scalar health-check normalization, value redaction, overlay provenance, and strict rejection before both lifecycle and direct Docker operations. Integration-test teardown also verifies that each test project leaves no project-labeled networks. The project-level `name` field produces a `partially-supported-field` diagnostic because resource ownership uses `ComposeProjectContext.ProjectName`. + ## Related work - [Issue #19](https://github.com/GaTTGeng/ComposeSharp/issues/19) will make dependency ordering and readiness deliberate and testable. diff --git a/docs/roadmap.md b/docs/roadmap.md index d6a3168..d6deaa0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -53,7 +53,9 @@ Reimplement every Docker Compose CLI UX feature. The public SDK should prioritiz **Goal:** Make repeated project reconciliation safe enough for applications that manage more than one development lifecycle. -Dependency-aware startup now includes selected services' transitive dependencies, rejects missing dependencies and cycles, and waits for dependencies to run or become healthy before starting dependents. Health checks inherited from images are detected, and callers can set an optional per-dependency timeout. Long-form `depends_on` conditions remain unsupported. +Dependency-aware startup now includes selected services' transitive dependencies, rejects missing dependencies and cycles, and waits for dependencies to run or become healthy before starting dependents. Health checks inherited from images are detected, and callers can set an optional per-dependency timeout. Long-form `depends_on` values are not retained; conditions that differ from current readiness behavior produce diagnostics. + +Project loading reports value-free diagnostics for unknown, unsupported, and partially applied non-default Compose fields. Callers can inspect them through `LoadProject` or `GenerateAsync`, or select strict validation to reject the configuration before Docker Engine operations. See the [field matrix](compose-field-matrix.md) for diagnostic coverage and each field's actual behavior. `ListAsync` derives each stack's status from all project-labeled container states. It reports `running`, `exited`, `partial`, or `unknown`; by default, Docker's non-`All` container listing determines which projects are included, while `All` includes projects with only stopped containers. diff --git a/src/ComposeSharp.Api/ComposeProjectContext.cs b/src/ComposeSharp.Api/ComposeProjectContext.cs index 8775179..363d69b 100644 --- a/src/ComposeSharp.Api/ComposeProjectContext.cs +++ b/src/ComposeSharp.Api/ComposeProjectContext.cs @@ -37,6 +37,21 @@ public sealed record ComposeProjectContext /// When true, apply legacy Compose CLI compatibility behavior. public bool Compatibility { get; init; } + + /// Controls how unsupported or partially supported Compose fields are handled. + /// Report mode exposes value-free diagnostics; strict mode rejects non-default unsupported or partial fields before a Docker Engine operation. + public ComposeValidationMode ValidationMode { get; init; } = ComposeValidationMode.Report; +} + +/// Controls whether Compose fields without full engine support are reported or rejected. +/// Empty/default values and x-* extension fields do not trigger diagnostics. +public enum ComposeValidationMode +{ + /// Continue loading and expose value-free field diagnostics in the project summary. + Report, + + /// Reject unsupported or partially supported fields before any Docker Engine operation. + Strict } /// diff --git a/src/ComposeSharp.Api/IComposeService.cs b/src/ComposeSharp.Api/IComposeService.cs index 205990d..e648ef8 100644 --- a/src/ComposeSharp.Api/IComposeService.cs +++ b/src/ComposeSharp.Api/IComposeService.cs @@ -171,7 +171,7 @@ public interface IComposeService Task> VolumesAsync(ComposeProjectContext context, ComposeVolumesOptions? options = null, CancellationToken cancellationToken = default); /// Loads and returns the project's configuration without touching Docker resources. - /// Multi-file loading currently replaces same-named services instead of applying the full Compose merge rules. + /// The result includes value-free diagnostics for unknown, unsupported, or partially applied non-default fields. Strict validation rejects them before any Docker operation. Multi-file loading currently replaces same-named services instead of applying the full Compose merge rules. ComposeProjectConfig LoadProject(ComposeProjectContext context); /// diff --git a/src/ComposeSharp.Api/Results/ComposeProjectConfig.cs b/src/ComposeSharp.Api/Results/ComposeProjectConfig.cs index d29af8b..931179b 100644 --- a/src/ComposeSharp.Api/Results/ComposeProjectConfig.cs +++ b/src/ComposeSharp.Api/Results/ComposeProjectConfig.cs @@ -35,6 +35,10 @@ public sealed record ComposeProjectConfig /// Names of the declared configs. public IReadOnlyList Configs { get; init; } = []; + /// Value-free diagnostics for unknown, unsupported, or partially supported Compose fields. + /// Diagnostics identify source provenance and field paths without exposing configuration values. + public IReadOnlyList Diagnostics { get; init; } = []; + /// Environment values resolved for the project, if requested. public IReadOnlyDictionary Environment { get; init; } = new Dictionary(); @@ -48,6 +52,25 @@ public sealed record ComposeProjectConfig /// public string? RenderedYaml { get; init; } } + +/// Identifies a Compose field whose value is not fully applied to project resources. +public sealed record ComposeDiagnostic +{ + /// The Compose file that contributed the field. + public required string SourceFile { get; init; } + + /// The dotted YAML property path. + public required string PropertyPath { get; init; } + + /// The service associated with a service-level field, or null for a project-level field. + public string? ServiceName { get; init; } + + /// Stable identifier for the diagnostic category. + public required string Code { get; init; } + + /// Value-free explanation of the unsupported or partial behavior. + public required string Message { get; init; } +} // ReSharper restore UnusedMember.Global // ReSharper restore UnusedAutoPropertyAccessor.Global // ReSharper restore CheckNamespace diff --git a/src/ComposeSharp.Engine/ComposeService.cs b/src/ComposeSharp.Engine/ComposeService.cs index ff3b11a..7ac9965 100644 --- a/src/ComposeSharp.Engine/ComposeService.cs +++ b/src/ComposeSharp.Engine/ComposeService.cs @@ -338,6 +338,7 @@ public async Task RemoveAsync(ComposeProjectContext context, ComposeRemoveOption /// Runs a command inside a running container of a service and returns its exit code and output. public async Task ExecAsync(ComposeProjectContext context, string serviceName, ComposeExecOptions options, CancellationToken cancellationToken = default) { + ValidateStrictProjectBeforeDocker(context); // Resolve the target running container (replica index honored). using var client = _clientFactory.CreateClient(context.SocketPath); var container = await _containers.FindRunningContainerAsync(client, context.ProjectName, serviceName, options.Index, cancellationToken); @@ -371,6 +372,7 @@ public async Task ExecAsync(ComposeProjectContext context, string se /// Streams a running container's combined output to the console until the stream ends. public async Task AttachAsync(ComposeProjectContext context, string serviceName, ComposeAttachOptions? options = null, CancellationToken cancellationToken = default) { + ValidateStrictProjectBeforeDocker(context); // Resolve the target running container, then open its multiplexed output stream. using var client = _clientFactory.CreateClient(context.SocketPath); var container = await _containers.FindRunningContainerAsync(client, context.ProjectName, serviceName, options?.Index, cancellationToken); @@ -407,6 +409,7 @@ public async Task CopyAsync(ComposeProjectContext context, ComposeCo if (source.IsContainer == destination.IsContainer) throw new ArgumentException("Exactly one copy path must use the 'service:/container/path' form.", nameof(options)); + ValidateStrictProjectBeforeDocker(context); using var client = _clientFactory.CreateClient(context.SocketPath); // Container → host: stream the container archive out and extract it to the host path. @@ -606,6 +609,7 @@ public async Task> ImagesAsync(ComposeProjectContext /// Resolves the host address and port published for a container port of a running service. public async Task<(string Host, int Port)> PortAsync(ComposeProjectContext context, string serviceName, int containerPort, ComposePortOptions? options = null, CancellationToken cancellationToken = default) { + ValidateStrictProjectBeforeDocker(context); // Resolve the running container, then inspect its published port bindings. using var client = _clientFactory.CreateClient(context.SocketPath); var container = await _containers.FindRunningContainerAsync(client, context.ProjectName, serviceName, options?.Index, cancellationToken); @@ -792,6 +796,7 @@ public async Task ExportAsync(ComposeProjectContext context, ComposeExportOption ArgumentException.ThrowIfNullOrWhiteSpace(options.Service); ArgumentException.ThrowIfNullOrWhiteSpace(options.OutputPath); + ValidateStrictProjectBeforeDocker(context); using var client = _clientFactory.CreateClient(context.SocketPath); var container = await _containers.FindContainerForArchiveAsync( client, context.ProjectName, options.Service, options.Index, cancellationToken); @@ -823,6 +828,7 @@ public async Task CommitAsync(ComposeProjectContext context, ComposeComm var reference = options.Reference ?? $"{context.ProjectName}-{options.Service}:latest"; var parameters = ImageCommitParametersFactory.Create(reference, options); + ValidateStrictProjectBeforeDocker(context); using var client = _clientFactory.CreateClient(context.SocketPath); var container = await _containers.FindContainerForArchiveAsync( client, context.ProjectName, options.Service, options.Index, cancellationToken); @@ -879,6 +885,7 @@ public Task GenerateAsync(ComposeProjectContext context, C Volumes = project.Volumes.ToList(), Secrets = project.Secrets.ToList(), Configs = project.Configs.ToList(), + Diagnostics = ToApiDiagnostics(project), RenderedYaml = yaml }); } @@ -900,6 +907,7 @@ private static IReadOnlyList SelectConfigurationServices( /// Lists the project-labeled volumes on the daemon. public async Task> VolumesAsync(ComposeProjectContext context, ComposeVolumesOptions? options = null, CancellationToken cancellationToken = default) { + ValidateStrictProjectBeforeDocker(context); // List only volumes owned by this project's label, then map to summaries. using var client = _clientFactory.CreateClient(context.SocketPath); var volumes = await client.Volumes.ListAsync(new VolumesListParameters @@ -920,6 +928,7 @@ public async Task> VolumesAsync(ComposeProjectCont } /// Loads the project and returns its configuration with services filtered by the active profiles. + /// The result includes value-free diagnostics for unknown, unsupported, or partially applied non-default fields. public ComposeProjectConfig LoadProject(ComposeProjectContext context) { // Load from disk, then project only the profile-selected services plus top-level resource lists. @@ -933,7 +942,8 @@ public ComposeProjectConfig LoadProject(ComposeProjectContext context) Networks = project.Networks.ToList(), Volumes = project.Volumes.ToList(), Secrets = project.Secrets.ToList(), - Configs = project.Configs.ToList() + Configs = project.Configs.ToList(), + Diagnostics = ToApiDiagnostics(project) }; } @@ -1030,9 +1040,36 @@ public async Task> PublishAsync(ComposeProjectConte private ComposeProject LoadProjectInternal(ComposeProjectContext context) { // Single entry point for Compose file loading; all public methods funnel through here. - return _loader.Load(context.WorkingDirectory, context.ComposeFileName); + var project = _loader.Load(context.WorkingDirectory, context.ComposeFileName); + if (context.ValidationMode == ComposeValidationMode.Strict && project.Diagnostics.Count > 0) + { + var diagnostic = project.Diagnostics[0]; + throw new ComposeValidationException( + diagnostic.SourceFile, + diagnostic.PropertyPath, + $"{diagnostic.Code}: {diagnostic.Message}", + diagnostic.ServiceName); + } + + return project; + } + + private void ValidateStrictProjectBeforeDocker(ComposeProjectContext context) + { + if (context.ValidationMode == ComposeValidationMode.Strict) + _ = LoadProjectInternal(context); } + private static IReadOnlyList ToApiDiagnostics(ComposeProject project) + => project.Diagnostics.Select(diagnostic => new ComposeDiagnostic + { + SourceFile = diagnostic.SourceFile, + PropertyPath = diagnostic.PropertyPath, + ServiceName = diagnostic.ServiceName, + Code = diagnostic.Code, + Message = diagnostic.Message + }).ToList(); + // A missing Compose file (without active profiles) falls back to label-only selection so // existing containers can still be listed, stopped, or removed without project source on disk. private ExistingServiceSelection SelectExistingServices( diff --git a/src/ComposeSharp.Loader/ComposeFileLoader.cs b/src/ComposeSharp.Loader/ComposeFileLoader.cs index f499758..3b72cdd 100644 --- a/src/ComposeSharp.Loader/ComposeFileLoader.cs +++ b/src/ComposeSharp.Loader/ComposeFileLoader.cs @@ -166,6 +166,7 @@ private ComposeProject ParseProject( // Stage: structural validation first (so bad shapes fail with provenance), then model mapping. var validation = new ValidationContext(composePath, provenance); ValidateProject(root, validation); + var diagnostics = CollectProjectDiagnostics(root, validation); var servicesMap = (Dictionary)root["services"]!; // Top-level resource maps contribute only their declared names to the project model. @@ -191,9 +192,529 @@ private ComposeProject ParseProject( networks, secrets, configs, - extensions); + extensions) + { + Diagnostics = diagnostics + }; + } + + private static readonly HashSet KnownProjectFields = new(StringComparer.Ordinal) + { + "name", "version", "services", "volumes", "networks", "secrets", "configs" + }; + + private static readonly HashSet KnownServiceFields = new(StringComparer.Ordinal) + { + "image", "build", "container_name", "command", "entrypoint", "environment", "env_file", "ports", + "volumes", "restart", "healthcheck", "depends_on", "networks", "extra_hosts", "privileged", + "network_mode", "ipc", "shm_size", "profiles", "deploy", "secrets", "configs", "labels", "logging", + "hostname", "domainname", "user", "working_dir", "tty", "stdin_open", "stop_signal", "stop_grace_period", + "read_only", "tmpfs", "cap_add", "cap_drop", "devices", "sysctls", "security_opt", "init", "platform", + "pull_policy", "dns", "dns_search", "pid", "mac_address", "cgroup_parent", "extends", "develop", "links", + "cpu_shares", "cpu_quota", "cpuset", "mem_limit", "memswap_limit", "mem_reservation", "oom_kill_disable", + "oom_score_adj", "group_add", "annotations" + }; + + private static readonly HashSet UnsupportedServiceFields = new(StringComparer.Ordinal) + { + "secrets", "configs", "logging", "stop_signal", "stop_grace_period", "sysctls", + "pull_policy", "dns", "dns_search", "links", "cpu_quota", "oom_kill_disable", "oom_score_adj", + "annotations", "extends", "develop" + }; + + private static readonly HashSet KnownBuildFields = new(StringComparer.Ordinal) + { + "context", "dockerfile", "args", "cache_from", "cache_to", "target", "tags", "labels", + "network", "extra_hosts", "privileged", "shm_size", "platforms", "pull", "no_cache", + "context_directory", "secrets", "ssh", "additional_contexts" + }; + + private static IReadOnlyList CollectProjectDiagnostics( + Dictionary root, + ValidationContext context) + { + var diagnostics = new List(); + foreach (var (keyNode, value) in root) + { + var key = keyNode.ToString() ?? string.Empty; + if (key == "name" && IsMeaningful(value)) + { + AddDiagnostic(diagnostics, context, "name", null, + "partially-supported-field", "The project name is supplied by ComposeProjectContext and this field does not change resource labels."); + continue; + } + + if (!KnownProjectFields.Contains(key) && !key.StartsWith("x-", StringComparison.Ordinal) && IsMeaningful(value)) + { + AddDiagnostic(diagnostics, context, AppendPathSegment(string.Empty, key), null, + "unsupported-field", "This project field is not recognized and has no engine behavior."); + } + } + + var services = (Dictionary)root["services"]!; + var projectNetworks = ParseNameList(root, "networks"); + foreach (var (nameNode, value) in services) + { + var serviceName = nameNode.ToString() ?? string.Empty; + var displayPath = $"services.{serviceName}"; + var serviceContext = context.ForService( + serviceName, displayPath, AppendPathSegment("services", serviceName)); + if (value is not Dictionary service) + continue; + + foreach (var (keyNode, fieldValue) in service) + { + var key = keyNode.ToString() ?? string.Empty; + var path = $"{displayPath}.{key}"; + if (!KnownServiceFields.Contains(key) && !key.StartsWith("x-", StringComparison.Ordinal) && IsMeaningful(fieldValue)) + { + AddDiagnostic(diagnostics, serviceContext, + AppendPathSegment(serviceContext.DisplayServicePath!, key), serviceName, + "unsupported-field", "This service field is not recognized and has no engine behavior."); + continue; + } + + if (UnsupportedServiceFields.Contains(key) && IsMeaningful(fieldValue) && + !IsDefaultUnsupportedFieldValue(key, fieldValue)) + { + var diagnosticPath = GetUnsupportedFieldDiagnosticPath(serviceContext, path, fieldValue); + AddDiagnostic(diagnostics, serviceContext, diagnosticPath, serviceName, + "unsupported-field", "This service field is retained or parsed but is not applied to Docker resources."); + } + + if (key == "platform" && IsMeaningful(fieldValue) && IsServicePlatformUnapplied(service)) + { + AddDiagnostic(diagnostics, serviceContext, path, serviceName, + "unsupported-field", "The service platform is not applied to image pulls or container creation."); + } + } + + AddPartialDiagnostics(service, services, projectNetworks, serviceName, displayPath, serviceContext, diagnostics); + } + + AddProjectResourceDiagnostics(root, "volumes", context, diagnostics); + AddProjectResourceDiagnostics(root, "networks", context, diagnostics); + AddUnappliedProjectResourceDiagnostics(root, "secrets", context, diagnostics); + AddUnappliedProjectResourceDiagnostics(root, "configs", context, diagnostics); + + return diagnostics; + } + + private static void AddPartialDiagnostics( + Dictionary service, + Dictionary services, + IReadOnlyList projectNetworks, + string serviceName, + string servicePath, + ValidationContext context, + List diagnostics) + { + foreach (var field in new[] { "command", "entrypoint" }) + { + if (TryGetValue(service, field, out var command) && command is string commandText && commandText.Length > 0) + AddDiagnostic(diagnostics, context, $"{servicePath}.{field}", serviceName, + "partially-supported-field", "Scalar commands are passed as one argument instead of shell-split arguments."); + } + + if (TryGetValue(service, "ports", out var ports) && ports is List portList) + { + var containerPorts = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < portList.Count; i++) + { + if (portList[i] is not string port) + continue; + + if (port.Count(character => character == ':') >= 2) + AddDiagnostic(diagnostics, context, $"{servicePath}.ports[{i}]", serviceName, + "partially-supported-field", "Host IP port bindings are not preserved by the current port model."); + + var containerPort = ParsePort(port).ContainerPort; + if (!containerPorts.Add(containerPort)) + AddDiagnostic(diagnostics, context, $"{servicePath}.ports[{i}]", serviceName, + "partially-supported-field", "Multiple host mappings for the same container port are not applied."); + } + } + + if (TryGetValue(service, "depends_on", out var dependencies) && dependencies is Dictionary dependencyMap) + { + foreach (var (dependencyName, dependencyValue) in dependencyMap) + { + var dependencyKey = dependencyName.ToString() ?? string.Empty; + if (IsMeaningful(dependencyValue) && !IsDependencyConditionApplied(dependencyValue, dependencyKey, services)) + AddDiagnostic(diagnostics, context, + AppendPathSegment($"{servicePath}.depends_on", dependencyKey), serviceName, + "partially-supported-field", "Long-form dependency conditions and options are not applied."); + } + } + + if (TryGetValue(service, "environment", out var environment)) + { + if (environment is List environmentList) + { + for (var i = 0; i < environmentList.Count; i++) + { + if (environmentList[i] is string entry && !string.IsNullOrWhiteSpace(entry) && !entry.Contains('=')) + AddDiagnostic(diagnostics, context, $"{servicePath}.environment[{i}]", serviceName, + "partially-supported-field", "Environment passthrough is not resolved from the calling process; use an explicit value."); + } + } + else if (environment is Dictionary environmentMap) + { + foreach (var (name, value) in environmentMap) + { + if (value is null) + AddDiagnostic(diagnostics, context, AppendPathSegment($"{servicePath}.environment", name.ToString()!), serviceName, + "partially-supported-field", "Environment passthrough is not resolved from the calling process; use an explicit value."); + } + } + } + + if (TryGetValue(service, "env_file", out var envFiles) && envFiles is List envFileList) + { + for (var i = 0; i < envFileList.Count; i++) + { + if (envFileList[i] is Dictionary) + AddDiagnostic(diagnostics, context, $"{servicePath}.env_file[{i}]", serviceName, + "partially-supported-field", "Long-form environment file options are not applied; use short syntax."); + } + } + + if (TryGetValue(service, "deploy", out var deployValue) && deployValue is Dictionary deploy) + { + foreach (var (field, value) in deploy) + { + var deployField = field.ToString() ?? string.Empty; + if (deployField != "replicas" && !deployField.StartsWith("x-", StringComparison.Ordinal) && + IsMeaningful(value)) + AddDiagnostic(diagnostics, context, $"{servicePath}.deploy.{field}", serviceName, + "partially-supported-field", "Only deploy.replicas affects service operations."); + } + } + + if (TryGetValue(service, "build", out var buildValue) && buildValue is Dictionary build && + TryGetValue(build, "cache_to", out var cacheTo) && IsMeaningful(cacheTo)) + { + AddDiagnostic(diagnostics, context, $"{servicePath}.build.cache_to", serviceName, + "partially-supported-field", "Build cache export is not applied."); + } + + if (TryGetValue(service, "build", out buildValue) && buildValue is Dictionary buildOptions) + { + if (TryGetValue(buildOptions, "platforms", out var platforms) && platforms is List platformList && platformList.Count > 1) + AddDiagnostic(diagnostics, context, $"{servicePath}.build.platforms", serviceName, + "partially-supported-field", "Docker Engine build requests support one platform at a time."); + + foreach (var field in new[] { "secrets", "ssh", "additional_contexts", "privileged" }) + { + if (TryGetValue(buildOptions, field, out var value) && IsMeaningful(value) && + !(field == "privileged" && IsFalse(value))) + AddDiagnostic(diagnostics, context, $"{servicePath}.build.{field}", serviceName, + field == "privileged" ? "partially-supported-field" : "unsupported-field", + field == "privileged" + ? "This build option is retained but not applied to Docker." + : "This build option is not retained by the loader or applied to Docker."); + } + + foreach (var (fieldNode, value) in buildOptions) + { + var field = fieldNode.ToString() ?? string.Empty; + if (!KnownBuildFields.Contains(field) && !field.StartsWith("x-", StringComparison.Ordinal) && + IsMeaningful(value)) + AddDiagnostic(diagnostics, context, + AppendPathSegment($"{servicePath}.build", field), serviceName, + "unsupported-field", "This build option is not retained by the loader or applied to Docker."); + } + } + + if (TryGetValue(service, "healthcheck", out var healthcheckValue) && + healthcheckValue is Dictionary healthcheck) + { + if (TryGetValue(healthcheck, "start_interval", out var startInterval) && IsMeaningful(startInterval)) + AddDiagnostic(diagnostics, context, $"{servicePath}.healthcheck.start_interval", serviceName, + "unsupported-field", "Health check start_interval is not retained or applied."); + } + + foreach (var field in new[] { "network_mode", "ipc", "pid" }) + { + if (TryGetValue(service, field, out var mode) && mode is string modeText && + modeText.StartsWith("service:", StringComparison.Ordinal)) + AddDiagnostic(diagnostics, context, $"{servicePath}.{field}", serviceName, + "partially-supported-field", "Service-container references are not resolved to a service container."); + } + + if (TryGetValue(service, "extra_hosts", out var extraHosts) && extraHosts is Dictionary hostMap && hostMap.Count > 0) + AddDiagnostic(diagnostics, context, $"{servicePath}.extra_hosts", serviceName, + "partially-supported-field", "Mapping syntax loses host addresses and is not supported."); + + if (projectNetworks.Count > 0 && + !string.Equals(projectNetworks[0], "default", StringComparison.OrdinalIgnoreCase) && + GetStringList(service, "networks").Count == 0 && GetString(service, "network_mode") is null) + { + AddDiagnostic(diagnostics, context, $"{servicePath}.networks", serviceName, + "partially-supported-field", "Services without an explicit network attach to the first project network instead of the implicit default network."); + } + + if (TryGetValue(service, "networks", out var networks) && networks is List networkList && networkList.Count > 1) + { + AddDiagnostic(diagnostics, context, $"{servicePath}.networks", serviceName, + "partially-supported-field", "Only the first declared network is attached to the service."); + } + + if (TryGetValue(service, "volumes", out var volumes) && volumes is List volumeList) + { + for (var i = 0; i < volumeList.Count; i++) + { + if (volumeList[i] is Dictionary) + AddDiagnostic(diagnostics, context, $"{servicePath}.volumes[{i}]", serviceName, + "partially-supported-field", "Long-form service volumes are not applied correctly; use short syntax."); + else if (volumeList[i] is string volume && volume.Length > 2 && + char.IsAsciiLetter(volume[0]) && volume[1] == ':' && volume[2] is '\\' or '/' && + volume.IndexOf(':', 2) >= 0) + AddDiagnostic(diagnostics, context, $"{servicePath}.volumes[{i}]", serviceName, + "partially-supported-field", "Windows drive-letter volume sources are not applied correctly."); + } + } + if (networks is Dictionary networkMap) + { + if (networkMap.Count > 1) + AddDiagnostic(diagnostics, context, $"{servicePath}.networks", serviceName, + "partially-supported-field", "Only the first declared network is attached to the service."); + + foreach (var (networkName, options) in networkMap) + { + if (IsMeaningfulWithoutExtensions(options)) + AddDiagnostic(diagnostics, context, AppendPathSegment($"{servicePath}.networks", networkName.ToString()!), serviceName, + "partially-supported-field", "Per-network options and multiple network attachments are not applied."); + } + } + + if (TryGetValue(service, "devices", out var devices) && devices is List deviceList) + { + for (var i = 0; i < deviceList.Count; i++) + { + if (deviceList[i] is string device && device.Count(character => character == ':') >= 2) + AddDiagnostic(diagnostics, context, $"{servicePath}.devices[{i}]", serviceName, + "partially-supported-field", "Device permission suffixes are not modeled correctly."); + } + } + } + + private static void AddProjectResourceDiagnostics( + Dictionary root, + string section, + ValidationContext context, + List diagnostics) + { + if (!TryGetValue(root, section, out var value) || value is not Dictionary resources) + return; + + foreach (var (name, definition) in resources) + { + var path = GetResourceDiagnosticPath(section, AppendPathSegment(section, name.ToString()!), definition, context); + if (path is not null) + AddDiagnostic(diagnostics, context, path, null, + "partially-supported-field", "Only the resource name is retained; resource options are not applied."); + } + } + + private static bool IsServicePlatformUnapplied(Dictionary service) + { + if (!TryGetValue(service, "build", out var build) || !IsMeaningful(build)) + return true; + + return build is Dictionary buildOptions && + TryGetValue(buildOptions, "platforms", out var platforms) && IsMeaningful(platforms); + } + + private static string GetUnsupportedFieldDiagnosticPath( + ValidationContext context, + string path, + object? value) + { + var source = context.SourceFor(path); + return FindMeaningfulDescendantPaths(path, value) + .FirstOrDefault(descendant => context.SourceFor(descendant) != source) ?? path; + } + + private static IEnumerable FindMeaningfulDescendantPaths(string path, object? value) + { + switch (value) + { + case Dictionary map: + foreach (var (key, child) in map) + { + foreach (var descendant in FindMeaningfulDescendantPaths( + AppendPathSegment(path, key.ToString() ?? string.Empty), child)) + yield return descendant; + } + break; + case List list: + for (var i = 0; i < list.Count; i++) + { + foreach (var descendant in FindMeaningfulDescendantPaths($"{path}[{i}]", list[i])) + yield return descendant; + } + break; + default: + if (IsMeaningful(value)) + yield return path; + break; + } + } + + private static bool IsDefaultUnsupportedFieldValue(string field, object? value) + { + if (field is "privileged" or "oom_kill_disable") + return IsFalse(value); + if (field is not ("cpu_quota" or "oom_score_adj")) + return false; + + return value switch + { + byte number => number == 0, + sbyte number => number == 0, + short number => number == 0, + ushort number => number == 0, + int number => number == 0, + uint number => number == 0, + long number => number == 0, + ulong number => number == 0, + float number => number == 0, + double number => number == 0, + decimal number => number == 0, + string text => decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var number) && number == 0, + _ => false + }; + } + + private static bool IsDependencyConditionApplied( + object? dependencyValue, + string dependencyName, + Dictionary services) + { + if (dependencyValue is not Dictionary options || + !TryGetValue(options, "condition", out var conditionValue) || + conditionValue is not string condition || + !TryGetValue(services, dependencyName, out var dependency) || + dependency is not Dictionary dependencyService) + return false; + + var hasHealthcheck = TryGetValue(dependencyService, "healthcheck", out var healthcheckValue) && + healthcheckValue is Dictionary healthcheck && + !(TryGetValue(healthcheck, "disable", out var disabled) && IsTrue(disabled)) && + TryGetValue(healthcheck, "test", out var test) && IsMeaningful(test) && + !IsDisabledHealthcheckTest(test); + var healthcheckDisabled = TryGetValue(dependencyService, "healthcheck", out healthcheckValue) && + healthcheckValue is Dictionary explicitHealthcheck && + ((TryGetValue(explicitHealthcheck, "disable", out var disableValue) && IsTrue(disableValue)) || + (TryGetValue(explicitHealthcheck, "test", out var explicitTest) && + IsDisabledHealthcheckTest(explicitTest))); + return options.Count == 1 && (condition switch + { + "service_healthy" => hasHealthcheck, + "service_started" => healthcheckDisabled, + _ => false + }); } + private static bool IsTrue(object? value) => value switch + { + bool boolean => boolean, + string text => bool.TryParse(text, out var parsed) && parsed, + _ => false + }; + + private static bool IsDisabledHealthcheckTest(object? value) => value switch + { + List tests => string.Equals(tests.FirstOrDefault()?.ToString(), "NONE", StringComparison.Ordinal), + _ => false + }; + + /// + /// Selects a resource diagnostic path from unapplied options, retaining the resource path + /// when those options share its source. Default values and extensions cannot change attribution. + /// + private static string? GetResourceDiagnosticPath( + string section, string path, object? definition, ValidationContext context) + { + if (definition is not Dictionary options) + return IsMeaningful(definition) ? path : null; + + var source = context.SourceFor(path); + var hasUnappliedOption = false; + foreach (var (key, value) in options) + { + if (IsExtensionField(key) || + (Equals(key.ToString(), "external") && IsFalse(value)) || + (section == "networks" && Equals(key.ToString(), "driver") && Equals(value, "bridge")) || + !IsMeaningful(value)) + continue; + + hasUnappliedOption = true; + var optionPath = GetUnsupportedFieldDiagnosticPath(context, AppendPathSegment(path, key.ToString()!), value); + if (context.SourceFor(optionPath) != source) + return optionPath; + } + + return hasUnappliedOption ? path : null; + } + + private static bool IsMeaningfulWithoutExtensions(object? value) => value switch + { + Dictionary map => map.Any(option => + !IsExtensionField(option.Key) && IsMeaningful(option.Value)), + _ => IsMeaningful(value) + }; + + private static bool IsExtensionField(object key) => + (key.ToString() ?? string.Empty).StartsWith("x-", StringComparison.Ordinal); + + private static bool IsFalse(object? value) => value switch + { + bool boolean => !boolean, + string text => bool.TryParse(text, out var parsed) && !parsed, + _ => false + }; + + private static void AddUnappliedProjectResourceDiagnostics( + Dictionary root, + string section, + ValidationContext context, + List diagnostics) + { + if (!TryGetValue(root, section, out var value) || value is not Dictionary resources) + return; + + foreach (var (name, definition) in resources) + { + var path = GetResourceDiagnosticPath(section, AppendPathSegment(section, name.ToString()!), definition, context); + if (path is not null) + AddDiagnostic(diagnostics, context, path, null, + "unsupported-field", "Project resources are named in the loaded model but are not provisioned or mounted."); + } + } + + private static void AddDiagnostic( + List diagnostics, + ValidationContext context, + string path, + string? serviceName, + string code, + string message) + { + diagnostics.Add(new ComposeProjectDiagnostic(context.SourceFor(path), path, serviceName, code, message)); + } + + private static bool IsMeaningful(object? value) => value switch + { + null => false, + bool boolean => boolean, + string text => text.Length > 0, + Dictionary map => map.Values.Any(IsMeaningful), + List list => list.Any(IsMeaningful), + _ => true + }; + /// A deserialized YAML document with its source provenance map. /// Deserialized YAML mapping tree. /// Property path to source Compose file mapping for diagnostics. @@ -1317,8 +1838,7 @@ private static (string? Service, string? File) ParseExtends(Dictionary - /// Reads depends_on as a name list or a mapping. Mapping form keeps the keys only: - /// per-dependency conditions are modeled but not yet acted upon by the engine. + /// Reads depends_on as a name list or a mapping, retaining dependency names only. /// private static List ReadDependsOn(Dictionary map) { @@ -1379,8 +1899,12 @@ private static string ParseDotEnvValue(string rawValue) } /// - /// Flattens the inline environment block (list or mapping form) to KEY=VALUE strings. + /// Flattens the inline environment block, preserving list entries and converting mapping entries to KEY=VALUE strings. /// + /// + /// Bare list names remain unresolved and null mapping values become empty assignments. + /// These passthrough forms produce partial-support diagnostics rather than inheriting process values. + /// private static List ReadEnvironment(Dictionary map) { if (!map.TryGetValue("environment", out var value) || value is null) return []; @@ -1437,9 +1961,9 @@ private static ComposePort ParsePort(string value) // test: list form is exec/CMD as written; scalar form is shell syntax and gets CMD-SHELL. var disabled = GetBool(healthMap, "disable") ?? false; - var test = GetStringList(healthMap, "test"); - if (test.Count == 0 && GetString(healthMap, "test") is { } testString) - test = ["CMD-SHELL", testString]; + IReadOnlyList test = TryGetValue(healthMap, "test", out var testValue) && testValue is string testString + ? ["CMD-SHELL", testString] + : GetStringList(healthMap, "test"); return new ComposeHealthcheck( disabled, test, diff --git a/src/ComposeSharp.Loader/Models/ComposeProject.cs b/src/ComposeSharp.Loader/Models/ComposeProject.cs index 7cbc4bd..26360b4 100644 --- a/src/ComposeSharp.Loader/Models/ComposeProject.cs +++ b/src/ComposeSharp.Loader/Models/ComposeProject.cs @@ -17,4 +17,21 @@ public sealed record ComposeProject( IReadOnlyList Networks, IReadOnlyList Secrets, IReadOnlyList Configs, - IReadOnlyDictionary Extensions); + IReadOnlyDictionary Extensions) +{ + /// Unsupported or partially supported fields found in the loaded Compose document. + public IReadOnlyList Diagnostics { get; init; } = []; +} + +/// Describes a Compose field the loader or engine cannot fully apply. +/// The Compose file that contributed the field. +/// The YAML path of the field. +/// The service name for a service-level field, or null for project-level fields. +/// Stable identifier for the diagnostic category. +/// A value-free description of the unsupported or partial behavior. +public sealed record ComposeProjectDiagnostic( + string SourceFile, + string PropertyPath, + string? ServiceName, + string Code, + string Message); diff --git a/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs b/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs index 5131964..8a3f09f 100644 --- a/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs +++ b/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs @@ -68,7 +68,7 @@ public async Task CreateAsync_AppliesRestartRetryCount(string restart, int retri } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -172,7 +172,7 @@ public async Task UpAsync_IncludesSelectedDependenciesAndWaitsForTheirHealthchec } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -220,7 +220,7 @@ public async Task UpAsync_ReportsBothServicesWhenDependencyReadinessTimesOut() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -288,10 +288,10 @@ await Assert.ThrowsAsync(() => service.StartAsync( } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { - try { await service.DownAsync(blockerContext); } + try { await DownAndAssertNoProjectNetworksAsync(service, blockerContext); } finally { Directory.Delete(directory, recursive: true); @@ -347,7 +347,7 @@ public async Task StartAsync_DoesNotRestartRetainedOneOffDependencyContainers() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -409,7 +409,7 @@ public async Task StartAsync_UsesExistingContainerHealthcheckConfiguration() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -466,7 +466,7 @@ public async Task UpAsyncAndCreateAsync_PreserveRetainedOneOffDependencyContaine } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -529,7 +529,7 @@ public async Task StartAsync_DoesNotTreatPausedDependencyAsReady() } catch (DockerApiException) { } } - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -636,7 +636,7 @@ public async Task ListAsync_DerivesStatusFromAllProjectContainersAndHonorsAll() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -754,7 +754,7 @@ RUN printf '%s\n' "$MESSAGE" > /message } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { using var client = new DockerClientFactory().CreateClient(); @@ -877,7 +877,7 @@ public async Task LifecycleOperations_ApplyProfilesAndAllowExplicitServiceSelect } finally { - try { await service.DownAsync(profileContext); } + try { await DownAndAssertNoProjectNetworksAsync(service, profileContext); } finally { Directory.Delete(directory, recursive: true); } } } @@ -983,7 +983,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "compose.yaml"), """ } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1073,7 +1073,7 @@ public async Task LifecycleOperations_DoNothing_WhenProfileSelectionIsEmpty() } finally { - try { await service.DownAsync(profileContext); } + try { await DownAndAssertNoProjectNetworksAsync(service, profileContext); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1129,7 +1129,7 @@ public async Task CommitAsync_CreatesImageThroughDockerEngine() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { using var client = new DockerClientFactory().CreateClient(); @@ -1199,7 +1199,7 @@ public async Task PublishAsync_ReportsTagAndPushOutcomesForEverySelectedService( } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { using var client = new DockerClientFactory().CreateClient(); @@ -1273,7 +1273,7 @@ await client.Images.CreateImageAsync( } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { using var client = new DockerClientFactory().CreateClient(); @@ -1327,7 +1327,7 @@ public async Task TopAsync_ReturnsProcessRows_ForRunningServiceContainers() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1373,7 +1373,7 @@ public async Task TopAsync_FiltersByServiceSelection() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1440,7 +1440,7 @@ public async Task EventsAsync_StreamsDockerLifecycleEvents() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1496,7 +1496,7 @@ public async Task EventsAsync_HonorsCancellation() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1566,7 +1566,7 @@ public async Task EventsAsync_FiltersByServiceSelection() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1637,7 +1637,7 @@ public async Task EventsAsync_PreservesProjectEventsWithoutServiceFilter() } finally { - try { await service.DownAsync(context); } + try { await DownAndAssertNoProjectNetworksAsync(service, context with { Profiles = ["debug"] }); } finally { Directory.Delete(directory, recursive: true); } } } @@ -1651,6 +1651,24 @@ private static async Task AwaitSubscriptionAsync(TaskCompletionSource subscribed await collector; } + private static async Task DownAndAssertNoProjectNetworksAsync(ComposeService service, ComposeProjectContext context) + { + await service.DownAsync(context); + using var client = new DockerClientFactory().CreateClient(context.SocketPath); + var networks = await client.Networks.ListNetworksAsync(new NetworksListParameters + { + Filters = new Dictionary> + { + ["label"] = new Dictionary + { + [$"com.docker.compose.project={context.ProjectName}"] = true + } + } + }); + + Assert.Empty(networks); + } + // Collects build statuses and container log lines for assertions. private sealed class TestLogConsumer : ILogConsumer { diff --git a/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs b/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs index 4c0627b..c4312d3 100644 --- a/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs +++ b/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs @@ -1,6 +1,9 @@ // Rider inspection rationale: The inline lambda keeps the local assertion or callback context visible. // ReSharper disable ConvertClosureToMethodGroup using ComposeSharp.Loader; +using ComposeSharp.Api; +using ComposeSharp.Engine; +using ComposeSharp.Loader.Models; using YamlDotNet.Core; namespace ComposeSharp.Tests; @@ -89,6 +92,687 @@ public void Load_BracketedIpv6PortMapping_IsAccepted() Assert.Equal("80/tcp", port.ContainerPort); } + [Fact] + public void Load_UnsupportedFields_ReturnsValueFreeDiagnosticsWithSourceAndPath() + { + using var files = new ComposeFiles( + "mystery: hidden-project-value\nservices:\n api:\n image: app\n logging:\n driver: private-driver\n access_token: hidden-service-value\n"); + + var project = (ComposeProject)files.Load(); + + (string SourceFile, string PropertyPath, string? ServiceName, string Code)[] expected = + [ + (files.PrimaryPath, "mystery", null, "unsupported-field"), + (files.PrimaryPath, "services.api.logging", "api", "unsupported-field"), + (files.PrimaryPath, "services.api.access_token", "api", "unsupported-field") + ]; + Assert.Equal( + expected, + project.Diagnostics.Select(diagnostic => + (diagnostic.SourceFile, diagnostic.PropertyPath, diagnostic.ServiceName, diagnostic.Code))); + Assert.DoesNotContain(project.Diagnostics, diagnostic => + diagnostic.Message.Contains("hidden", StringComparison.Ordinal)); + } + + [Fact] + public void Load_PartialFields_ReturnsDiagnosticsAndLeavesSupportedDefaultsQuiet() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n privileged: false\n ports:\n - '127.0.0.1:8080:80'\n db:\n image: database\n"); + + var project = (ComposeProject)files.Load(); + + var diagnostic = Assert.Single(project.Diagnostics); + Assert.Equal("services.api.ports[0]", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.DoesNotContain("127.0.0.1", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_NormalizesScalarHealthcheckAndDiagnosesScalarCommands() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n command: echo ready\n entrypoint: /bin/sh\n healthcheck:\n test: echo healthy\n"); + + var project = new ComposeFileLoader().Load(files.DirectoryPath, Path.GetFileName(files.PrimaryPath)); + + Assert.Equal(["CMD-SHELL", "echo healthy"], Assert.Single(project.Services).Healthcheck!.Test); + Assert.Equal( + ["services.api.command", "services.api.entrypoint"], + project.Diagnostics.Select(diagnostic => diagnostic.PropertyPath)); + } + + [Fact] + public void Load_MultipleServiceNetworksAreDiagnosedButSingleNetworkIsQuiet() + { + using var files = new ComposeFiles( + "services:\n list:\n image: app\n networks: [front, back]\n mapping:\n image: app\n networks:\n front: null\n back: null\n single:\n image: app\n networks: [front]\n"); + + var project = (ComposeProject)files.Load(); + + Assert.Equal( + ["services.list.networks", "services.mapping.networks"], + project.Diagnostics.Select(diagnostic => diagnostic.PropertyPath)); + } + + [Theory] + [InlineData("networks:\n front: {}\n", "", true)] + [InlineData("networks:\n front: {}\n", "networks: []", true)] + [InlineData("networks:\n front: {}\n", "networks: {}", true)] + [InlineData("networks:\n front: {}\n", "networks: null", true)] + [InlineData("networks:\n front: {}\n", "network_mode: null", true)] + [InlineData("networks:\n front: {}\n default: {}\n", "", true)] + [InlineData("", "", false)] + [InlineData("networks: {}\n", "", false)] + [InlineData("networks:\n default: {}\n front: {}\n", "", false)] + [InlineData("networks:\n Default: {}\n", "", false)] + [InlineData("networks:\n front: {}\n", "networks: [front]", false)] + [InlineData("networks:\n front: {}\n", "networks: {front: null}", false)] + [InlineData("networks:\n front: {}\n", "network_mode: host", false)] + [InlineData("networks:\n front: {}\n", "network_mode: none", false)] + [InlineData("networks:\n front: {}\n", "network_mode: ''", false)] + public async Task Load_ImplicitNetworkFallbackMatchesEngineSelection( + string projectNetworks, string serviceNetwork, bool expectDiagnostic) + { + using var files = new ComposeFiles( + $"{projectNetworks}services:\n api:\n image: app\n {serviceNetwork}\n"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "implicit-network-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + SocketPath = "npipe://./pipe/compose-sharp-validation-test-missing", + ValidationMode = ComposeValidationMode.Strict + }; + var diagnostics = ((ComposeProject)files.Load()).Diagnostics; + + if (expectDiagnostic) + { + var diagnostic = Assert.Single(diagnostics); + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.Equal("services.api.networks", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.DoesNotContain("front", diagnostic.Message, StringComparison.Ordinal); + var exception = await Assert.ThrowsAsync(() => service.UpAsync(context)); + Assert.Equal(diagnostic.PropertyPath, exception.PropertyPath); + } + else + { + Assert.Empty(diagnostics); + Assert.Empty(service.LoadProject(context).Diagnostics); + } + } + + [Fact] + public void Load_BuildPrivilegedIsDiagnosedAsNotApplied() + { + using var files = new ComposeFiles("services:\n api:\n build:\n context: .\n privileged: true\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.api.build.privileged", diagnostic.PropertyPath); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.Contains("retained but not applied", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_BuildExtensionFieldsAreIgnoredByDiagnostics() + { + using var files = new ComposeFiles( + "services:\n api:\n build:\n context: .\n x-cache-key: local\n"); + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + } + + [Fact] + public void Load_NestedExtensionFieldsAreIgnoredByDiagnostics() + { + using var files = new ComposeFiles( + "volumes:\n data:\n x-volume-note: local\nnetworks:\n front:\n x-network-note: local\nsecrets:\n credential:\n x-secret-note: local\nconfigs:\n settings:\n x-config-note: local\nservices:\n api:\n image: app\n deploy:\n x-rollout-note: local\n networks:\n front:\n x-attachment-note: local\n"); + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + } + + [Fact] + public void Load_StringBooleanDefaultsAreQuiet() + { + using var files = new ComposeFiles( + "services:\n api:\n build:\n context: .\n privileged: \"false\"\n oom_kill_disable: \"false\"\n"); + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + } + + [Theory] + [InlineData(false, "private-process-value")] + [InlineData(true, "private-process-value")] + [InlineData(false, null)] + [InlineData(true, null)] + public async Task Load_EnvironmentPassthroughIsDiagnosedBeforeDockerAccess(bool mapping, string? processValue) + { + var name = $"PASSTHROUGH_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(name, processValue); + try + { + var environment = mapping ? $"{{{name}: null}}" : $"[{name}]"; + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n environment: {environment}\n"); + var project = (ComposeProject)files.Load(); + var diagnostic = Assert.Single(project.Diagnostics); + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.Equal(mapping ? $"services.api.environment.{name}" : "services.api.environment[0]", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.DoesNotContain("private-process-value", diagnostic.Message, StringComparison.Ordinal); + Assert.Equal([mapping ? $"{name}=" : name], Assert.Single(project.Services).Environment); + Assert.Equal(processValue, Environment.GetEnvironmentVariable(name)); + + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "environment-passthrough-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + SocketPath = "npipe://./pipe/compose-sharp-validation-test-missing", + ValidationMode = ComposeValidationMode.Strict + }; + var exception = await Assert.ThrowsAsync(() => service.UpAsync(context)); + Assert.Equal(diagnostic.PropertyPath, exception.PropertyPath); + Assert.DoesNotContain("private-process-value", exception.Message, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [Theory] + [InlineData("[API_TOKEN=]")] + [InlineData("[API_TOKEN=private=explicit]")] + [InlineData("{API_TOKEN: ''}")] + [InlineData("{API_TOKEN: explicit}")] + [InlineData("{ENABLED: false}")] + [InlineData("{COUNT: 0}")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public void Load_ExplicitEnvironmentAssignmentsRemainQuiet(string environment) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n environment: {environment}\n"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "explicit-environment-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + ValidationMode = ComposeValidationMode.Strict + }; + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + Assert.Empty(service.LoadProject(context).Diagnostics); + } + + [Theory] + [InlineData("[EXPLICIT=value]", "[API_TOKEN]", "services.api.environment[1]")] + [InlineData("{EXPLICIT: value}", "{'access.token': null}", "services.api.environment[\"access.token\"]")] + public void LoadMerged_EnvironmentPassthroughDiagnosticUsesOverlayProvenance( + string baseEnvironment, string overlayEnvironment, string expectedPath) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n environment: {baseEnvironment}\n", + $"services:\n api:\n environment: {overlayEnvironment}\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal(expectedPath, diagnostic.PropertyPath); + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + } + + [Fact] + public void Load_LongFormEnvironmentFileIsDiagnosed() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n env_file:\n - path: ./app.env\n required: true\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.api.env_file[0]", diagnostic.PropertyPath); + Assert.Equal("partially-supported-field", diagnostic.Code); + } + + [Fact] + public void Load_MultipleBuildPlatformsAreDiagnosed() + { + using var files = new ComposeFiles( + "services:\n api:\n build:\n context: .\n platforms: [linux/amd64, linux/arm64]\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.api.build.platforms", diagnostic.PropertyPath); + Assert.Equal("partially-supported-field", diagnostic.Code); + } + + [Fact] + public void Load_DuplicateContainerPortMappingsAreDiagnosed() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n ports: [\"8080:80\", \"8081:80\"]\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.api.ports[1]", diagnostic.PropertyPath); + Assert.Equal("partially-supported-field", diagnostic.Code); + } + + [Fact] + public void Load_ServicePlatformIsAppliedAsBuildFallback() + { + using var files = new ComposeFiles( + "services:\n built:\n build: .\n platform: linux/amd64\n image_only:\n image: app\n platform: linux/arm64\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.image_only.platform", diagnostic.PropertyPath); + Assert.Equal("unsupported-field", diagnostic.Code); + } + + [Fact] + public void Load_LongFormServiceVolumeIsDiagnosed() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n volumes:\n - type: bind\n source: ./data\n target: /data\n networks:\n first: {}\n second: {}\n"); + + var diagnostics = ((ComposeProject)files.Load()).Diagnostics; + + Assert.Contains(diagnostics, diagnostic => diagnostic is + { PropertyPath: "services.api.volumes[0]", Code: "partially-supported-field" }); + Assert.Contains(diagnostics, diagnostic => diagnostic is + { PropertyPath: "services.api.networks", Code: "partially-supported-field" }); + } + + [Theory] + [InlineData(@"C:\data:/data")] + [InlineData(@"d:\data:/data:ro")] + [InlineData("C:/data:/data")] + [InlineData("d:/data:/data:rw")] + public void Load_WindowsDriveVolumeSourcesAreDiagnosed(string volume) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n volumes:\n - '{volume}'\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.Equal("services.api.volumes[0]", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.DoesNotContain(volume, diagnostic.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("/host/data:/data:ro")] + [InlineData("./data:/data")] + [InlineData("data:/data")] + [InlineData("c:/data")] + [InlineData("/data")] + public void Load_PosixAndNamedVolumeSourcesRemainQuiet(string volume) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n volumes:\n - '{volume}'\n"); + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + } + + [Fact] + public void Load_UnappliedBuildFieldsAreDiagnosed() + { + using var files = new ComposeFiles( + "services:\n api:\n build:\n context: .\n dockerfile_inline: FROM scratch\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.Load()).Diagnostics); + + Assert.Equal("services.api.build.dockerfile_inline", diagnostic.PropertyPath); + Assert.Equal("unsupported-field", diagnostic.Code); + } + + [Fact] + public void Load_DependencyConditionsAccountForInheritedHealthchecks() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n depends_on:\n healthy:\n condition: service_healthy\n started:\n condition: service_started\n disabled:\n condition: service_started\n completed:\n condition: service_completed_successfully\n healthy:\n image: db\n healthcheck:\n test: [CMD, true]\n started:\n image: cache\n disabled:\n image: cache\n healthcheck:\n disable: true\n"); + + var diagnostics = ((ComposeProject)files.Load()).Diagnostics; + + Assert.Equal( + ["services.api.depends_on.started", "services.api.depends_on.completed"], + diagnostics.Select(diagnostic => diagnostic.PropertyPath)); + Assert.All(diagnostics, diagnostic => Assert.Equal("partially-supported-field", diagnostic.Code)); + } + + [Theory] + [InlineData("test: NONE", "service_started", true)] + [InlineData("test: NONE", "service_healthy", false)] + [InlineData("test: [NONE]", "service_started", false)] + [InlineData("test: [NONE]", "service_healthy", true)] + [InlineData("disable: true\n test: NONE", "service_started", false)] + [InlineData("disable: true\n test: NONE", "service_healthy", true)] + public void Load_DependencyConditionsDistinguishScalarNoneFromDisabledHealthchecks( + string healthcheck, string condition, bool expectDiagnostic) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n depends_on:\n db:\n condition: {condition}\n db:\n image: database\n healthcheck:\n {healthcheck}\n"); + + var project = (ComposeProject)files.Load(); + var dependency = Assert.Single(project.Services, service => service.Name == "db"); + string[] expectedTest = healthcheck.Contains("[NONE]", StringComparison.Ordinal) + ? ["NONE"] + : ["CMD-SHELL", "NONE"]; + Assert.Equal(expectedTest, dependency.Healthcheck!.Test); + if (expectDiagnostic) + { + var diagnostic = Assert.Single(project.Diagnostics); + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.Equal("services.api.depends_on.db", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + } + else + { + Assert.Empty(project.Diagnostics); + } + } + + [Fact] + public void Load_DefaultZeroValuesForUnsupportedFieldsAreQuiet() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n cpu_quota: 0\n oom_score_adj: 0\n"); + + Assert.Empty(((ComposeProject)files.Load()).Diagnostics); + } + + [Fact] + public void LoadMerged_DependencyNameUsesEscapedPathAndOverlayProvenance() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n depends_on:\n db: {}\n db:\n image: database\n db.prod:\n image: database\n", + "services:\n api:\n depends_on:\n db.prod:\n condition: service_completed_successfully\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("services.api.depends_on[\"db.prod\"]", diagnostic.PropertyPath); + } + + [Fact] + public void LoadMerged_UnsupportedMergedMapDiagnosticUsesMeaningfulOverlayProvenance() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n logging: {}\n", + "services:\n api:\n logging:\n driver: json-file\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("services.api.logging.driver", diagnostic.PropertyPath); + } + + [Fact] + public void LoadMerged_UnknownDottedServiceFieldUsesOverlayProvenance() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n", + "services:\n api:\n access.token: hidden\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("services.api[\"access.token\"]", diagnostic.PropertyPath); + Assert.Equal("api", diagnostic.ServiceName); + } + + [Fact] + public void Load_ResourceDefinitionsWithOnlyDefaultValuesAreQuiet() + { + using var files = new ComposeFiles( + "volumes:\n data:\n external: false\nnetworks:\n back:\n external: false\nservices:\n api:\n image: app\n networks: [back]\n"); + + var project = (ComposeProject)files.Load(); + + Assert.Empty(project.Diagnostics); + } + + [Theory] + [InlineData("networks", "driver: bridge", null)] + [InlineData("networks", "driver: bridge\n external: false\n x-note: local", null)] + [InlineData("networks", "driver: overlay", "partially-supported-field")] + [InlineData("networks", "driver: bridge\n external: true", "partially-supported-field")] + [InlineData("networks", "driver: bridge\n driver_opts:\n custom: value", "partially-supported-field")] + [InlineData("networks", "driver: bridge\n internal: true", "partially-supported-field")] + [InlineData("volumes", "driver: bridge", "partially-supported-field")] + [InlineData("secrets", "driver: bridge", "unsupported-field")] + [InlineData("configs", "driver: bridge", "unsupported-field")] + public void Load_ResourceDriverDefaultsAreScopedToNetworks( + string section, string definition, string? expectedCode) + { + var networkAttachment = section == "networks" ? " networks: [front]\n" : string.Empty; + using var files = new ComposeFiles( + $"{section}:\n front:\n {definition}\nservices:\n api:\n image: app\n{networkAttachment}"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "resource-default-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + ValidationMode = ComposeValidationMode.Strict + }; + var diagnostics = ((ComposeProject)files.Load()).Diagnostics; + + if (expectedCode is null) + { + Assert.Empty(diagnostics); + Assert.Empty(service.LoadProject(context).Diagnostics); + } + else + { + var diagnostic = Assert.Single(diagnostics); + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.Equal($"{section}.front", diagnostic.PropertyPath); + Assert.Null(diagnostic.ServiceName); + Assert.Equal(expectedCode, diagnostic.Code); + var exception = Assert.Throws(() => service.LoadProject(context)); + Assert.Equal(diagnostic.PropertyPath, exception.PropertyPath); + } + } + + [Fact] + public void LoadMerged_ResourceDiagnosticEscapesNameAndUsesOverlayProvenance() + { + using var files = new ComposeFiles( + "networks:\n front: {}\nservices:\n api:\n image: app\n networks: [front]\n", + "networks:\n front.prod:\n driver: overlay\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("networks[\"front.prod\"]", diagnostic.PropertyPath); + } + + [Theory] + [InlineData("networks", "{}", "{internal: true}", ".internal", "partially-supported-field")] + [InlineData("networks", "{driver: bridge}", "{internal: true}", ".internal", "partially-supported-field")] + [InlineData("networks", "{driver_opts: {}}", "{driver_opts: {'access.token': private-option-value}}", ".driver_opts[\"access.token\"]", "partially-supported-field")] + [InlineData("volumes", "{driver_opts: {}}", "{driver_opts: {'access.token': private-option-value}}", ".driver_opts[\"access.token\"]", "partially-supported-field")] + [InlineData("secrets", "{}", "{file: private-option-value}", ".file", "unsupported-field")] + [InlineData("configs", "{}", "{content: private-option-value}", ".content", "unsupported-field")] + public void LoadMerged_ResourceOptionDiagnosticUsesMeaningfulOverlayProvenance( + string section, string baseDefinition, string overlayDefinition, string expectedSuffix, string expectedCode) + { + var networkAttachment = section == "networks" ? " networks: ['front.prod']\n" : string.Empty; + using var files = new ComposeFiles( + $"{section}:\n 'front.prod': {baseDefinition}\nservices:\n api:\n image: app\n{networkAttachment}", + $"{section}:\n 'front.prod': {overlayDefinition}\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal($"{section}[\"front.prod\"]{expectedSuffix}", diagnostic.PropertyPath); + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Null(diagnostic.ServiceName); + Assert.Equal(expectedCode, diagnostic.Code); + Assert.DoesNotContain("private-option-value", diagnostic.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("networks", "{internal: true}", "{driver: bridge, external: false, x-note: private-option-value}")] + [InlineData("volumes", "{driver_opts: {type: local}}", "{external: false, x-note: private-option-value}")] + public void LoadMerged_DefaultOverlayOptionsDoNotChangeResourceDiagnosticSource( + string section, string baseDefinition, string overlayDefinition) + { + var networkAttachment = section == "networks" ? " networks: [front]\n" : string.Empty; + using var files = new ComposeFiles( + $"{section}:\n front: {baseDefinition}\nservices:\n api:\n image: app\n{networkAttachment}", + $"{section}:\n front: {overlayDefinition}\n"); + + var diagnostic = Assert.Single(((ComposeProject)files.LoadMerged()).Diagnostics); + + Assert.Equal($"{section}.front", diagnostic.PropertyPath); + Assert.Equal(files.PrimaryPath, diagnostic.SourceFile); + Assert.DoesNotContain("private-option-value", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public void LoadMerged_DefaultOnlyResourceOptionsRemainQuiet() + { + using var files = new ComposeFiles( + "networks:\n front: {driver: bridge}\nservices:\n api:\n image: app\n networks: [front]\n", + "networks:\n front: {external: false, x-note: private-option-value}\n"); + + Assert.Empty(((ComposeProject)files.LoadMerged()).Diagnostics); + } + + [Theory] + [InlineData("front.prod", "services.api.networks[\"front.prod\"]")] + [InlineData("front[prod]", "services.api.networks[\"front[prod]\"]")] + [InlineData("back", "services.api.networks.back")] + public void LoadMerged_ServiceNetworkDiagnosticEscapesNameAndUsesOverlayProvenance( + string networkName, string expectedPath) + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n networks:\n front: {}\n", + $"services:\n api:\n networks:\n '{networkName}':\n aliases: [private-alias]\n"); + + var diagnostics = ((ComposeProject)files.LoadMerged()).Diagnostics; + + Assert.Equal( + ["services.api.networks", expectedPath], + diagnostics.Select(diagnostic => diagnostic.PropertyPath)); + var diagnostic = diagnostics[1]; + Assert.Equal(Path.GetFullPath(files.OverlayPath!), diagnostic.SourceFile); + Assert.Equal("api", diagnostic.ServiceName); + Assert.Equal("partially-supported-field", diagnostic.Code); + Assert.DoesNotContain("private-alias", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task DirectDockerOperations_StrictModeRejectsDiagnosticsBeforeDockerAccess() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n logging:\n driver: json-file\n"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "strict-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + SocketPath = "npipe://./pipe/compose-sharp-validation-test-missing", + ValidationMode = ComposeValidationMode.Strict + }; + var copyPath = Path.Combine(files.DirectoryPath, "copy"); + var exportPath = Path.Combine(files.DirectoryPath, "export.tar"); + Func[] operations = + [ + () => service.ExecAsync(context, "api", new ComposeExecOptions { Command = ["true"] }), + () => service.AttachAsync(context, "api"), + () => service.CopyAsync(context, new ComposeCopyOptions { Source = "api:/tmp/file", Destination = copyPath }), + () => service.PortAsync(context, "api", 80), + () => service.ExportAsync(context, new ComposeExportOptions { Service = "api", OutputPath = exportPath }), + () => service.CommitAsync(context, new ComposeCommitOptions { Service = "api" }), + () => service.VolumesAsync(context) + ]; + + foreach (var operation in operations) + { + var exception = await Assert.ThrowsAsync(operation); + Assert.Equal("services.api.logging", exception.PropertyPath); + } + } + + [Fact] + public void Load_SecretDefinitionsNeverExposeSecretMaterialInDiagnostics() + { + using var files = new ComposeFiles( + "secrets:\n private_key:\n file: private-key-material\nservices:\n api:\n image: app\n secrets:\n - private_key\n environment:\n API_TOKEN: private-environment-token\n"); + + var project = (ComposeProject)files.Load(); + + Assert.NotEmpty(project.Diagnostics); + foreach (var diagnostic in project.Diagnostics) + { + Assert.DoesNotContain("private-key-material", diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain("private-environment-token", diagnostic.Message, StringComparison.Ordinal); + } + } + + [Fact] + public void LoadProject_ExposesDiagnosticsInPublicConfiguration() + { + using var files = new ComposeFiles( + "services:\n api:\n image: app\n logging:\n driver: json-file\n"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "diagnostic-summary", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath) + }; + + var config = service.LoadProject(context); + + var diagnostic = Assert.Single(config.Diagnostics); + Assert.Equal("services.api.logging", diagnostic.PropertyPath); + Assert.Equal("unsupported-field", diagnostic.Code); + } + + [Theory] + [InlineData("access_token: hidden-service-value", "services.api.access_token")] + [InlineData(@"volumes: ['C:\data:/data']", "services.api.volumes[0]")] + [InlineData("volumes: ['d:/data:/data:ro']", "services.api.volumes[0]")] + [InlineData("depends_on:\n db:\n condition: service_started\n db:\n image: database\n healthcheck:\n test: NONE", "services.api.depends_on.db")] + public async Task Up_StrictModeRejectsUnsupportedFieldsBeforeDockerAccess(string properties, string expectedPath) + { + using var files = new ComposeFiles( + $"services:\n api:\n image: app\n {properties}\n"); + var service = new ComposeService(); + var context = new ComposeProjectContext + { + ProjectName = "strict-validation", + WorkingDirectory = files.DirectoryPath, + ComposeFileName = Path.GetFileName(files.PrimaryPath), + SocketPath = "npipe://./pipe/compose-sharp-validation-test-missing", + ValidationMode = ComposeValidationMode.Strict + }; + + var exception = await Assert.ThrowsAsync(() => service.UpAsync(context)); + + Assert.Equal(expectedPath, exception.PropertyPath); + Assert.DoesNotContain("hidden-service-value", exception.Message, StringComparison.Ordinal); + } + [Theory] [InlineData("healthcheck:\n interval: eventually", "services.api.healthcheck.interval")] [InlineData("stop_grace_period: eventually", "services.api.stop_grace_period")]