Skip to content

Commit 07e9470

Browse files
authored
implement normalization as a yaml transformation (#589)
* implement normalization as a yaml transformation Signed-off-by: Nicolas De Loof <[email protected]> * introduce LoadModel for full compose file loading without go bindings Signed-off-by: Nicolas De Loof <[email protected]> --------- Signed-off-by: Nicolas De Loof <[email protected]>
1 parent dff4d49 commit 07e9470

File tree

8 files changed

+454
-406
lines changed

8 files changed

+454
-406
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
### IDEs ###
22
.idea/*
33
.vscode/*
4+
bin/

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ IMAGE_PREFIX=composespec/conformance-tests-
1616

1717
.PHONY: build
1818
build: ## Build command line
19-
go build -o compose-spec cmd/main.go
19+
go build -o bin/compose-spec cmd/main.go
2020

2121
.PHONY: test
2222
test: ## Run tests

cli/options.go

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.y
379379
// DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
380380
var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}
381381

382-
func (o ProjectOptions) GetWorkingDir() (string, error) {
382+
func (o *ProjectOptions) GetWorkingDir() (string, error) {
383383
if o.WorkingDir != "" {
384384
return filepath.Abs(o.WorkingDir)
385385
}
@@ -395,7 +395,7 @@ func (o ProjectOptions) GetWorkingDir() (string, error) {
395395
return os.Getwd()
396396
}
397397

398-
func (o ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
398+
func (o *ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
399399
configPaths, err := o.getConfigPaths()
400400
if err != nil {
401401
return nil, err
@@ -427,37 +427,64 @@ func (o ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
427427
return configs, err
428428
}
429429

430-
// ProjectFromOptions load a compose project based on command line options
431-
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
432-
configs, err := options.GeConfigFiles()
430+
// LoadProject loads compose file according to options and bind to types.Project go structs
431+
func (o *ProjectOptions) LoadProject(ctx context.Context) (*types.Project, error) {
432+
configDetails, err := o.prepare()
433433
if err != nil {
434434
return nil, err
435435
}
436436

437-
workingDir, err := options.GetWorkingDir()
437+
project, err := loader.LoadWithContext(ctx, configDetails, o.loadOptions...)
438438
if err != nil {
439439
return nil, err
440440
}
441441

442-
options.loadOptions = append(options.loadOptions,
443-
withNamePrecedenceLoad(workingDir, options),
444-
withConvertWindowsPaths(options),
445-
withListeners(options))
442+
for _, config := range configDetails.ConfigFiles {
443+
project.ComposeFiles = append(project.ComposeFiles, config.Filename)
444+
}
446445

447-
project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
448-
ConfigFiles: configs,
449-
WorkingDir: workingDir,
450-
Environment: options.Environment,
451-
}, options.loadOptions...)
446+
return project, nil
447+
}
448+
449+
// LoadModel loads compose file according to options and returns a raw (yaml tree) model
450+
func (o *ProjectOptions) LoadModel(ctx context.Context) (map[string]any, error) {
451+
configDetails, err := o.prepare()
452452
if err != nil {
453453
return nil, err
454454
}
455455

456-
for _, config := range configs {
457-
project.ComposeFiles = append(project.ComposeFiles, config.Filename)
456+
return loader.LoadModelWithContext(ctx, configDetails, o.loadOptions...)
457+
}
458+
459+
// prepare converts ProjectOptions into loader's types.ConfigDetails and configures default load options
460+
func (o *ProjectOptions) prepare() (types.ConfigDetails, error) {
461+
configs, err := o.GeConfigFiles()
462+
if err != nil {
463+
return types.ConfigDetails{}, err
458464
}
459465

460-
return project, nil
466+
workingDir, err := o.GetWorkingDir()
467+
if err != nil {
468+
return types.ConfigDetails{}, err
469+
}
470+
471+
configDetails := types.ConfigDetails{
472+
ConfigFiles: configs,
473+
WorkingDir: workingDir,
474+
Environment: o.Environment,
475+
}
476+
477+
o.loadOptions = append(o.loadOptions,
478+
withNamePrecedenceLoad(workingDir, o),
479+
withConvertWindowsPaths(o),
480+
withListeners(o))
481+
return configDetails, nil
482+
}
483+
484+
// ProjectFromOptions load a compose project based on command line options
485+
// Deprecated: use ProjectOptions.LoadProject or ProjectOptions.LoadModel
486+
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
487+
return options.LoadProject(ctx)
461488
}
462489

463490
func withNamePrecedenceLoad(absWorkingDir string, options *ProjectOptions) func(*loader.Options) {

cmd/main.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ package main
1818

1919
import (
2020
"context"
21+
"encoding/json"
2122
"flag"
2223
"fmt"
2324
"os"
2425

2526
"github.com/compose-spec/compose-go/v2/cli"
27+
"gopkg.in/yaml.v3"
2628
)
2729

2830
func main() {
@@ -34,11 +36,13 @@ Usage: compose-spec [OPTIONS] COMPOSE_FILE [COMPOSE_OVERRIDE_FILE]`)
3436
}
3537

3638
var skipInterpolation, skipResolvePaths, skipNormalization, skipConsistencyCheck bool
39+
var format string
3740

3841
flag.BoolVar(&skipInterpolation, "no-interpolation", false, "Don't interpolate environment variables.")
3942
flag.BoolVar(&skipResolvePaths, "no-path-resolution", false, "Don't resolve file paths.")
4043
flag.BoolVar(&skipNormalization, "no-normalization", false, "Don't normalize compose model.")
4144
flag.BoolVar(&skipConsistencyCheck, "no-consistency", false, "Don't check model consistency.")
45+
flag.StringVar(&format, "format", "yaml", "Output format (yaml|json).")
4246
flag.Parse()
4347

4448
wd, err := os.Getwd()
@@ -61,16 +65,29 @@ Usage: compose-spec [OPTIONS] COMPOSE_FILE [COMPOSE_OVERRIDE_FILE]`)
6165
exitError("failed to configure project options", err)
6266
}
6367

64-
project, err := cli.ProjectFromOptions(context.Background(), options)
68+
model, err := options.LoadModel(context.Background())
6569
if err != nil {
6670
exitError("failed to load project", err)
6771
}
6872

69-
yaml, err := project.MarshalYAML()
70-
if err != nil {
71-
exitError("failed to marshall project", err)
73+
var raw []byte
74+
switch format {
75+
case "yaml":
76+
raw, err = yaml.Marshal(model)
77+
if err != nil {
78+
exitError("failed to marshall project", err)
79+
}
80+
case "json":
81+
raw, err = json.MarshalIndent(model, "", " ")
82+
if err != nil {
83+
exitError("failed to marshall project", err)
84+
}
85+
default:
86+
_ = fmt.Errorf("unsupported output format %s", format)
87+
os.Exit(1)
7288
}
73-
fmt.Println(string(yaml))
89+
90+
fmt.Println(string(raw))
7491
}
7592

7693
func exitError(message string, err error) {

loader/loader.go

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -285,27 +285,29 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
285285
return LoadWithContext(context.Background(), configDetails, options...)
286286
}
287287

288-
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration
288+
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration as a compose-go Project
289289
func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
290-
if len(configDetails.ConfigFiles) < 1 {
291-
return nil, errors.New("No files specified")
290+
opts := toOptions(&configDetails, options)
291+
dict, err := loadModelWithContext(ctx, &configDetails, opts)
292+
if err != nil {
293+
return nil, err
292294
}
295+
return modelToProject(dict, opts, configDetails)
296+
}
293297

294-
opts := &Options{
295-
Interpolate: &interp.Options{
296-
Substitute: template.Substitute,
297-
LookupValue: configDetails.LookupEnv,
298-
TypeCastMapping: interpolateTypeCastMapping,
299-
},
300-
ResolvePaths: true,
301-
}
298+
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
299+
func LoadModelWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (map[string]any, error) {
300+
opts := toOptions(&configDetails, options)
301+
return loadModelWithContext(ctx, &configDetails, opts)
302+
}
302303

303-
for _, op := range options {
304-
op(opts)
304+
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
305+
func loadModelWithContext(ctx context.Context, configDetails *types.ConfigDetails, opts *Options) (map[string]any, error) {
306+
if len(configDetails.ConfigFiles) < 1 {
307+
return nil, errors.New("No files specified")
305308
}
306-
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
307309

308-
err := projectName(configDetails, opts)
310+
err := projectName(*configDetails, opts)
309311
if err != nil {
310312
return nil, err
311313
}
@@ -318,7 +320,24 @@ func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, opt
318320
configDetails.Environment[consts.ComposeProjectName] = opts.projectName
319321
}
320322

321-
return load(ctx, configDetails, opts, nil)
323+
return load(ctx, *configDetails, opts, nil)
324+
}
325+
326+
func toOptions(configDetails *types.ConfigDetails, options []func(*Options)) *Options {
327+
opts := &Options{
328+
Interpolate: &interp.Options{
329+
Substitute: template.Substitute,
330+
LookupValue: configDetails.LookupEnv,
331+
TypeCastMapping: interpolateTypeCastMapping,
332+
},
333+
ResolvePaths: true,
334+
}
335+
336+
for _, op := range options {
337+
op(opts)
338+
}
339+
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
340+
return opts
322341
}
323342

324343
func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Options, ct *cycleTracker, included []string) (map[string]interface{}, error) {
@@ -458,7 +477,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
458477
return dict, nil
459478
}
460479

461-
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (*types.Project, error) {
480+
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (map[string]interface{}, error) {
462481
mainFile := configDetails.ConfigFiles[0].Filename
463482
for _, f := range loaded {
464483
if f == mainFile {
@@ -481,13 +500,26 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
481500
return nil, errors.New("project name must not be empty")
482501
}
483502

503+
if !opts.SkipNormalization {
504+
dict, err = Normalize(dict, configDetails.Environment)
505+
if err != nil {
506+
return nil, err
507+
}
508+
}
509+
510+
return dict, nil
511+
}
512+
513+
// modelToProject binds a canonical yaml dict into compose-go structs
514+
func modelToProject(dict map[string]interface{}, opts *Options, configDetails types.ConfigDetails) (*types.Project, error) {
484515
project := &types.Project{
485516
Name: opts.projectName,
486517
WorkingDir: configDetails.WorkingDir,
487518
Environment: configDetails.Environment,
488519
}
489520
delete(dict, "name") // project name set by yaml must be identified by caller as opts.projectName
490521

522+
var err error
491523
dict, err = processExtensions(dict, tree.NewPath(), opts.KnownExtensions)
492524
if err != nil {
493525
return nil, err
@@ -498,13 +530,6 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
498530
return nil, err
499531
}
500532

501-
if !opts.SkipNormalization {
502-
err := Normalize(project)
503-
if err != nil {
504-
return nil, err
505-
}
506-
}
507-
508533
if opts.ConvertWindowsPaths {
509534
for i, service := range project.Services {
510535
for j, volume := range service.Volumes {
@@ -531,7 +556,6 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
531556
return nil, err
532557
}
533558
}
534-
535559
return project, nil
536560
}
537561

loader/loader_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2325,7 +2325,7 @@ services:
23252325
container_name: ${COMPOSE_PROJECT_NAME}-web
23262326
`
23272327
configDetails := buildConfigDetails(yaml, map[string]string{"COMPOSE_PROJECT_NAME": "env-var"})
2328-
actual, err := Load(configDetails)
2328+
actual, err := LoadWithContext(context.TODO(), configDetails)
23292329
assert.NilError(t, err)
23302330
svc, err := actual.GetService("web")
23312331
assert.NilError(t, err)

0 commit comments

Comments
 (0)