Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ runs without configuration; the rest are `off` until you enable them:
| --- | --- | --- |
| [`correctness`](https://bare-devcontainer.github.io/decolint/rules/#correctness) | `error` | 13 |
| [`security`](https://bare-devcontainer.github.io/decolint/rules/#security) | `off` | 11 |
| [`reproducibility`](https://bare-devcontainer.github.io/decolint/rules/#reproducibility) | `off` | 4 |
| [`reproducibility`](https://bare-devcontainer.github.io/decolint/rules/#reproducibility) | `off` | 9 |
| [`style`](https://bare-devcontainer.github.io/decolint/rules/#style) | `off` | 2 |
<!-- /decolint:categories -->

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/spf13/pflag v1.0.10
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
oras.land/oras-go/v2 v2.6.2
Expand Down Expand Up @@ -200,7 +201,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
gocloud.dev v0.45.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
Expand Down
179 changes: 179 additions & 0 deletions rules/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package rules

import (
"path"
"strings"

"github.com/bare-devcontainer/decolint/linter"
"github.com/tailscale/hujson"
"go.yaml.in/yaml/v3"
)

// composeSource is what the Compose service a dev container runs is made from: the image it pulls,
// or the build that produces one. At most one is set; neither is when the service names an image
// this cannot resolve (see [composeServiceSource]).
type composeSource struct {
image string
build *composeBuild
}

// composeBuild is a Compose service's "build", reduced to what a rule reading its Dockerfile needs.
// Exactly one of dockerfile and inline is set.
type composeBuild struct {
// dockerfile is the Dockerfile's path, relative to the directory being linted.
dockerfile string
// inline is the Dockerfile's content, for a build that gives it as "dockerfile_inline".
inline string
// target is the stage "target" names, empty when it names none.
target string
}

// composeFilePaths returns the Compose file paths obj declares, with the byte offset of the value
// declaring them. The property is a single path or an array of paths, later ones overriding earlier
// ones; the merge reads the same property in feature's composeFilePaths.
func composeFilePaths(obj *hujson.Object) (paths []string, offset int, ok bool) {
m := memberNamed(obj, "dockerComposeFile")
if m == nil {
return nil, 0, false
}
switch v := m.Value.Value.(type) {
case hujson.Literal:
if v.Kind() != '"' {
return nil, 0, false
}
paths = []string{v.String()}
case *hujson.Array:
for _, e := range v.Elements {
lit, isLit := e.Value.(hujson.Literal)
if !isLit || lit.Kind() != '"' {
return nil, 0, false
}
paths = append(paths, lit.String())
}
default:
return nil, 0, false
}
return paths, m.Value.StartOffset, true
}

// composeService is the part of a Compose service definition that says what the service runs, or
// that the definition is not all in this file.
type composeService struct {
Image string `yaml:"image"`
// Build is untyped because Compose writes it two ways: the build context as a string, or an
// object of build options. See [composeServiceBuild].
Build any `yaml:"build"`
Extends any `yaml:"extends"`
}

// composeDoc is the part of a Compose file that defines the services, or pulls definitions in from
// files of its own.
type composeDoc struct {
Services map[string]composeService `yaml:"services"`
Include any `yaml:"include"`
}

// composeServiceSource returns what the named Compose service is made from, reading the files at
// paths in the order they are declared, each later one overriding the earlier ones as Compose merges
// them.
//
// This reads the declared files and nothing else, which is narrower than the resolution the merge
// performs through compose-go (see feature's loadComposeService: it applies "extends" and "include"
// and interpolates variables, reading files outside the linted directory and an environment a rule
// does not have). ok is therefore false for everything this cannot settle from the files
// themselves, so that what it does report is what the full resolution would report too:
//
// - a file that cannot be read (see [readConfigFile]) or does not parse;
// - a file declaring "include", or a service declaring "extends", either of which can define or
// override the service from a file not named here;
// - a service none of the files defines;
// - a service more than one file gives a "build", which Compose merges option by option.
//
// A service whose image or build context is written with a variable resolves to neither an image nor
// a build: the value comes from the environment. The same is true of a build context naming a remote
// repository, which is no path in the linted directory.
func composeServiceSource(dir linter.Dir, paths []string, service string) (composeSource, bool) {
var src composeSource
var found, built bool
for _, p := range paths {
data, ok := readConfigFile(dir, p)
if !ok {
return composeSource{}, false
}
var doc composeDoc
if err := yaml.Unmarshal(data, &doc); err != nil || doc.Include != nil {
return composeSource{}, false
}
svc, ok := doc.Services[service]
if !ok {
continue
}
found = true
if svc.Extends != nil {
return composeSource{}, false
}
if svc.Image != "" {
src.image = svc.Image
}
if svc.Build == nil {
continue
}
if built {
return composeSource{}, false
}
built = true
src.build = composeServiceBuild(svc.Build, path.Dir(p))
}
if !found {
return composeSource{}, false
}
if src.build != nil {
// The "image" of a service that builds names what the build produces, not what it starts
// from, so the build is the whole answer.
return composeSource{build: src.build}, true
}
// Both "${VAR}" and the bare "$VAR" Compose accepts leave the image unresolved here.
if strings.Contains(src.image, "$") {
src.image = ""
}
return src, true
}

// composeServiceBuild reads a service's "build" in either of the forms Compose writes it, resolving
// the Dockerfile against baseDir, the directory of the Compose file declaring the build, as Compose
// resolves it against the file it is written in. It returns nil for a build whose Dockerfile is not
// a path in the linted directory.
//
// The Dockerfile defaults to "Dockerfile" in the build context, and the context to the Compose
// file's own directory.
func composeServiceBuild(value any, baseDir string) *composeBuild {
var context, dockerfile, inline, target string
switch v := value.(type) {
case string:
// The short form is the build context alone.
context = v
case map[string]any:
context, _ = v["context"].(string)
dockerfile, _ = v["dockerfile"].(string)
inline, _ = v["dockerfile_inline"].(string)
target, _ = v["target"].(string)
default:
return nil
}

if inline != "" {
return &composeBuild{inline: inline, target: target}
}
// A context naming a remote repository, or one written as a variable, is no path the Dockerfile
// can be read through.
if strings.Contains(context, "://") || strings.Contains(context, "$") {
return nil
}
if dockerfile == "" {
dockerfile = "Dockerfile"
}
if strings.Contains(dockerfile, "$") {
return nil
}
return &composeBuild{dockerfile: path.Join(baseDir, context, dockerfile), target: target}
}
188 changes: 188 additions & 0 deletions rules/compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package rules

import (
"testing"
"testing/fstest"

"github.com/bare-devcontainer/decolint/linter"
)

// TestComposeServiceSource covers what a service is read as — an image, a build, or neither — since
// which one it is decides whether the Compose rule or the Dockerfile rules report on it.
func TestComposeServiceSource(t *testing.T) {
t.Parallel()

tests := []struct {
name string
files map[string]string
paths []string
wantOK bool
wantImage string
wantBuild *composeBuild
}{
{
name: "an image",
files: map[string]string{"docker-compose.yml": "services:\n app:\n image: ubuntu:24.04\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantImage: "ubuntu:24.04",
},
{
name: "a build in the long form",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{dockerfile: "Dockerfile"},
},
{
name: "a build in the short form defaults the Dockerfile",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build: .\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{dockerfile: "Dockerfile"},
},
{
name: "a build resolves against the Compose file's own directory",
files: map[string]string{"compose/docker-compose.yml": "services:\n app:\n build:\n context: ..\n dockerfile: build/Dockerfile\n"},
paths: []string{"compose/docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{dockerfile: "build/Dockerfile"},
},
{
name: "a build carries its target",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n target: dev\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{dockerfile: "Dockerfile", target: "dev"},
},
{
name: "an inline Dockerfile is its own content",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n dockerfile_inline: |\n FROM ubuntu:latest\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{inline: "FROM ubuntu:latest\n"},
},
{
name: "a build overrides an image",
files: map[string]string{"docker-compose.yml": "services:\n app:\n image: built:latest\n build: .\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: &composeBuild{dockerfile: "Dockerfile"},
},
{
name: "a later file overriding the image wins",
files: map[string]string{
"a.yml": "services:\n app:\n image: ubuntu:latest\n",
"b.yml": "services:\n app:\n image: ubuntu:24.04\n",
},
paths: []string{"a.yml", "b.yml"},
wantOK: true,
wantImage: "ubuntu:24.04",
},
{
// Compose merges a build option by option across files, which this does not model.
name: "a build declared by two files is not resolved",
files: map[string]string{
"a.yml": "services:\n app:\n build: .\n",
"b.yml": "services:\n app:\n build:\n target: dev\n",
},
paths: []string{"a.yml", "b.yml"},
wantOK: false,
},
{
name: "a context naming a repository is no path",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build: https://example.invalid/repo.git\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: nil,
},
{
name: "a context written as a variable is not resolved",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build: ${CONTEXT}\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: nil,
},
{
name: "a Dockerfile written as a variable is not resolved",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n dockerfile: ${DOCKERFILE}\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: nil,
},
{
// Compose writes a build as its context or as an object of options, and as neither of
// those a build says nothing about a Dockerfile.
name: "a build that is neither form is not resolved",
files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n - .\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantBuild: nil,
},
{
name: "an image written as a variable is not resolved",
files: map[string]string{"docker-compose.yml": "services:\n app:\n image: ubuntu:$TAG\n"},
paths: []string{"docker-compose.yml"},
wantOK: true,
wantImage: "",
},
{
name: "a service none of the files defines",
files: map[string]string{"docker-compose.yml": "services:\n web:\n image: ubuntu:24.04\n"},
paths: []string{"docker-compose.yml"},
wantOK: false,
},
{
name: "a service extending another",
files: map[string]string{"docker-compose.yml": "services:\n app:\n extends:\n service: base\n"},
paths: []string{"docker-compose.yml"},
wantOK: false,
},
{
name: "a file pulling in others",
files: map[string]string{"docker-compose.yml": "include:\n - other.yml\nservices:\n app:\n image: ubuntu:24.04\n"},
paths: []string{"docker-compose.yml"},
wantOK: false,
},
{
name: "a file that does not parse",
files: map[string]string{"docker-compose.yml": "services:\n app:\n image: [\n"},
paths: []string{"docker-compose.yml"},
wantOK: false,
},
{
name: "a missing file",
files: map[string]string{},
paths: []string{"docker-compose.yml"},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

fsys := fstest.MapFS{}
for name, content := range tt.files {
fsys[name] = &fstest.MapFile{Data: []byte(content)}
}
got, ok := composeServiceSource(linter.Dir{FS: fsys}, tt.paths, "app")
if ok != tt.wantOK {
t.Fatalf("composeServiceSource ok = %v, want %v", ok, tt.wantOK)
}
if !ok {
return
}
if got.image != tt.wantImage {
t.Errorf("image = %q, want %q", got.image, tt.wantImage)
}
switch {
case tt.wantBuild == nil && got.build != nil:
t.Errorf("build = %+v, want none", *got.build)
case tt.wantBuild != nil && got.build == nil:
t.Errorf("build = none, want %+v", *tt.wantBuild)
case tt.wantBuild != nil && *got.build != *tt.wantBuild:
t.Errorf("build = %+v, want %+v", *got.build, *tt.wantBuild)
}
})
}
}
Loading
Loading