Skip to content

schema: no constraint requires image, build or provider, but the loader rejects a service with none of them #933

Open

Description

@simin-yuan

Summary

schema/compose-spec.json validates a service that declares none of image, build or provider. loader/validate.go rejects that same service.

Minimal reproduction

bad.yml:

services:
  app:
    container_name: app
    restart: always

Validated against the official schema:

$ python -m check_jsonschema --schemafile compose-spec.json bad.yml
ok -- validation done

Loaded by compose-go:

$ kompose convert -f bad.yml -o /dev/null     # kompose depends on github.com/compose-spec/compose-go/v2 v2.10.0
FATA Unable to load files: service "app" has neither an image nor a build context specified: invalid compose project

Where the two disagree

loader/validate.go, in checkConsistency:

if s.Build == nil && s.Image == "" && s.Provider == nil {
    return fmt.Errorf("service %q has neither an image nor a build context specified: %w", s.Name, errdefs.ErrInvalid)
}

schema/compose-spec.json defines $defs/service as allOf over two refs:

[0] $ref -> container_spec   72 properties, has `image`, no `required`
[1] $ref -> workload_spec     7 properties, has `build`, no `required`

Neither ref carries required, and the enclosing object adds no anyOf/oneOf. So as far as the schema is concerned, image, build and provider are all optional and all may be absent.

The specification agrees with the loader, in 05-services.md:

image may be omitted from a Compose file as long as a build section is declared.

Why this matters in practice

This schema is the one editors apply. SchemaStore's catalog maps **/docker-compose.yml, **/compose.yaml and friends to this file, so VS Code, JetBrains and anything else with a JSON-schema language server validate against it while the file is being typed. The result is a compose file that the editor accepts and docker compose up rejects — the error arrives at run time instead of at write time, which is the failure mode the schema exists to prevent.

Suggested fix

Either express the constraint:

"anyOf": [
  {"required": ["image"]},
  {"required": ["build"]},
  {"required": ["provider"]}
]

or record it as a reviewed entry in the known-gaps list that loader/tests/completeness_test.go maintains, so that it is a decision rather than an omission. That test currently guarantees every service attribute has a declared test; this is about a relation between attributes, so it is outside what that test can catch.

I have not checked whether provider is expressible in the schema the same way image is, so treat that branch of the suggestion as unverified.

How it came up

I wrote a small mutation tester for validators — it mutates the inputs a checker accepts and reports which mutations get through. Run against JSON schemas, it deleted the image key from a file that validated, and the schema still accepted it. The tool is at https://github.com/simin-yuan/greencheck (MIT, no dependencies) if it is of use, but the reproduction above stands on its own.

Activity

  1. simin-yuan commented on Sep 29, 2026

    @simin-yuan
    Author

    Follow-up: I picked up the two branches I had left unverified in the report, and tested both against the schema as vendored in this repository.

    • provider is expressible the same way as the others. It is a declared property of $defs/service (line 926 of schema/compose-spec.json), with required: ["type"]. So a provider-only service is accepted by the schema today, and that anyOf branch closes a real case rather than a hypothetical one. Tested: services: {app: {provider: {type: docker}}} validates against the unmodified schema.
    • $defs/service is an object carrying allOf over container_spec plus workload_spec, with a sibling properties block. An anyOf next to that allOf applies as intended.

    Patch tested here against schema/compose-spec.json at blob e4e473bbca37c1fdbd7cfe957644c615b04f284b (main, 2026-09-29), inserted into $defs/service as a sibling of allOf:

    "anyOf": [
      { "required": ["image"] },
      { "required": ["build"] },
      { "required": ["provider"] },
      { "required": ["extends"] }
    ]

    extends is not padding. It is resolved before checkConsistency runs, so a service with only extends is legal whenever the base it inherits from supplies one of the other three. Dropping that branch would reject files the loader accepts, which is a worse outcome than the hole being fixed. A service whose base supplies nothing still fails at load time, so this closes the editor-side hole, not every input the loader rejects.

    Validation with check-jsonschema 0.38.2 (YAML input, so no conversion step):

    input schema as published with the patch
    services: {app: {container_name: app, restart: always}} ok rejected
    image only ok ok
    build only ok ok
    provider only ok ok
    extends only ok ok

    Regression run: all 42 YAML files under loader/testdata/ at main, validated twice, once with the unmodified schema and once with the patched one. 39 accepted by both, 3 rejected by both (they are fragments, not standalone documents), 0 verdict changes.

    Second gap in the same pair, same treatment. loader/validate.go lines 242-246:

    if secret.External { continue }
    if secret.File == "" && secret.Environment == "" { ... error ... }

    $defs/secret carries no required at all, so secrets: {s: {}} and secrets: {s: {name: custom}} validate while the loader rejects both. Tested form:

    "anyOf": [
      { "required": ["file"] },
      { "required": ["environment"] },
      { "required": ["external"], "properties": { "external": { "const": true } } }
    ]

    secrets: {s: {}} is ok before and rejected after; file, environment and external: true all validate both ways. The const is deliberate: external: false falls through the continue and still needs a source.

    What I did not do, so that nobody reads more into the tables than they say:

    • No Go test suite was run. schema/schema_test.go and the loader tests were not executed; the regression evidence above is schema against schema over YAML fixtures.
    • Explicit nulls: build: with no value parses to nil in Go and the loader rejects it, while {"required": ["build"]} accepts it. Closing that would need something like {"properties": {"build": {"not": {"type": "null"}}}, "required": ["build"]}, which I did not test.
    • I did not check whether a consumer relies on the schema accepting a sourceless service on purpose. If one does, this is an entry in the known-gaps list rather than a schema change.
    • schema/compose-spec.json here is a copy of the upstream file, so this may need to land in compose-spec/compose-spec first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions