Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions cmd/compose/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,9 @@ func TestFilterServices(t *testing.T) {
Links: []string{"bar"},
},
"bar": {
Name: "bar",
DependsOn: map[string]types.ServiceDependency{
Name: "bar", WorkloadSpec: types.WorkloadSpec{DependsOn: map[string]types.ServiceDependency{
"zot": {},
},
}},
},
"zot": {
Name: "zot",
Expand Down
21 changes: 18 additions & 3 deletions cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func imagesOnly(project *types.Project) *types.Project {
digests := types.Services{}
for name, config := range project.Services {
service := types.ServiceConfig{
Image: config.Image,
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
Expand All @@ -271,7 +271,22 @@ func imagesOnly(project *types.Project) *types.Project {
}
digests[name] = service
}
project = &types.Project{Services: digests}
var jobDigests types.Jobs
if len(project.Jobs) > 0 {
jobDigests = types.Jobs{}
}
for name, config := range project.Jobs {
job := types.JobConfig{
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
job.Volumes = append(job.Volumes, vol)
}
}
jobDigests[name] = job
}
project = &types.Project{Services: digests, Jobs: jobDigests}
return project
}

Expand Down Expand Up @@ -386,7 +401,7 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s
}
for _, hook := range preStartHooks(service) {
image, _ := hook["image"].(string)
config.PreStart = append(config.PreStart, types.ServiceHook{Image: image})
config.PreStart = append(config.PreStart, types.PreStartHook{ContainerSpec: types.ContainerSpec{Image: image}})
}
for _, volume := range imageVolumes(service) {
source, _ := volume["source"].(string)
Expand Down
29 changes: 17 additions & 12 deletions cmd/compose/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,17 @@ func TestImagesOnly(t *testing.T) {
Name: "test",
Services: types.Services{
"test": types.ServiceConfig{
Name: "test",
Image: "docker.io/library/nginx@" + testDigest,
Command: types.ShellCommand{"echo", "hello"},
Name: "test",

// hooks can't be overridden element-wise on merge, so the lock must not carry them
PreStart: []types.ServiceHook{{Image: "docker.io/library/hookimage@" + testDigest}},
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
{Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"},
PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "docker.io/library/hookimage@" + testDigest}}}, ContainerSpec: types.ContainerSpec{
Image: "docker.io/library/nginx@" + testDigest,
Command: types.ShellCommand{"echo", "hello"},

Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
{Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"},
},
},
},
},
Expand All @@ -154,9 +157,11 @@ func TestImagesOnly(t *testing.T) {
assert.DeepEqual(t, locked, &types.Project{
Services: types.Services{
"test": types.ServiceConfig{
Image: "docker.io/library/nginx@" + testDigest,
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
ContainerSpec: types.ContainerSpec{
Image: "docker.io/library/nginx@" + testDigest,
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
},
},
},
},
Expand All @@ -167,8 +172,8 @@ func TestWarnHooksNotLockable(t *testing.T) {
messages := captureWarnings(t, func() {
warnHooksNotLockable(&types.Project{
Services: types.Services{
"with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}},
"inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}},
"with-hook-image": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "alpine:latest"}}}},
"inline-hook": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"echo"}}}}},
"without-hook": types.ServiceConfig{},
},
})
Expand Down
5 changes: 5 additions & 0 deletions cmd/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac
}

func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error {
// same contract as up: an active scheduled job is refused before any
// resource is created — silently not scheduling would break expectations
if err := rejectScheduledJobs(project); err != nil {
return err
}
if err := createOpts.Apply(project); err != nil {
return err
}
Expand Down
23 changes: 10 additions & 13 deletions cmd/compose/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,18 @@ func TestApplyPlatforms_InferFromRuntime(t *testing.T) {
return &types.Project{
Services: types.Services{
"test": {
Name: "test",
Image: "foo",
Build: &types.BuildConfig{
Name: "test", ContainerSpec: types.ContainerSpec{
Image: "foo",

Platform: "alice/32",
}, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{
Context: ".",
Platforms: []string{
"linux/amd64",
"linux/arm64",
"alice/32",
},
},
Platform: "alice/32",
}},
},
},
}
Expand All @@ -75,15 +76,13 @@ func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) {
},
Services: types.Services{
"test": {
Name: "test",
Image: "foo",
Build: &types.BuildConfig{
Name: "test", ContainerSpec: types.ContainerSpec{Image: "foo"}, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{
Context: ".",
Platforms: []string{
"linux/amd64",
"linux/arm64",
},
},
}},
},
},
}
Expand All @@ -110,15 +109,13 @@ func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) {
},
Services: types.Services{
"test": {
Name: "test",
Image: "foo",
Build: &types.BuildConfig{
Name: "test", ContainerSpec: types.ContainerSpec{Image: "foo"}, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{
Context: ".",
Platforms: []string{
"linux/amd64",
"linux/arm64",
},
},
}},
},
},
}
Expand Down
15 changes: 6 additions & 9 deletions cmd/compose/pullOptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,19 @@ func TestApplyPullOptions(t *testing.T) {
project := &types.Project{
Services: types.Services{
"must-build": {
Name: "must-build",
Name: "must-build", WorkloadSpec:
// No image, local build only
Build: &types.BuildConfig{
types.WorkloadSpec{Build: &types.BuildConfig{
Context: ".",
},
}},
},
"has-build": {
Name: "has-build",
Image: "registry.example.com/myservice",
Build: &types.BuildConfig{
Name: "has-build", ContainerSpec: types.ContainerSpec{Image: "registry.example.com/myservice"}, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{
Context: ".",
},
}},
},
"must-pull": {
Name: "must-pull",
Image: "registry.example.com/another-service",
Name: "must-pull", ContainerSpec: types.ContainerSpec{Image: "registry.example.com/another-service"},
},
},
}
Expand Down
106 changes: 104 additions & 2 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package compose
import (
"context"
"errors"
"fmt"
"os"
"strings"

Expand Down Expand Up @@ -71,6 +72,11 @@ type runOptions struct {
}

func (options runOptions) apply(project *types.Project) (*types.Project, error) {
project, err := materializeManualJob(project, options.Service)
if err != nil {
return nil, err
}

if options.noDeps {
var err error
project, err = project.WithSelectedServices([]string{options.Service}, types.IgnoreDependencies)
Expand All @@ -83,7 +89,6 @@ func (options runOptions) apply(project *types.Project) (*types.Project, error)
if err != nil {
return nil, err
}

target.Tty = !options.noTty
target.StdinOpen = options.interactive

Expand Down Expand Up @@ -273,7 +278,23 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, p *ProjectOptions, service string) (*types.Project, error) {
project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution)
if err != nil {
return nil, err
// The run target may be a job — invisible to the service selector.
// Reload unselected, materialize the job as a service, and narrow to
// it, so the env resolution below sees the job like any selected
// service (its env_file resolves; unrelated services' env_file still
// doesn't need to exist). A target that is not a declared job keeps
// the original, precise selection error.
unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution)
if uerr != nil {
return nil, err
}
if _, isJob := unselected.AllJobs()[service]; !isJob {
return nil, err
}
project, err = materializeManualJob(unselected, service)
if err != nil {
return nil, err
}
}
project, err = project.WithServicesEnvironmentResolved(true)
if err != nil {
Expand Down Expand Up @@ -366,3 +387,84 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op
}
return err
}

// materializeManualJob lets run target a job exactly like a service: per the
// spec, any job can be triggered manually regardless of its automated
// triggers, unless it explicitly opts out with `triggers.manual: false`.
// A job is a ContainerSpec+WorkloadSpec — the same layers a service is made
// of — so it materializes as a service for the one-off machinery: its
// profile is activated and the project narrowed to its dependencies by
// WithSelectedJob, then the job joins Services under its own name.
func materializeManualJob(project *types.Project, name string) (*types.Project, error) {
// jobs and services share the depends_on namespace but not their own: a
// service with the target's name wins — it is what the service selector
// resolved — and a job already materialized must not be re-materialized
// (it would shed whatever resolution ran on it since).
if _, exists := project.Services[name]; exists {
return project, nil
}
job, ok := project.AllJobs()[name]
if !ok {
return project, nil
}
if job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual {
return nil, fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name)
}
project, err := project.WithSelectedJob(name)
if err != nil {
return nil, err
}
// A job may depend on other jobs: materialize the whole job closure so
// every depends_on reference resolves to a service — the dependency job
// runs through the exact machinery a service dependency does (a
// run-to-completion container satisfying its declared condition),
// instead of dangling as an unresolvable name.
jobs := project.AllJobs()
materializeJobClosure(project, jobs, job, map[string]bool{name: true})
project.Services[name] = jobAsService(project, name, job)
return project, nil
}

// materializeJobClosure adds every job reachable through job-typed
// depends_on edges to project.Services. seen carries the starting job and
// guards against dependency cycles.
func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.JobConfig, seen map[string]bool) {
for dep := range job.DependsOn {
if seen[dep] {
continue
}
seen[dep] = true
depJob, isJob := jobs[dep]
if !isJob {
continue
}
materializeJobClosure(project, jobs, depJob, seen)
project.Services[dep] = jobAsService(project, dep, depJob)
}
}

// jobAsService materializes a job as a service for the one-off machinery: a
// job is a ContainerSpec+WorkloadSpec, the same layers a service is made of.
// It carries the standard custom labels the loader stamps on every service —
// materialization happens after loading, so without them the containers
// created for a dependency job would be invisible to every label-driven
// path: start would silently skip them, ps/down would not see them, and the
// dependency wait would report the job as a missing dependency.
func jobAsService(project *types.Project, name string, job types.JobConfig) types.ServiceConfig {
svc := types.ServiceConfig{
Name: name,
Profiles: job.Profiles,
Extensions: job.Extensions,
ContainerSpec: job.ContainerSpec,
WorkloadSpec: job.WorkloadSpec,
}
svc.CustomLabels = types.Labels{
api.ProjectLabel: project.Name,
api.ServiceLabel: name,
api.VersionLabel: api.ComposeVersion,
api.WorkingDirLabel: project.WorkingDir,
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: "False",
}
return svc
}
Loading
Loading