Skip to content

Commit f8c4ce3

Browse files
committed
Add LoadProject method to Compose SDK API
This commit adds a new LoadProject method to the Compose service API, allowing SDK users to programmatically load Compose projects with full control over the loading process. Changes: 1. New API method (pkg/api/api.go): - LoadProject(ctx, ProjectLoadOptions) (*types.Project, error) - ProjectLoadOptions struct with all loader configuration - LoadListener callback for event notifications (metrics, etc.) - ProjectOptionsFns field for compose-go loader options 2. Implementation (pkg/compose/loader.go): - createRemoteLoaders: Git and OCI remote loader setup - buildProjectOptions: Translates ProjectLoadOptions to compose-go options - postProcessProject: Service filtering, labels, resource pruning 3. Unit test (pkg/compose/loader_test.go): - Tests basic project loading functionality - Verifies ProjectOptionsFns with cli.WithoutEnvironmentResolution 4. Mock update (pkg/mocks/mock_docker_compose_api.go): - Added LoadProject to mock interface Key design decisions: - LoadListener pattern keeps metrics collection in CLI, not SDK - ProjectOptionsFns exposes compose-go options directly (e.g., cli.WithInterpolation(false)) - Post-processing in SDK: labels, service filtering, resource pruning - Environment resolution NOT in SDK (command responsibility) - Compatibility mode handling (api.Separator)
1 parent fc74c78 commit f8c4ce3

File tree

4 files changed

+674
-136
lines changed

4 files changed

+674
-136
lines changed

pkg/api/api.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,53 @@ import (
2424
"strings"
2525
"time"
2626

27+
"github.com/compose-spec/compose-go/v2/cli"
2728
"github.com/compose-spec/compose-go/v2/types"
2829
"github.com/containerd/platforms"
2930
"github.com/docker/cli/opts"
3031
"github.com/docker/docker/api/types/volume"
3132
)
3233

34+
// LoadListener receives events during project loading.
35+
// Events include:
36+
// - "extends": when a service extends another (metadata: service info)
37+
// - "include": when including external compose files (metadata: {"path": StringList})
38+
//
39+
// Multiple listeners can be registered, and all will be notified of events.
40+
type LoadListener func(event string, metadata map[string]any)
41+
42+
// ProjectLoadOptions configures how a Compose project should be loaded
43+
type ProjectLoadOptions struct {
44+
// ProjectName to use, or empty to infer from directory
45+
ProjectName string
46+
// ConfigPaths are paths to compose files
47+
ConfigPaths []string
48+
// WorkingDir is the project directory
49+
WorkingDir string
50+
// EnvFiles are paths to .env files
51+
EnvFiles []string
52+
// Profiles to activate
53+
Profiles []string
54+
// Services to select (empty = all)
55+
Services []string
56+
// Offline mode disables remote resource loading
57+
Offline bool
58+
// All includes all resources (not just those used by services)
59+
All bool
60+
// Compatibility enables v1 compatibility mode
61+
Compatibility bool
62+
63+
// ProjectOptionsFns are compose-go project options to apply.
64+
// Use cli.WithInterpolation(false), cli.WithNormalization(false), etc.
65+
// This is optional - pass nil or empty slice to use defaults.
66+
ProjectOptionsFns []cli.ProjectOptionsFn
67+
68+
// LoadListeners receive events during project loading.
69+
// All registered listeners will be notified of events.
70+
// This is optional - pass nil or empty slice if not needed.
71+
LoadListeners []LoadListener
72+
}
73+
3374
// Compose is the API interface one can use to programmatically use docker/compose in a third-party software
3475
// Use [compose.NewComposeService] to get an actual instance
3576
type Compose interface {
@@ -102,6 +143,8 @@ type Compose interface {
102143
// GetConfiguredStreams returns the configured I/O streams (stdout, stderr, stdin).
103144
// If no custom streams were configured, it returns the dockerCli streams.
104145
GetConfiguredStreams() (stdout io.Writer, stderr io.Writer, stdin io.Reader)
146+
// LoadProject loads and validates a Compose project from configuration files.
147+
LoadProject(ctx context.Context, options ProjectLoadOptions) (*types.Project, error)
105148
}
106149

107150
type VolumesOptions struct {

pkg/compose/loader.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"context"
21+
"errors"
22+
"os"
23+
"strings"
24+
25+
"github.com/compose-spec/compose-go/v2/cli"
26+
"github.com/compose-spec/compose-go/v2/loader"
27+
"github.com/compose-spec/compose-go/v2/types"
28+
"github.com/docker/compose/v2/pkg/api"
29+
"github.com/docker/compose/v2/pkg/remote"
30+
)
31+
32+
// LoadProject implements api.Compose.LoadProject
33+
// It loads and validates a Compose project from configuration files.
34+
func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoadOptions) (*types.Project, error) {
35+
// Setup remote loaders (Git, OCI)
36+
remoteLoaders := s.createRemoteLoaders(options.Offline)
37+
38+
projectOptions, err := s.buildProjectOptions(options, remoteLoaders)
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
// Register all user-provided listeners (e.g., for metrics collection)
44+
for _, listener := range options.LoadListeners {
45+
if listener != nil {
46+
projectOptions.WithListeners(listener)
47+
}
48+
}
49+
50+
if options.Compatibility {
51+
api.Separator = "_"
52+
}
53+
54+
project, err := projectOptions.LoadProject(ctx)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
// Post-processing: service selection, environment resolution, etc.
60+
project, err = s.postProcessProject(project, options)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
return project, nil
66+
}
67+
68+
// createRemoteLoaders creates Git and OCI remote loaders if not in offline mode
69+
func (s *composeService) createRemoteLoaders(offline bool) []loader.ResourceLoader {
70+
if offline {
71+
return nil
72+
}
73+
git := remote.NewGitRemoteLoader(s.dockerCli, offline)
74+
oci := remote.NewOCIRemoteLoader(s.dockerCli, offline)
75+
return []loader.ResourceLoader{git, oci}
76+
}
77+
78+
// buildProjectOptions constructs compose-go ProjectOptions from API options
79+
func (s *composeService) buildProjectOptions(options api.ProjectLoadOptions, remoteLoaders []loader.ResourceLoader) (*cli.ProjectOptions, error) {
80+
opts := []cli.ProjectOptionsFn{
81+
cli.WithWorkingDirectory(options.WorkingDir),
82+
cli.WithOsEnv,
83+
}
84+
85+
// Add PWD if not present
86+
if _, present := os.LookupEnv("PWD"); !present {
87+
if pwd, err := os.Getwd(); err == nil {
88+
opts = append(opts, cli.WithEnv([]string{"PWD=" + pwd}))
89+
}
90+
}
91+
92+
// Add remote loaders
93+
for _, r := range remoteLoaders {
94+
opts = append(opts, cli.WithResourceLoader(r))
95+
}
96+
97+
opts = append(opts,
98+
cli.WithEnvFiles(options.EnvFiles...),
99+
cli.WithDotEnv,
100+
cli.WithConfigFileEnv,
101+
cli.WithDefaultConfigPath,
102+
cli.WithEnvFiles(options.EnvFiles...),
103+
cli.WithDotEnv,
104+
cli.WithDefaultProfiles(options.Profiles...),
105+
cli.WithName(options.ProjectName),
106+
)
107+
108+
return cli.NewProjectOptions(options.ConfigPaths, append(options.ProjectOptionsFns, opts...)...)
109+
}
110+
111+
// postProcessProject applies post-loading transformations to the project
112+
func (s *composeService) postProcessProject(project *types.Project, options api.ProjectLoadOptions) (*types.Project, error) {
113+
if project.Name == "" {
114+
return nil, errors.New("project name can't be empty. Use ProjectName option to set a valid name")
115+
}
116+
117+
project, err := project.WithServicesEnabled(options.Services...)
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
// Add custom labels
123+
for name, s := range project.Services {
124+
s.CustomLabels = map[string]string{
125+
api.ProjectLabel: project.Name,
126+
api.ServiceLabel: name,
127+
api.VersionLabel: api.ComposeVersion,
128+
api.WorkingDirLabel: project.WorkingDir,
129+
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
130+
api.OneoffLabel: "False",
131+
}
132+
if len(options.EnvFiles) != 0 {
133+
s.CustomLabels[api.EnvironmentFileLabel] = strings.Join(options.EnvFiles, ",")
134+
}
135+
project.Services[name] = s
136+
}
137+
138+
project, err = project.WithSelectedServices(options.Services)
139+
if err != nil {
140+
return nil, err
141+
}
142+
143+
// Remove unnecessary resources if not All
144+
if !options.All {
145+
project = project.WithoutUnnecessaryResources()
146+
}
147+
148+
return project, nil
149+
}

0 commit comments

Comments
 (0)