vendor: update compose-go to v2.0.0-rc.3

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2024-01-31 14:15:41 +01:00
committed by CrazyMax
parent d0c4bed484
commit 13beda8b11
97 changed files with 5770 additions and 2719 deletions

191
vendor/github.com/compose-spec/compose-go/v2/LICENSE generated vendored Normal file
View File

@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2013-2017 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

2
vendor/github.com/compose-spec/compose-go/v2/NOTICE generated vendored Normal file
View File

@ -0,0 +1,2 @@
The Compose Specification
Copyright 2020 The Compose Specification Authors

View File

@ -0,0 +1,507 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cli
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/sirupsen/logrus"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
)
// ProjectOptions provides common configuration for loading a project.
type ProjectOptions struct {
ctx context.Context
// Name is a valid Compose project name to be used or empty.
//
// If empty, the project loader will automatically infer a reasonable
// project name if possible.
Name string
// WorkingDir is a file path to use as the project directory or empty.
//
// If empty, the project loader will automatically infer a reasonable
// working directory if possible.
WorkingDir string
// ConfigPaths are file paths to one or more Compose files.
//
// These are applied in order by the loader following the override logic
// as described in the spec.
//
// The first entry is required and is the primary Compose file.
// For convenience, WithConfigFileEnv and WithDefaultConfigPath
// are provided to populate this in a predictable manner.
ConfigPaths []string
// Environment are additional environment variables to make available
// for interpolation.
//
// NOTE: For security, the loader does not automatically expose any
// process environment variables. For convenience, WithOsEnv can be
// used if appropriate.
Environment types.Mapping
// EnvFiles are file paths to ".env" files with additional environment
// variable data.
//
// These are loaded in-order, so it is possible to override variables or
// in subsequent files.
//
// This field is optional, but any file paths that are included here must
// exist or an error will be returned during load.
EnvFiles []string
loadOptions []func(*loader.Options)
}
type ProjectOptionsFn func(*ProjectOptions) error
// NewProjectOptions creates ProjectOptions
func NewProjectOptions(configs []string, opts ...ProjectOptionsFn) (*ProjectOptions, error) {
options := &ProjectOptions{
ConfigPaths: configs,
Environment: map[string]string{},
}
for _, o := range opts {
err := o(options)
if err != nil {
return nil, err
}
}
return options, nil
}
// WithName defines ProjectOptions' name
func WithName(name string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
// a project (once loaded) cannot have an empty name
// however, on the options object, the name is optional: if unset,
// a name will be inferred by the loader, so it's legal to set the
// name to an empty string here
if name != loader.NormalizeProjectName(name) {
return loader.InvalidProjectNameErr(name)
}
o.Name = name
return nil
}
}
// WithWorkingDirectory defines ProjectOptions' working directory
func WithWorkingDirectory(wd string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
if wd == "" {
return nil
}
abs, err := filepath.Abs(wd)
if err != nil {
return err
}
o.WorkingDir = abs
return nil
}
}
// WithConfigFileEnv allow to set compose config file paths by COMPOSE_FILE environment variable
func WithConfigFileEnv(o *ProjectOptions) error {
if len(o.ConfigPaths) > 0 {
return nil
}
sep := o.Environment[consts.ComposePathSeparator]
if sep == "" {
sep = string(os.PathListSeparator)
}
f, ok := o.Environment[consts.ComposeFilePath]
if ok {
paths, err := absolutePaths(strings.Split(f, sep))
o.ConfigPaths = paths
return err
}
return nil
}
// WithDefaultConfigPath searches for default config files from working directory
func WithDefaultConfigPath(o *ProjectOptions) error {
if len(o.ConfigPaths) > 0 {
return nil
}
pwd, err := o.GetWorkingDir()
if err != nil {
return err
}
for {
candidates := findFiles(DefaultFileNames, pwd)
if len(candidates) > 0 {
winner := candidates[0]
if len(candidates) > 1 {
logrus.Warnf("Found multiple config files with supported names: %s", strings.Join(candidates, ", "))
logrus.Warnf("Using %s", winner)
}
o.ConfigPaths = append(o.ConfigPaths, winner)
overrides := findFiles(DefaultOverrideFileNames, pwd)
if len(overrides) > 0 {
if len(overrides) > 1 {
logrus.Warnf("Found multiple override files with supported names: %s", strings.Join(overrides, ", "))
logrus.Warnf("Using %s", overrides[0])
}
o.ConfigPaths = append(o.ConfigPaths, overrides[0])
}
return nil
}
parent := filepath.Dir(pwd)
if parent == pwd {
// no config file found, but that's not a blocker if caller only needs project name
return nil
}
pwd = parent
}
}
// WithEnv defines a key=value set of variables used for compose file interpolation
func WithEnv(env []string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
for k, v := range utils.GetAsEqualsMap(env) {
o.Environment[k] = v
}
return nil
}
}
// WithDiscardEnvFile sets discards the `env_file` section after resolving to
// the `environment` section
func WithDiscardEnvFile(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithDiscardEnvFiles)
return nil
}
// WithLoadOptions provides a hook to control how compose files are loaded
func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loadOptions...)
return nil
}
}
// WithDefaultProfiles uses the provided profiles (if any), and falls back to
// profiles specified via the COMPOSE_PROFILES environment variable otherwise.
func WithDefaultProfiles(profile ...string) ProjectOptionsFn {
if len(profile) == 0 {
profile = strings.Split(os.Getenv(consts.ComposeProfiles), ",")
}
return WithProfiles(profile)
}
// WithProfiles sets profiles to be activated
func WithProfiles(profiles []string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithProfiles(profiles))
return nil
}
}
// WithOsEnv imports environment variables from OS
func WithOsEnv(o *ProjectOptions) error {
for k, v := range utils.GetAsEqualsMap(os.Environ()) {
if _, set := o.Environment[k]; set {
continue
}
o.Environment[k] = v
}
return nil
}
// WithEnvFile sets an alternate env file.
//
// Deprecated: use WithEnvFiles instead.
func WithEnvFile(file string) ProjectOptionsFn {
var files []string
if file != "" {
files = []string{file}
}
return WithEnvFiles(files...)
}
// WithEnvFiles set env file(s) to be loaded to set project environment.
// defaults to local .env file if no explicit file is selected, until COMPOSE_DISABLE_ENV_FILE is set
func WithEnvFiles(file ...string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
if len(file) > 0 {
o.EnvFiles = file
return nil
}
if v, ok := os.LookupEnv(consts.ComposeDisableDefaultEnvFile); ok {
b, err := strconv.ParseBool(v)
if err != nil {
return err
}
if b {
return nil
}
}
wd, err := o.GetWorkingDir()
if err != nil {
return err
}
defaultDotEnv := filepath.Join(wd, ".env")
s, err := os.Stat(defaultDotEnv)
if os.IsNotExist(err) {
return nil
}
if !s.IsDir() {
o.EnvFiles = []string{defaultDotEnv}
}
return nil
}
}
// WithDotEnv imports environment variables from .env file
func WithDotEnv(o *ProjectOptions) error {
envMap, err := dotenv.GetEnvFromFile(o.Environment, o.EnvFiles)
if err != nil {
return err
}
o.Environment.Merge(envMap)
return nil
}
// WithInterpolation set ProjectOptions to enable/skip interpolation
func WithInterpolation(interpolation bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipInterpolation = !interpolation
})
return nil
}
}
// WithNormalization set ProjectOptions to enable/skip normalization
func WithNormalization(normalization bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipNormalization = !normalization
})
return nil
}
}
// WithConsistency set ProjectOptions to enable/skip consistency
func WithConsistency(consistency bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipConsistencyCheck = !consistency
})
return nil
}
}
// WithResolvedPaths set ProjectOptions to enable paths resolution
func WithResolvedPaths(resolve bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.ResolvePaths = resolve
})
return nil
}
}
// WithContext sets the context used to load model and resources
func WithContext(ctx context.Context) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.ctx = ctx
return nil
}
}
// WithResourceLoader register support for ResourceLoader to manage remote resources
func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.ResourceLoaders = append(options.ResourceLoaders, r)
})
return nil
}
}
// WithoutEnvironmentResolution disable environment resolution
func WithoutEnvironmentResolution(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipResolveEnvironment = true
})
return nil
}
// DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
// DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}
func (o ProjectOptions) GetWorkingDir() (string, error) {
if o.WorkingDir != "" {
return o.WorkingDir, nil
}
for _, path := range o.ConfigPaths {
if path != "-" {
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Dir(absPath), nil
}
}
return os.Getwd()
}
// ProjectFromOptions load a compose project based on command line options
func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
configPaths, err := getConfigPathsFromOptions(options)
if err != nil {
return nil, err
}
var configs []types.ConfigFile
for _, f := range configPaths {
var b []byte
if f == "-" {
b, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, err
}
} else {
f, err := filepath.Abs(f)
if err != nil {
return nil, err
}
b, err = os.ReadFile(f)
if err != nil {
return nil, err
}
}
configs = append(configs, types.ConfigFile{
Filename: f,
Content: b,
})
}
workingDir, err := options.GetWorkingDir()
if err != nil {
return nil, err
}
absWorkingDir, err := filepath.Abs(workingDir)
if err != nil {
return nil, err
}
options.loadOptions = append(options.loadOptions,
withNamePrecedenceLoad(absWorkingDir, options),
withConvertWindowsPaths(options))
ctx := options.ctx
if ctx == nil {
ctx = context.Background()
}
project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
ConfigFiles: configs,
WorkingDir: workingDir,
Environment: options.Environment,
}, options.loadOptions...)
if err != nil {
return nil, err
}
project.ComposeFiles = configPaths
return project, nil
}
func withNamePrecedenceLoad(absWorkingDir string, options *ProjectOptions) func(*loader.Options) {
return func(opts *loader.Options) {
if options.Name != "" {
opts.SetProjectName(options.Name, true)
} else if nameFromEnv, ok := options.Environment[consts.ComposeProjectName]; ok && nameFromEnv != "" {
opts.SetProjectName(nameFromEnv, true)
} else {
opts.SetProjectName(
loader.NormalizeProjectName(filepath.Base(absWorkingDir)),
false,
)
}
}
}
func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
return func(o *loader.Options) {
if o.ResolvePaths {
o.ConvertWindowsPaths = utils.StringToBool(options.Environment["COMPOSE_CONVERT_WINDOWS_PATHS"])
}
}
}
// getConfigPathsFromOptions retrieves the config files for project based on project options
func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
if len(options.ConfigPaths) != 0 {
return absolutePaths(options.ConfigPaths)
}
return nil, fmt.Errorf("no configuration file provided: %w", errdefs.ErrNotFound)
}
func findFiles(names []string, pwd string) []string {
candidates := []string{}
for _, n := range names {
f := filepath.Join(pwd, n)
if _, err := os.Stat(f); err == nil {
candidates = append(candidates, f)
}
}
return candidates
}
func absolutePaths(p []string) ([]string, error) {
var paths []string
for _, f := range p {
if f == "-" {
paths = append(paths, f)
continue
}
abs, err := filepath.Abs(f)
if err != nil {
return nil, err
}
f = abs
if _, err := os.Stat(f); err != nil {
return nil, err
}
paths = append(paths, f)
}
return paths, nil
}

View File

@ -0,0 +1,29 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package consts
const (
ComposeProjectName = "COMPOSE_PROJECT_NAME"
ComposePathSeparator = "COMPOSE_PATH_SEPARATOR"
ComposeFilePath = "COMPOSE_FILE"
ComposeDisableDefaultEnvFile = "COMPOSE_DISABLE_ENV_FILE"
ComposeProfiles = "COMPOSE_PROFILES"
)
const Extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
type ComposeFileKey struct{}

View File

@ -0,0 +1,22 @@
Copyright (c) 2013 John Barton
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,76 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dotenv
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
func GetEnvFromFile(currentEnv map[string]string, filenames []string) (map[string]string, error) {
envMap := make(map[string]string)
for _, dotEnvFile := range filenames {
abs, err := filepath.Abs(dotEnvFile)
if err != nil {
return envMap, err
}
dotEnvFile = abs
s, err := os.Stat(dotEnvFile)
if os.IsNotExist(err) {
return envMap, fmt.Errorf("Couldn't find env file: %s", dotEnvFile)
}
if err != nil {
return envMap, err
}
if s.IsDir() {
if len(filenames) == 0 {
return envMap, nil
}
return envMap, fmt.Errorf("%s is a directory", dotEnvFile)
}
b, err := os.ReadFile(dotEnvFile)
if os.IsNotExist(err) {
return nil, fmt.Errorf("Couldn't read env file: %s", dotEnvFile)
}
if err != nil {
return envMap, err
}
env, err := ParseWithLookup(bytes.NewReader(b), func(k string) (string, bool) {
v, ok := currentEnv[k]
if ok {
return v, true
}
v, ok = envMap[k]
return v, ok
})
if err != nil {
return envMap, fmt.Errorf("failed to read %s: %w", dotEnvFile, err)
}
for k, v := range env {
envMap[k] = v
}
}
return envMap, nil
}

View File

@ -0,0 +1,175 @@
// Package dotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv)
//
// Examples/readme can be found on the github page at https://github.com/joho/godotenv
//
// The TL;DR is that you make a .env file that looks something like
//
// SOME_ENV_VAR=somevalue
//
// and then in your go code you can call
//
// godotenv.Load()
//
// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
package dotenv
import (
"bytes"
"io"
"os"
"regexp"
"strings"
"github.com/compose-spec/compose-go/v2/template"
)
var utf8BOM = []byte("\uFEFF")
var startsWithDigitRegex = regexp.MustCompile(`^\s*\d.*`) // Keys starting with numbers are ignored
// LookupFn represents a lookup function to resolve variables from
type LookupFn func(string) (string, bool)
var noLookupFn = func(s string) (string, bool) {
return "", false
}
// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, error) {
return ParseWithLookup(r, nil)
}
// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
func ParseWithLookup(r io.Reader, lookupFn LookupFn) (map[string]string, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
// seek past the UTF-8 BOM if it exists (particularly on Windows, some
// editors tend to add it, and it'll cause parsing to fail)
data = bytes.TrimPrefix(data, utf8BOM)
return UnmarshalBytesWithLookup(data, lookupFn)
}
// Load will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main).
//
// If you call Load without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults
func Load(filenames ...string) error {
return load(false, filenames...)
}
func load(overload bool, filenames ...string) error {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err := loadFile(filename, overload)
if err != nil {
return err
}
}
return nil
}
// ReadWithLookup gets all env vars from the files and/or lookup function and return values as
// a map rather than automatically writing values into env
func ReadWithLookup(lookupFn LookupFn, filenames ...string) (map[string]string, error) {
filenames = filenamesOrDefault(filenames)
envMap := make(map[string]string)
for _, filename := range filenames {
individualEnvMap, individualErr := readFile(filename, lookupFn)
if individualErr != nil {
return envMap, individualErr
}
for key, value := range individualEnvMap {
if startsWithDigitRegex.MatchString(key) {
continue
}
envMap[key] = value
}
}
return envMap, nil
}
// Read all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func Read(filenames ...string) (map[string]string, error) {
return ReadWithLookup(nil, filenames...)
}
// UnmarshalBytesWithLookup parses env file from byte slice of chars, returning a map of keys and values.
func UnmarshalBytesWithLookup(src []byte, lookupFn LookupFn) (map[string]string, error) {
return UnmarshalWithLookup(string(src), lookupFn)
}
// UnmarshalWithLookup parses env file from string, returning a map of keys and values.
func UnmarshalWithLookup(src string, lookupFn LookupFn) (map[string]string, error) {
out := make(map[string]string)
err := newParser().parse(src, out, lookupFn)
return out, err
}
func filenamesOrDefault(filenames []string) []string {
if len(filenames) == 0 {
return []string{".env"}
}
return filenames
}
func loadFile(filename string, overload bool) error {
envMap, err := readFile(filename, nil)
if err != nil {
return err
}
currentEnv := map[string]bool{}
rawEnv := os.Environ()
for _, rawEnvLine := range rawEnv {
key := strings.Split(rawEnvLine, "=")[0]
currentEnv[key] = true
}
for key, value := range envMap {
if !currentEnv[key] || overload {
_ = os.Setenv(key, value)
}
}
return nil
}
func readFile(filename string, lookupFn LookupFn) (map[string]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
return ParseWithLookup(file, lookupFn)
}
func expandVariables(value string, envMap map[string]string, lookupFn LookupFn) (string, error) {
retVal, err := template.Substitute(value, func(k string) (string, bool) {
if v, ok := lookupFn(k); ok {
return v, true
}
v, ok := envMap[k]
return v, ok
})
if err != nil {
return value, err
}
return retVal, nil
}

View File

@ -0,0 +1,282 @@
package dotenv
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
)
const (
charComment = '#'
prefixSingleQuote = '\''
prefixDoubleQuote = '"'
)
var (
escapeSeqRegex = regexp.MustCompile(`(\\(?:[abcfnrtv$"\\]|0\d{0,3}))`)
exportRegex = regexp.MustCompile(`^export\s+`)
)
type parser struct {
line int
}
func newParser() *parser {
return &parser{
line: 1,
}
}
func (p *parser) parse(src string, out map[string]string, lookupFn LookupFn) error {
cutset := src
if lookupFn == nil {
lookupFn = noLookupFn
}
for {
cutset = p.getStatementStart(cutset)
if cutset == "" {
// reached end of file
break
}
key, left, inherited, err := p.locateKeyName(cutset)
if err != nil {
return err
}
if strings.Contains(key, " ") {
return fmt.Errorf("line %d: key cannot contain a space", p.line)
}
if inherited {
value, ok := lookupFn(key)
if ok {
out[key] = value
}
cutset = left
continue
}
value, left, err := p.extractVarValue(left, out, lookupFn)
if err != nil {
return err
}
out[key] = value
cutset = left
}
return nil
}
// getStatementPosition returns position of statement begin.
//
// It skips any comment line or non-whitespace character.
func (p *parser) getStatementStart(src string) string {
pos := p.indexOfNonSpaceChar(src)
if pos == -1 {
return ""
}
src = src[pos:]
if src[0] != charComment {
return src
}
// skip comment section
pos = strings.IndexFunc(src, isCharFunc('\n'))
if pos == -1 {
return ""
}
return p.getStatementStart(src[pos:])
}
// locateKeyName locates and parses key name and returns rest of slice
func (p *parser) locateKeyName(src string) (string, string, bool, error) {
var key string
var inherited bool
// trim "export" and space at beginning
if exportRegex.MatchString(src) {
// we use a `strings.trim` to preserve the pointer to the same underlying memory.
// a regexp replace would copy the string.
src = strings.TrimLeftFunc(strings.TrimPrefix(src, "export"), isSpace)
}
// locate key name end and validate it in single loop
offset := 0
loop:
for i, rune := range src {
if isSpace(rune) {
continue
}
switch rune {
case '=', ':', '\n':
// library also supports yaml-style value declaration
key = string(src[0:i])
offset = i + 1
inherited = rune == '\n'
break loop
case '_', '.', '-', '[', ']':
default:
// variable name should match [A-Za-z0-9_.-]
if unicode.IsLetter(rune) || unicode.IsNumber(rune) {
continue
}
return "", "", inherited, fmt.Errorf(
`line %d: unexpected character %q in variable name %q`,
p.line, string(rune), strings.Split(src, "\n")[0])
}
}
if src == "" {
return "", "", inherited, errors.New("zero length string")
}
// trim whitespace
key = strings.TrimRightFunc(key, unicode.IsSpace)
cutset := strings.TrimLeftFunc(src[offset:], isSpace)
return key, cutset, inherited, nil
}
// extractVarValue extracts variable value and returns rest of slice
func (p *parser) extractVarValue(src string, envMap map[string]string, lookupFn LookupFn) (string, string, error) {
quote, isQuoted := hasQuotePrefix(src)
if !isQuoted {
// unquoted value - read until new line
value, rest, _ := strings.Cut(src, "\n")
p.line++
// Remove inline comments on unquoted lines
value, _, _ = strings.Cut(value, " #")
value = strings.TrimRightFunc(value, unicode.IsSpace)
retVal, err := expandVariables(string(value), envMap, lookupFn)
return retVal, rest, err
}
previousCharIsEscape := false
// lookup quoted string terminator
var chars []byte
for i := 1; i < len(src); i++ {
char := src[i]
if char == '\n' {
p.line++
}
if char != quote {
if !previousCharIsEscape && char == '\\' {
previousCharIsEscape = true
continue
}
if previousCharIsEscape {
previousCharIsEscape = false
chars = append(chars, '\\')
}
chars = append(chars, char)
continue
}
// skip escaped quote symbol (\" or \', depends on quote)
if previousCharIsEscape {
previousCharIsEscape = false
chars = append(chars, char)
continue
}
// trim quotes
value := string(chars)
if quote == prefixDoubleQuote {
// expand standard shell escape sequences & then interpolate
// variables on the result
retVal, err := expandVariables(expandEscapes(value), envMap, lookupFn)
if err != nil {
return "", "", err
}
value = retVal
}
return value, src[i+1:], nil
}
// return formatted error if quoted string is not terminated
valEndIndex := strings.IndexFunc(src, isCharFunc('\n'))
if valEndIndex == -1 {
valEndIndex = len(src)
}
return "", "", fmt.Errorf("line %d: unterminated quoted value %s", p.line, src[:valEndIndex])
}
func expandEscapes(str string) string {
out := escapeSeqRegex.ReplaceAllStringFunc(str, func(match string) string {
if match == `\$` {
// `\$` is not a Go escape sequence, the expansion parser uses
// the special `$$` syntax
// both `FOO=\$bar` and `FOO=$$bar` are valid in an env file and
// will result in FOO w/ literal value of "$bar" (no interpolation)
return "$$"
}
if strings.HasPrefix(match, `\0`) {
// octal escape sequences in Go are not prefixed with `\0`, so
// rewrite the prefix, e.g. `\0123` -> `\123` -> literal value "S"
match = strings.Replace(match, `\0`, `\`, 1)
}
// use Go to unquote (unescape) the literal
// see https://go.dev/ref/spec#Rune_literals
//
// NOTE: Go supports ADDITIONAL escapes like `\x` & `\u` & `\U`!
// These are NOT supported, which is why we use a regex to find
// only matches we support and then use `UnquoteChar` instead of a
// `Unquote` on the entire value
v, _, _, err := strconv.UnquoteChar(match, '"')
if err != nil {
return match
}
return string(v)
})
return out
}
func (p *parser) indexOfNonSpaceChar(src string) int {
return strings.IndexFunc(src, func(r rune) bool {
if r == '\n' {
p.line++
}
return !unicode.IsSpace(r)
})
}
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
func hasQuotePrefix(src string) (byte, bool) {
if src == "" {
return 0, false
}
switch quote := src[0]; quote {
case prefixDoubleQuote, prefixSingleQuote:
return quote, true // isQuoted
default:
return 0, false
}
}
func isCharFunc(char rune) func(rune) bool {
return func(v rune) bool {
return v == char
}
}
// isSpace reports whether the rune is a space character but not line break character
//
// this differs from unicode.IsSpace, which also applies line break as space
func isSpace(r rune) bool {
switch r {
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
return true
}
return false
}

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package errdefs
import "errors"
var (
// ErrNotFound is returned when an object is not found
ErrNotFound = errors.New("not found")
// ErrInvalid is returned when a compose project is invalid
ErrInvalid = errors.New("invalid compose project")
// ErrUnsupported is returned when a compose project uses an unsupported attribute
ErrUnsupported = errors.New("unsupported attribute")
// ErrIncompatible is returned when a compose project uses an incompatible attribute
ErrIncompatible = errors.New("incompatible attribute")
)
// IsNotFoundError returns true if the unwrapped error is ErrNotFound
func IsNotFoundError(err error) bool {
return errors.Is(err, ErrNotFound)
}
// IsInvalidError returns true if the unwrapped error is ErrInvalid
func IsInvalidError(err error) bool {
return errors.Is(err, ErrInvalid)
}
// IsUnsupportedError returns true if the unwrapped error is ErrUnsupported
func IsUnsupportedError(err error) bool {
return errors.Is(err, ErrUnsupported)
}
// IsUnsupportedError returns true if the unwrapped error is ErrIncompatible
func IsIncompatibleError(err error) bool {
return errors.Is(err, ErrIncompatible)
}

View File

@ -0,0 +1,184 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package format
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/compose-spec/compose-go/v2/types"
)
const endOfSpec = rune(0)
// ParseVolume parses a volume spec without any knowledge of the target platform
func ParseVolume(spec string) (types.ServiceVolumeConfig, error) {
volume := types.ServiceVolumeConfig{}
switch len(spec) {
case 0:
return volume, errors.New("invalid empty volume spec")
case 1, 2:
volume.Target = spec
volume.Type = types.VolumeTypeVolume
return volume, nil
}
var buffer []rune
for _, char := range spec + string(endOfSpec) {
switch {
case isWindowsDrive(buffer, char):
buffer = append(buffer, char)
case char == ':' || char == endOfSpec:
if err := populateFieldFromBuffer(char, buffer, &volume); err != nil {
populateType(&volume)
return volume, fmt.Errorf("invalid spec: %s: %w", spec, err)
}
buffer = nil
default:
buffer = append(buffer, char)
}
}
populateType(&volume)
return volume, nil
}
func isWindowsDrive(buffer []rune, char rune) bool {
return char == ':' && len(buffer) == 1 && unicode.IsLetter(buffer[0])
}
func populateFieldFromBuffer(char rune, buffer []rune, volume *types.ServiceVolumeConfig) error {
strBuffer := string(buffer)
switch {
case len(buffer) == 0:
return errors.New("empty section between colons")
// Anonymous volume
case volume.Source == "" && char == endOfSpec:
volume.Target = strBuffer
return nil
case volume.Source == "":
volume.Source = strBuffer
return nil
case volume.Target == "":
volume.Target = strBuffer
return nil
case char == ':':
return errors.New("too many colons")
}
for _, option := range strings.Split(strBuffer, ",") {
switch option {
case "ro":
volume.ReadOnly = true
case "rw":
volume.ReadOnly = false
case "nocopy":
volume.Volume = &types.ServiceVolumeVolume{NoCopy: true}
default:
if isBindOption(option) {
setBindOption(volume, option)
}
// ignore unknown options
}
}
return nil
}
var Propagations = []string{
types.PropagationRPrivate,
types.PropagationPrivate,
types.PropagationRShared,
types.PropagationShared,
types.PropagationRSlave,
types.PropagationSlave,
}
type setBindOptionFunc func(bind *types.ServiceVolumeBind, option string)
var bindOptions = map[string]setBindOptionFunc{
types.PropagationRPrivate: setBindPropagation,
types.PropagationPrivate: setBindPropagation,
types.PropagationRShared: setBindPropagation,
types.PropagationShared: setBindPropagation,
types.PropagationRSlave: setBindPropagation,
types.PropagationSlave: setBindPropagation,
types.SELinuxShared: setBindSELinux,
types.SELinuxPrivate: setBindSELinux,
}
func setBindPropagation(bind *types.ServiceVolumeBind, option string) {
bind.Propagation = option
}
func setBindSELinux(bind *types.ServiceVolumeBind, option string) {
bind.SELinux = option
}
func isBindOption(option string) bool {
_, ok := bindOptions[option]
return ok
}
func setBindOption(volume *types.ServiceVolumeConfig, option string) {
if volume.Bind == nil {
volume.Bind = &types.ServiceVolumeBind{}
}
bindOptions[option](volume.Bind, option)
}
func populateType(volume *types.ServiceVolumeConfig) {
if isFilePath(volume.Source) {
volume.Type = types.VolumeTypeBind
if volume.Bind == nil {
volume.Bind = &types.ServiceVolumeBind{}
}
// For backward compatibility with docker-compose legacy, using short notation involves
// bind will create missing host path
volume.Bind.CreateHostPath = true
} else {
volume.Type = types.VolumeTypeVolume
if volume.Volume == nil {
volume.Volume = &types.ServiceVolumeVolume{}
}
}
}
func isFilePath(source string) bool {
if source == "" {
return false
}
switch source[0] {
case '.', '/', '~':
return true
}
// windows named pipes
if strings.HasPrefix(source, `\\`) {
return true
}
first, nextIndex := utf8.DecodeRuneInString(source)
if len(source) <= nextIndex {
return false
}
return isWindowsDrive([]rune{first}, rune(source[nextIndex]))
}

View File

@ -0,0 +1,111 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/utils"
"golang.org/x/exp/slices"
)
// graph represents project as service dependencies
type graph[T any] struct {
vertices map[string]*vertex[T]
}
// vertex represents a service in the dependencies structure
type vertex[T any] struct {
key string
service *T
children map[string]*vertex[T]
parents map[string]*vertex[T]
}
func (g *graph[T]) addVertex(name string, service T) {
g.vertices[name] = &vertex[T]{
key: name,
service: &service,
parents: map[string]*vertex[T]{},
children: map[string]*vertex[T]{},
}
}
func (g *graph[T]) addEdge(src, dest string) {
g.vertices[src].children[dest] = g.vertices[dest]
g.vertices[dest].parents[src] = g.vertices[src]
}
func (g *graph[T]) roots() []*vertex[T] {
var res []*vertex[T]
for _, v := range g.vertices {
if len(v.parents) == 0 {
res = append(res, v)
}
}
return res
}
func (g *graph[T]) leaves() []*vertex[T] {
var res []*vertex[T]
for _, v := range g.vertices {
if len(v.children) == 0 {
res = append(res, v)
}
}
return res
}
func (g *graph[T]) checkCycle() error {
// iterate on vertices in a name-order to render a predicable error message
// this is required by tests and enforce command reproducibility by user, which otherwise could be confusing
names := utils.MapKeys(g.vertices)
for _, name := range names {
err := searchCycle([]string{name}, g.vertices[name])
if err != nil {
return err
}
}
return nil
}
func searchCycle[T any](path []string, v *vertex[T]) error {
names := utils.MapKeys(v.children)
for _, name := range names {
if i := slices.Index(path, name); i > 0 {
return fmt.Errorf("dependency cycle detected: %s", strings.Join(path[i:], " -> "))
}
ch := v.children[name]
err := searchCycle(append(path, name), ch)
if err != nil {
return err
}
}
return nil
}
// descendents return all descendents for a vertex, might contain duplicates
func (v *vertex[T]) descendents() []string {
var vx []string
for _, n := range v.children {
vx = append(vx, n.key)
vx = append(vx, n.descendents()...)
}
return vx
}

View File

@ -0,0 +1,80 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"context"
"fmt"
"github.com/compose-spec/compose-go/v2/types"
)
// InDependencyOrder walk the service graph an invoke VisitorFn in respect to dependency order
func InDependencyOrder(ctx context.Context, project *types.Project, fn VisitorFn[types.ServiceConfig], options ...func(*Options)) error {
_, err := CollectInDependencyOrder[any](ctx, project, func(ctx context.Context, s string, config types.ServiceConfig) (any, error) {
return nil, fn(ctx, s, config)
}, options...)
return err
}
// CollectInDependencyOrder walk the service graph an invoke CollectorFn in respect to dependency order, then return result for each call
func CollectInDependencyOrder[T any](ctx context.Context, project *types.Project, fn CollectorFn[types.ServiceConfig, T], options ...func(*Options)) (map[string]T, error) {
graph, err := newGraph(project)
if err != nil {
return nil, err
}
t := newTraversal(fn)
for _, option := range options {
option(t.Options)
}
err = walk(ctx, graph, t)
return t.results, err
}
// newGraph creates a service graph from project
func newGraph(project *types.Project) (*graph[types.ServiceConfig], error) {
g := &graph[types.ServiceConfig]{
vertices: map[string]*vertex[types.ServiceConfig]{},
}
for name, s := range project.Services {
g.addVertex(name, s)
}
for name, s := range project.Services {
src := g.vertices[name]
for dep, condition := range s.DependsOn {
dest, ok := g.vertices[dep]
if !ok {
if condition.Required {
if ds, exists := project.DisabledServices[dep]; exists {
return nil, fmt.Errorf("service %q is required by %q but is disabled. Can be enabled by profiles %s", dep, name, ds.Profiles)
}
return nil, fmt.Errorf("service %q depends on unknown service %q", name, dep)
}
delete(s.DependsOn, name)
project.Services[name] = s
continue
}
src.children[dep] = dest
dest.parents[name] = src
}
}
err := g.checkCycle()
return g, err
}

View File

@ -0,0 +1,211 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"context"
"sync"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
)
// CollectorFn executes on each graph vertex based on visit order and return associated value
type CollectorFn[S any, T any] func(context.Context, string, S) (T, error)
// VisitorFn executes on each graph nodes based on visit order
type VisitorFn[S any] func(context.Context, string, S) error
type traversal[S any, T any] struct {
*Options
visitor CollectorFn[S, T]
mu sync.Mutex
status map[string]int
results map[string]T
}
type Options struct {
// inverse reverse the traversal direction
inverse bool
// maxConcurrency limit the concurrent execution of visitorFn while walking the graph
maxConcurrency int
// after marks a set of node as starting points walking the graph
after []string
}
const (
vertexEntered = iota
vertexVisited
)
func newTraversal[S, T any](fn CollectorFn[S, T]) *traversal[S, T] {
return &traversal[S, T]{
Options: &Options{},
status: map[string]int{},
results: map[string]T{},
visitor: fn,
}
}
// WithMaxConcurrency configure traversal to limit concurrency walking graph nodes
func WithMaxConcurrency(max int) func(*Options) {
return func(o *Options) {
o.maxConcurrency = max
}
}
// InReverseOrder configure traversal to walk the graph in reverse dependency order
func InReverseOrder(o *Options) {
o.inverse = true
}
// WithRootNodesAndDown creates a graphTraversal to start from selected nodes
func WithRootNodesAndDown(nodes []string) func(*Options) {
return func(o *Options) {
o.after = nodes
}
}
func walk[S, T any](ctx context.Context, g *graph[S], t *traversal[S, T]) error {
expect := len(g.vertices)
if expect == 0 {
return nil
}
// nodeCh need to allow n=expect writers while reader goroutine could have returned after ctx.Done
nodeCh := make(chan *vertex[S], expect)
defer close(nodeCh)
eg, ctx := errgroup.WithContext(ctx)
if t.maxConcurrency > 0 {
eg.SetLimit(t.maxConcurrency + 1)
}
eg.Go(func() error {
for {
select {
case <-ctx.Done():
return nil
case node := <-nodeCh:
expect--
if expect == 0 {
return nil
}
for _, adj := range t.adjacentNodes(node) {
t.visit(ctx, eg, adj, nodeCh)
}
}
}
})
// select nodes to start walking the graph based on traversal.direction
for _, node := range t.extremityNodes(g) {
t.visit(ctx, eg, node, nodeCh)
}
return eg.Wait()
}
func (t *traversal[S, T]) visit(ctx context.Context, eg *errgroup.Group, node *vertex[S], nodeCh chan *vertex[S]) {
if !t.ready(node) {
// don't visit this service yet as dependencies haven't been visited
return
}
if !t.enter(node) {
// another worker already acquired this node
return
}
eg.Go(func() error {
var (
err error
result T
)
if !t.skip(node) {
result, err = t.visitor(ctx, node.key, *node.service)
}
t.done(node, result)
nodeCh <- node
return err
})
}
func (t *traversal[S, T]) extremityNodes(g *graph[S]) []*vertex[S] {
if t.inverse {
return g.roots()
}
return g.leaves()
}
func (t *traversal[S, T]) adjacentNodes(v *vertex[S]) map[string]*vertex[S] {
if t.inverse {
return v.children
}
return v.parents
}
func (t *traversal[S, T]) ready(v *vertex[S]) bool {
t.mu.Lock()
defer t.mu.Unlock()
depends := v.children
if t.inverse {
depends = v.parents
}
for name := range depends {
if t.status[name] != vertexVisited {
return false
}
}
return true
}
func (t *traversal[S, T]) enter(v *vertex[S]) bool {
t.mu.Lock()
defer t.mu.Unlock()
if _, ok := t.status[v.key]; ok {
return false
}
t.status[v.key] = vertexEntered
return true
}
func (t *traversal[S, T]) done(v *vertex[S], result T) {
t.mu.Lock()
defer t.mu.Unlock()
t.status[v.key] = vertexVisited
t.results[v.key] = result
}
func (t *traversal[S, T]) skip(node *vertex[S]) bool {
if len(t.after) == 0 {
return false
}
if slices.Contains(t.after, node.key) {
return false
}
// is none of our starting node is a descendent, skip visit
ancestors := node.descendents()
for _, name := range t.after {
if slices.Contains(ancestors, name) {
return false
}
}
return true
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package interpolation
import (
"errors"
"fmt"
"os"
"github.com/compose-spec/compose-go/v2/template"
"github.com/compose-spec/compose-go/v2/tree"
)
// Options supported by Interpolate
type Options struct {
// LookupValue from a key
LookupValue LookupValue
// TypeCastMapping maps key paths to functions to cast to a type
TypeCastMapping map[tree.Path]Cast
// Substitution function to use
Substitute func(string, template.Mapping) (string, error)
}
// LookupValue is a function which maps from variable names to values.
// Returns the value as a string and a bool indicating whether
// the value is present, to distinguish between an empty string
// and the absence of a value.
type LookupValue func(key string) (string, bool)
// Cast a value to a new type, or return an error if the value can't be cast
type Cast func(value string) (interface{}, error)
// Interpolate replaces variables in a string with the values from a mapping
func Interpolate(config map[string]interface{}, opts Options) (map[string]interface{}, error) {
if opts.LookupValue == nil {
opts.LookupValue = os.LookupEnv
}
if opts.TypeCastMapping == nil {
opts.TypeCastMapping = make(map[tree.Path]Cast)
}
if opts.Substitute == nil {
opts.Substitute = template.Substitute
}
out := map[string]interface{}{}
for key, value := range config {
interpolatedValue, err := recursiveInterpolate(value, tree.NewPath(key), opts)
if err != nil {
return out, err
}
out[key] = interpolatedValue
}
return out, nil
}
func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (interface{}, error) {
switch value := value.(type) {
case string:
newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue))
if err != nil {
return value, newPathError(path, err)
}
caster, ok := opts.getCasterForPath(path)
if !ok {
return newValue, nil
}
casted, err := caster(newValue)
if err != nil {
return casted, newPathError(path, fmt.Errorf("failed to cast to expected type: %w", err))
}
return casted, nil
case map[string]interface{}:
out := map[string]interface{}{}
for key, elem := range value {
interpolatedElem, err := recursiveInterpolate(elem, path.Next(key), opts)
if err != nil {
return nil, err
}
out[key] = interpolatedElem
}
return out, nil
case []interface{}:
out := make([]interface{}, len(value))
for i, elem := range value {
interpolatedElem, err := recursiveInterpolate(elem, path.Next(tree.PathMatchList), opts)
if err != nil {
return nil, err
}
out[i] = interpolatedElem
}
return out, nil
default:
return value, nil
}
}
func newPathError(path tree.Path, err error) error {
var ite *template.InvalidTemplateError
switch {
case err == nil:
return nil
case errors.As(err, &ite):
return fmt.Errorf(
"invalid interpolation format for %s.\nYou may need to escape any $ with another $.\n%s",
path, ite.Template)
default:
return fmt.Errorf("error while interpolating %s: %w", path, err)
}
}
func (o Options) getCasterForPath(path tree.Path) (Cast, bool) {
for pattern, caster := range o.TypeCastMapping {
if path.Matches(pattern) {
return caster, true
}
}
return nil, false
}

View File

@ -0,0 +1,10 @@
# passed through
FOO=foo_from_env_file
ENV.WITH.DOT=ok
ENV_WITH_UNDERSCORE=ok
# overridden in example2.env
BAR=bar_from_env_file
# overridden in full-example.yml
BAZ=baz_from_env_file

View File

@ -0,0 +1,4 @@
BAR=bar_from_env_file_2
# overridden in configDetails.Environment
QUX=quz_from_env_file_2

View File

@ -0,0 +1,176 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"context"
"fmt"
"path/filepath"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/override"
"github.com/compose-spec/compose-go/v2/types"
)
func ApplyExtends(ctx context.Context, dict map[string]any, opts *Options, tracker *cycleTracker, post ...PostProcessor) error {
a, ok := dict["services"]
if !ok {
return nil
}
services, ok := a.(map[string]any)
if !ok {
return fmt.Errorf("services must be a mapping")
}
for name := range services {
merged, err := applyServiceExtends(ctx, name, services, opts, tracker, post...)
if err != nil {
return err
}
services[name] = merged
}
dict["services"] = services
return nil
}
func applyServiceExtends(ctx context.Context, name string, services map[string]any, opts *Options, tracker *cycleTracker, post ...PostProcessor) (any, error) {
s := services[name]
if s == nil {
return nil, nil
}
service, ok := s.(map[string]any)
if !ok {
return nil, fmt.Errorf("services.%s must be a mapping", name)
}
extends, ok := service["extends"]
if !ok {
return s, nil
}
filename := ctx.Value(consts.ComposeFileKey{}).(string)
tracker, err := tracker.Add(filename, name)
if err != nil {
return nil, err
}
var (
ref string
file any
)
switch v := extends.(type) {
case map[string]any:
ref = v["service"].(string)
file = v["file"]
case string:
ref = v
}
var base any
if file != nil {
path := file.(string)
services, err = getExtendsBaseFromFile(ctx, ref, path, opts, tracker)
if err != nil {
return nil, err
}
} else {
_, ok := services[ref]
if !ok {
return nil, fmt.Errorf("cannot extend service %q in %s: service not found", name, filename)
}
}
// recursively apply `extends`
base, err = applyServiceExtends(ctx, ref, services, opts, tracker, post...)
if err != nil {
return nil, err
}
if base == nil {
return service, nil
}
source := deepClone(base).(map[string]any)
for _, processor := range post {
processor.Apply(map[string]any{
"services": map[string]any{
name: source,
},
})
}
merged, err := override.ExtendService(source, service)
if err != nil {
return nil, err
}
delete(merged, "extends")
return merged, nil
}
func getExtendsBaseFromFile(ctx context.Context, name string, path string, opts *Options, ct *cycleTracker) (map[string]any, error) {
for _, loader := range opts.ResourceLoaders {
if !loader.Accept(path) {
continue
}
local, err := loader.Load(ctx, path)
if err != nil {
return nil, err
}
localdir := filepath.Dir(local)
relworkingdir := loader.Dir(path)
extendsOpts := opts.clone()
// replace localResourceLoader with a new flavour, using extended file base path
extendsOpts.ResourceLoaders = append(opts.RemoteResourceLoaders(), localResourceLoader{
WorkingDir: localdir,
})
extendsOpts.ResolvePaths = true
extendsOpts.SkipNormalization = true
extendsOpts.SkipConsistencyCheck = true
extendsOpts.SkipInclude = true
extendsOpts.SkipExtends = true // we manage extends recursively based on raw service definition
extendsOpts.SkipValidation = true // we validate the merge result
source, err := loadYamlModel(ctx, types.ConfigDetails{
WorkingDir: relworkingdir,
ConfigFiles: []types.ConfigFile{
{Filename: local},
},
}, extendsOpts, ct, nil)
if err != nil {
return nil, err
}
services := source["services"].(map[string]any)
_, ok := services[name]
if !ok {
return nil, fmt.Errorf("cannot extend service %q in %s: service not found", name, path)
}
return services, nil
}
return nil, fmt.Errorf("cannot read %s", path)
}
func deepClone(value any) any {
switch v := value.(type) {
case []any:
cp := make([]any, len(v))
for i, e := range v {
cp[i] = deepClone(e)
}
return cp
case map[string]any:
cp := make(map[string]any, len(v))
for k, e := range v {
cp[k] = deepClone(e)
}
return cp
default:
return value
}
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
// fixEmptyNotNull is a workaround for https://github.com/xeipuuv/gojsonschema/issues/141
// as go-yaml `[]` will load as a `[]any(nil)`, which is not the same as an empty array
func fixEmptyNotNull(value any) interface{} {
switch v := value.(type) {
case []any:
if v == nil {
return []any{}
}
for i, e := range v {
v[i] = fixEmptyNotNull(e)
}
case map[string]any:
for k, e := range v {
v[k] = fixEmptyNotNull(e)
}
}
return value
}

View File

@ -0,0 +1,453 @@
name: full_example_project_name
services:
bar:
build:
dockerfile_inline: |
FROM alpine
RUN echo "hello" > /world.txt
foo:
annotations:
- com.example.foo=bar
build:
context: ./dir
dockerfile: Dockerfile
args:
foo: bar
ssh:
- default
target: foo
network: foo
cache_from:
- foo
- bar
labels: [FOO=BAR]
additional_contexts:
foo: ./bar
secrets:
- secret1
- source: secret2
target: my_secret
uid: '103'
gid: '103'
mode: 0440
tags:
- foo:v1.0.0
- docker.io/username/foo:my-other-tag
- ${COMPOSE_PROJECT_NAME}:1.0.0
platforms:
- linux/amd64
- linux/arm64
cap_add:
- ALL
cap_drop:
- NET_ADMIN
- SYS_ADMIN
cgroup_parent: m-executor-abcd
# String or list
command: bundle exec thin -p 3000
# command: ["bundle", "exec", "thin", "-p", "3000"]
configs:
- config1
- source: config2
target: /my_config
uid: '103'
gid: '103'
mode: 0440
container_name: my-web-container
depends_on:
- db
- redis
deploy:
mode: replicated
replicas: 6
labels: [FOO=BAR]
rollback_config:
parallelism: 3
delay: 10s
failure_action: continue
monitor: 60s
max_failure_ratio: 0.3
order: start-first
update_config:
parallelism: 3
delay: 10s
failure_action: continue
monitor: 60s
max_failure_ratio: 0.3
order: start-first
resources:
limits:
cpus: '0.001'
memory: 50M
reservations:
cpus: '0.0001'
memory: 20M
generic_resources:
- discrete_resource_spec:
kind: 'gpu'
value: 2
- discrete_resource_spec:
kind: 'ssd'
value: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
placement:
constraints: [node=foo]
max_replicas_per_node: 5
preferences:
- spread: node.labels.az
endpoint_mode: dnsrr
device_cgroup_rules:
- "c 1:3 mr"
- "a 7:* rmw"
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0"
# String or list
# dns: 8.8.8.8
dns:
- 8.8.8.8
- 9.9.9.9
# String or list
# dns_search: example.com
dns_search:
- dc1.example.com
- dc2.example.com
domainname: foo.com
# String or list
# entrypoint: /code/entrypoint.sh -p 3000
entrypoint: ["/code/entrypoint.sh", "-p", "3000"]
# String or list
# env_file: .env
env_file:
- ./example1.env
- path: ./example2.env
required: false
# Mapping or list
# Mapping values can be strings, numbers or null
# Booleans are not allowed - must be quoted
environment:
BAZ: baz_from_service_def
QUX:
# environment:
# - RACK_ENV=development
# - SHOW=true
# - SESSION_SECRET
# Items can be strings or numbers
expose:
- "3000"
- 8000
external_links:
- redis_1
- project_db_1:mysql
- project_db_1:postgresql
# Mapping or list
# Mapping values must be strings
# extra_hosts:
# somehost: "162.242.195.82"
# otherhost: "50.31.209.229"
extra_hosts:
- "otherhost:50.31.209.229"
- "somehost:162.242.195.82"
hostname: foo
healthcheck:
test: echo "hello world"
interval: 10s
timeout: 1s
retries: 5
start_period: 15s
start_interval: 5s
# Any valid image reference - repo, tag, id, sha
image: redis
# image: ubuntu:14.04
# image: tutum/influxdb
# image: example-registry.com:4000/postgresql
# image: a4bc65fd
# image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d
ipc: host
uts: host
# Mapping or list
# Mapping values can be strings, numbers or null
labels:
com.example.description: "Accounting webapp"
com.example.number: 42
com.example.empty-label:
# labels:
# - "com.example.description=Accounting webapp"
# - "com.example.number=42"
# - "com.example.empty-label"
links:
- db
- db:database
- redis
logging:
driver: syslog
options:
syslog-address: "tcp://192.168.0.42:123"
mac_address: 02:42:ac:11:65:43
# network_mode: "bridge"
# network_mode: "host"
# network_mode: "none"
# Use the network mode of an arbitrary container from another service
# network_mode: "service:db"
# Use the network mode of another container, specified by name or id
# network_mode: "container:some-container"
network_mode: "container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b"
networks:
some-network:
aliases:
- alias1
- alias3
other-network:
ipv4_address: 172.16.238.10
ipv6_address: 2001:3984:3989::10
mac_address: 02:42:72:98:65:08
other-other-network:
pid: "host"
ports:
- 3000
- "3001-3005"
- "8000:8000"
- "9090-9091:8080-8081"
- "49100:22"
- "127.0.0.1:8001:8001"
- "127.0.0.1:5000-5010:5000-5010"
privileged: true
read_only: true
restart: always
secrets:
- secret1
- source: secret2
target: my_secret
uid: '103'
gid: '103'
mode: 0440
security_opt:
- label=level:s0:c100,c200
- label=type:svirt_apache_t
stdin_open: true
stop_grace_period: 20s
stop_signal: SIGUSR1
storage_opt:
size: "20G"
sysctls:
net.core.somaxconn: 1024
net.ipv4.tcp_syncookies: 0
# String or list
# tmpfs: /run
tmpfs:
- /run
- /tmp
tty: true
ulimits:
# Single number or mapping with soft + hard limits
nproc: 65535
nofile:
soft: 20000
hard: 40000
user: someone
volumes:
# Just specify a path and let the Engine create a volume
- /var/lib/anonymous
# Specify an absolute path mapping
- /opt/data:/var/lib/data
# Path on the host, relative to the Compose file
- .:/code
- ./static:/var/www/html
# User-relative path
- ~/configs:/etc/configs:ro
# Named volume
- datavolume:/var/lib/volume
- type: bind
source: ./opt
target: /opt/cached
consistency: cached
- type: tmpfs
target: /opt/tmpfs
tmpfs:
size: 10000
working_dir: /code
x-bar: baz
x-foo: bar
networks:
# Entries can be null, which specifies simply that a network
# called "{project name}_some-network" should be created and
# use the default driver
some-network:
other-network:
driver: overlay
driver_opts:
# Values can be strings or numbers
foo: "bar"
baz: 1
ipam:
driver: overlay
# driver_opts:
# # Values can be strings or numbers
# com.docker.network.enable_ipv6: "true"
# com.docker.network.numeric_value: 1
config:
- subnet: 172.28.0.0/16
ip_range: 172.28.5.0/24
gateway: 172.28.5.254
aux_addresses:
host1: 172.28.1.5
host2: 172.28.1.6
host3: 172.28.1.7
- subnet: 2001:3984:3989::/64
gateway: 2001:3984:3989::1
labels:
foo: bar
external-network:
# Specifies that a pre-existing network called "external-network"
# can be referred to within this file as "external-network"
external: true
other-external-network:
# Specifies that a pre-existing network called "my-cool-network"
# can be referred to within this file as "other-external-network"
external:
name: my-cool-network
x-bar: baz
x-foo: bar
volumes:
# Entries can be null, which specifies simply that a volume
# called "{project name}_some-volume" should be created and
# use the default driver
some-volume:
other-volume:
driver: flocker
driver_opts:
# Values can be strings or numbers
foo: "bar"
baz: 1
labels:
foo: bar
another-volume:
name: "user_specified_name"
driver: vsphere
driver_opts:
# Values can be strings or numbers
foo: "bar"
baz: 1
external-volume:
# Specifies that a pre-existing volume called "external-volume"
# can be referred to within this file as "external-volume"
external: true
other-external-volume:
# Specifies that a pre-existing volume called "my-cool-volume"
# can be referred to within this file as "other-external-volume"
# This example uses the deprecated "volume.external.name" (replaced by "volume.name")
external:
name: my-cool-volume
external-volume3:
# Specifies that a pre-existing volume called "this-is-volume3"
# can be referred to within this file as "external-volume3"
name: this-is-volume3
external: true
x-bar: baz
x-foo: bar
configs:
config1:
file: ./config_data
labels:
foo: bar
config2:
external:
name: my_config
config3:
external: true
config4:
name: foo
file: ~/config_data
x-bar: baz
x-foo: bar
secrets:
secret1:
file: ./secret_data
labels:
foo: bar
secret2:
external:
name: my_secret
secret3:
external: true
secret4:
name: bar
environment: BAR
x-bar: baz
x-foo: bar
secret5:
file: /abs/secret_data
x-bar: baz
x-foo: bar
x-nested:
bar: baz
foo: bar

View File

@ -0,0 +1,155 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/compose-spec/compose-go/v2/dotenv"
interp "github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/types"
)
// loadIncludeConfig parse the require config from raw yaml
func loadIncludeConfig(source any) ([]types.IncludeConfig, error) {
if source == nil {
return nil, nil
}
var requires []types.IncludeConfig
err := Transform(source, &requires)
return requires, err
}
func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model map[string]any, options *Options, included []string) error {
includeConfig, err := loadIncludeConfig(model["include"])
if err != nil {
return err
}
for _, r := range includeConfig {
for i, p := range r.Path {
for _, loader := range options.ResourceLoaders {
if loader.Accept(p) {
path, err := loader.Load(ctx, p)
if err != nil {
return err
}
p = path
break
}
}
r.Path[i] = absPath(configDetails.WorkingDir, p)
}
mainFile := r.Path[0]
for _, f := range included {
if f == mainFile {
included = append(included, mainFile)
return fmt.Errorf("include cycle detected:\n%s\n include %s", included[0], strings.Join(included[1:], "\n include "))
}
}
if r.ProjectDirectory == "" {
r.ProjectDirectory = filepath.Dir(mainFile)
}
loadOptions := options.clone()
loadOptions.ResolvePaths = true
loadOptions.SkipNormalization = true
loadOptions.SkipConsistencyCheck = true
if len(r.EnvFile) == 0 {
f := filepath.Join(r.ProjectDirectory, ".env")
if s, err := os.Stat(f); err == nil && !s.IsDir() {
r.EnvFile = types.StringList{f}
}
}
envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.EnvFile)
if err != nil {
return err
}
config := types.ConfigDetails{
WorkingDir: r.ProjectDirectory,
ConfigFiles: types.ToConfigFiles(r.Path),
Environment: configDetails.Environment.Clone().Merge(envFromFile),
}
loadOptions.Interpolate = &interp.Options{
Substitute: options.Interpolate.Substitute,
LookupValue: config.LookupEnv,
TypeCastMapping: options.Interpolate.TypeCastMapping,
}
imported, err := loadYamlModel(ctx, config, loadOptions, &cycleTracker{}, included)
if err != nil {
return err
}
err = importResources(imported, model)
if err != nil {
return err
}
}
delete(model, "include")
return nil
}
// importResources import into model all resources defined by imported, and report error on conflict
func importResources(source map[string]any, target map[string]any) error {
if err := importResource(source, target, "services"); err != nil {
return err
}
if err := importResource(source, target, "volumes"); err != nil {
return err
}
if err := importResource(source, target, "networks"); err != nil {
return err
}
if err := importResource(source, target, "secrets"); err != nil {
return err
}
if err := importResource(source, target, "configs"); err != nil {
return err
}
return nil
}
func importResource(source map[string]any, target map[string]any, key string) error {
from := source[key]
if from != nil {
var to map[string]any
if v, ok := target[key]; ok {
to = v.(map[string]any)
} else {
to = map[string]any{}
}
for name, a := range from.(map[string]any) {
if conflict, ok := to[name]; ok {
if reflect.DeepEqual(a, conflict) {
continue
}
return fmt.Errorf("%s.%s conflicts with imported resource", key, name)
}
to[name] = a
}
target[key] = to
}
return nil
}

View File

@ -0,0 +1,117 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"fmt"
"strconv"
"strings"
interp "github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/sirupsen/logrus"
)
var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
servicePath("configs", tree.PathMatchList, "mode"): toInt,
servicePath("cpu_count"): toInt64,
servicePath("cpu_percent"): toFloat,
servicePath("cpu_period"): toInt64,
servicePath("cpu_quota"): toInt64,
servicePath("cpu_rt_period"): toInt64,
servicePath("cpu_rt_runtime"): toInt64,
servicePath("cpus"): toFloat32,
servicePath("cpu_shares"): toInt64,
servicePath("init"): toBoolean,
servicePath("deploy", "replicas"): toInt,
servicePath("deploy", "update_config", "parallelism"): toInt,
servicePath("deploy", "update_config", "max_failure_ratio"): toFloat,
servicePath("deploy", "rollback_config", "parallelism"): toInt,
servicePath("deploy", "rollback_config", "max_failure_ratio"): toFloat,
servicePath("deploy", "restart_policy", "max_attempts"): toInt,
servicePath("deploy", "placement", "max_replicas_per_node"): toInt,
servicePath("healthcheck", "retries"): toInt,
servicePath("healthcheck", "disable"): toBoolean,
servicePath("oom_kill_disable"): toBoolean,
servicePath("oom_score_adj"): toInt64,
servicePath("pids_limit"): toInt64,
servicePath("ports", tree.PathMatchList, "target"): toInt,
servicePath("privileged"): toBoolean,
servicePath("read_only"): toBoolean,
servicePath("scale"): toInt,
servicePath("secrets", tree.PathMatchList, "mode"): toInt,
servicePath("stdin_open"): toBoolean,
servicePath("tty"): toBoolean,
servicePath("ulimits", tree.PathMatchAll): toInt,
servicePath("ulimits", tree.PathMatchAll, "hard"): toInt,
servicePath("ulimits", tree.PathMatchAll, "soft"): toInt,
servicePath("volumes", tree.PathMatchList, "read_only"): toBoolean,
servicePath("volumes", tree.PathMatchList, "volume", "nocopy"): toBoolean,
iPath("networks", tree.PathMatchAll, "external"): toBoolean,
iPath("networks", tree.PathMatchAll, "internal"): toBoolean,
iPath("networks", tree.PathMatchAll, "attachable"): toBoolean,
iPath("networks", tree.PathMatchAll, "enable_ipv6"): toBoolean,
iPath("volumes", tree.PathMatchAll, "external"): toBoolean,
iPath("secrets", tree.PathMatchAll, "external"): toBoolean,
iPath("configs", tree.PathMatchAll, "external"): toBoolean,
}
func iPath(parts ...string) tree.Path {
return tree.NewPath(parts...)
}
func servicePath(parts ...string) tree.Path {
return iPath(append([]string{"services", tree.PathMatchAll}, parts...)...)
}
func toInt(value string) (interface{}, error) {
return strconv.Atoi(value)
}
func toInt64(value string) (interface{}, error) {
return strconv.ParseInt(value, 10, 64)
}
func toFloat(value string) (interface{}, error) {
return strconv.ParseFloat(value, 64)
}
func toFloat32(value string) (interface{}, error) {
f, err := strconv.ParseFloat(value, 32)
if err != nil {
return nil, err
}
return float32(f), nil
}
// should match http://yaml.org/type/bool.html
func toBoolean(value string) (interface{}, error) {
switch strings.ToLower(value) {
case "true":
return true, nil
case "false":
return false, nil
case "y", "yes", "on":
logrus.Warnf("%q for boolean is not supported by YAML 1.2, please use `true`", value)
return true, nil
case "n", "no", "off":
logrus.Warnf("%q for boolean is not supported by YAML 1.2, please use `false`", value)
return false, nil
default:
return nil, fmt.Errorf("invalid boolean: %s", value)
}
}

View File

@ -0,0 +1,742 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/compose-spec/compose-go/v2/consts"
interp "github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/override"
"github.com/compose-spec/compose-go/v2/paths"
"github.com/compose-spec/compose-go/v2/schema"
"github.com/compose-spec/compose-go/v2/template"
"github.com/compose-spec/compose-go/v2/transform"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/validation"
"github.com/mitchellh/mapstructure"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// Options supported by Load
type Options struct {
// Skip schema validation
SkipValidation bool
// Skip interpolation
SkipInterpolation bool
// Skip normalization
SkipNormalization bool
// Resolve path
ResolvePaths bool
// Convert Windows path
ConvertWindowsPaths bool
// Skip consistency check
SkipConsistencyCheck bool
// Skip extends
SkipExtends bool
// SkipInclude will ignore `include` and only load model from file(s) set by ConfigDetails
SkipInclude bool
// SkipResolveEnvironment will ignore computing `environment` for services
SkipResolveEnvironment bool
// Interpolation options
Interpolate *interp.Options
// Discard 'env_file' entries after resolving to 'environment' section
discardEnvFiles bool
// Set project projectName
projectName string
// Indicates when the projectName was imperatively set or guessed from path
projectNameImperativelySet bool
// Profiles set profiles to enable
Profiles []string
// ResourceLoaders manages support for remote resources
ResourceLoaders []ResourceLoader
}
// ResourceLoader is a plugable remote resource resolver
type ResourceLoader interface {
// Accept returns `true` is the resource reference matches ResourceLoader supported protocol(s)
Accept(path string) bool
// Load returns the path to a local copy of remote resource identified by `path`.
Load(ctx context.Context, path string) (string, error)
// Dir computes path to resource"s parent folder, made relative if possible
Dir(path string) string
}
// RemoteResourceLoaders excludes localResourceLoader from ResourceLoaders
func (o Options) RemoteResourceLoaders() []ResourceLoader {
var loaders []ResourceLoader
for i, loader := range o.ResourceLoaders {
if _, ok := loader.(localResourceLoader); ok {
if i != len(o.ResourceLoaders)-1 {
logrus.Warning("misconfiguration of ResourceLoaders: localResourceLoader should be last")
}
continue
}
loaders = append(loaders, loader)
}
return loaders
}
type localResourceLoader struct {
WorkingDir string
}
func (l localResourceLoader) abs(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(l.WorkingDir, p)
}
func (l localResourceLoader) Accept(p string) bool {
_, err := os.Stat(l.abs(p))
return err == nil
}
func (l localResourceLoader) Load(_ context.Context, p string) (string, error) {
return l.abs(p), nil
}
func (l localResourceLoader) Dir(path string) string {
path = l.abs(filepath.Dir(path))
rel, err := filepath.Rel(l.WorkingDir, path)
if err != nil {
return path
}
return rel
}
func (o *Options) clone() *Options {
return &Options{
SkipValidation: o.SkipValidation,
SkipInterpolation: o.SkipInterpolation,
SkipNormalization: o.SkipNormalization,
ResolvePaths: o.ResolvePaths,
ConvertWindowsPaths: o.ConvertWindowsPaths,
SkipConsistencyCheck: o.SkipConsistencyCheck,
SkipExtends: o.SkipExtends,
SkipInclude: o.SkipInclude,
Interpolate: o.Interpolate,
discardEnvFiles: o.discardEnvFiles,
projectName: o.projectName,
projectNameImperativelySet: o.projectNameImperativelySet,
Profiles: o.Profiles,
ResourceLoaders: o.ResourceLoaders,
}
}
func (o *Options) SetProjectName(name string, imperativelySet bool) {
o.projectName = name
o.projectNameImperativelySet = imperativelySet
}
func (o Options) GetProjectName() (string, bool) {
return o.projectName, o.projectNameImperativelySet
}
// serviceRef identifies a reference to a service. It's used to detect cyclic
// references in "extends".
type serviceRef struct {
filename string
service string
}
type cycleTracker struct {
loaded []serviceRef
}
func (ct *cycleTracker) Add(filename, service string) (*cycleTracker, error) {
toAdd := serviceRef{filename: filename, service: service}
for _, loaded := range ct.loaded {
if toAdd == loaded {
// Create an error message of the form:
// Circular reference:
// service-a in docker-compose.yml
// extends service-b in docker-compose.yml
// extends service-a in docker-compose.yml
errLines := []string{
"Circular reference:",
fmt.Sprintf(" %s in %s", ct.loaded[0].service, ct.loaded[0].filename),
}
for _, service := range append(ct.loaded[1:], toAdd) {
errLines = append(errLines, fmt.Sprintf(" extends %s in %s", service.service, service.filename))
}
return nil, errors.New(strings.Join(errLines, "\n"))
}
}
var branch []serviceRef
branch = append(branch, ct.loaded...)
branch = append(branch, toAdd)
return &cycleTracker{
loaded: branch,
}, nil
}
// WithDiscardEnvFiles sets the Options to discard the `env_file` section after resolving to
// the `environment` section
func WithDiscardEnvFiles(opts *Options) {
opts.discardEnvFiles = true
}
// WithSkipValidation sets the Options to skip validation when loading sections
func WithSkipValidation(opts *Options) {
opts.SkipValidation = true
}
// WithProfiles sets profiles to be activated
func WithProfiles(profiles []string) func(*Options) {
return func(opts *Options) {
opts.Profiles = profiles
}
}
// ParseYAML reads the bytes from a file, parses the bytes into a mapping
// structure, and returns it.
func ParseYAML(source []byte) (map[string]interface{}, error) {
r := bytes.NewReader(source)
decoder := yaml.NewDecoder(r)
m, _, err := parseYAML(decoder)
return m, err
}
// PostProcessor is used to tweak compose model based on metadata extracted during yaml Unmarshal phase
// that hardly can be implemented using go-yaml and mapstructure
type PostProcessor interface {
yaml.Unmarshaler
// Apply changes to compose model based on recorder metadata
Apply(interface{}) error
}
func parseYAML(decoder *yaml.Decoder) (map[string]interface{}, PostProcessor, error) {
var cfg interface{}
processor := ResetProcessor{target: &cfg}
if err := decoder.Decode(&processor); err != nil {
return nil, nil, err
}
stringMap, ok := cfg.(map[string]interface{})
if ok {
converted, err := convertToStringKeysRecursive(stringMap, "")
if err != nil {
return nil, nil, err
}
return converted.(map[string]interface{}), &processor, nil
}
cfgMap, ok := cfg.(map[interface{}]interface{})
if !ok {
return nil, nil, errors.New("Top-level object must be a mapping")
}
converted, err := convertToStringKeysRecursive(cfgMap, "")
if err != nil {
return nil, nil, err
}
return converted.(map[string]interface{}), &processor, nil
}
// Load reads a ConfigDetails and returns a fully loaded configuration.
// Deprecated: use LoadWithContext.
func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
return LoadWithContext(context.Background(), configDetails, options...)
}
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration
func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
if len(configDetails.ConfigFiles) < 1 {
return nil, errors.New("No files specified")
}
opts := &Options{
Interpolate: &interp.Options{
Substitute: template.Substitute,
LookupValue: configDetails.LookupEnv,
TypeCastMapping: interpolateTypeCastMapping,
},
ResolvePaths: true,
}
for _, op := range options {
op(opts)
}
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
projectName, err := projectName(configDetails, opts)
if err != nil {
return nil, err
}
opts.projectName = projectName
// TODO(milas): this should probably ALWAYS set (overriding any existing)
if _, ok := configDetails.Environment[consts.ComposeProjectName]; !ok && projectName != "" {
if configDetails.Environment == nil {
configDetails.Environment = map[string]string{}
}
configDetails.Environment[consts.ComposeProjectName] = projectName
}
return load(ctx, configDetails, opts, nil)
}
func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Options, ct *cycleTracker, included []string) (map[string]interface{}, error) {
var (
dict = map[string]interface{}{}
err error
)
for _, file := range config.ConfigFiles {
fctx := context.WithValue(ctx, consts.ComposeFileKey{}, file.Filename)
if len(file.Content) == 0 && file.Config == nil {
content, err := os.ReadFile(file.Filename)
if err != nil {
return nil, err
}
file.Content = content
}
processRawYaml := func(raw interface{}, processors ...PostProcessor) error {
converted, err := convertToStringKeysRecursive(raw, "")
if err != nil {
return err
}
cfg, ok := converted.(map[string]interface{})
if !ok {
return errors.New("Top-level object must be a mapping")
}
if opts.Interpolate != nil && !opts.SkipInterpolation {
cfg, err = interp.Interpolate(cfg, *opts.Interpolate)
if err != nil {
return err
}
}
fixEmptyNotNull(cfg)
if !opts.SkipExtends {
err = ApplyExtends(fctx, cfg, opts, ct, processors...)
if err != nil {
return err
}
}
for _, processor := range processors {
if err := processor.Apply(dict); err != nil {
return err
}
}
dict, err = override.Merge(dict, cfg)
if err != nil {
return err
}
dict, err = override.EnforceUnicity(dict)
if err != nil {
return err
}
if !opts.SkipValidation {
if err := schema.Validate(dict); err != nil {
return fmt.Errorf("validating %s: %w", file.Filename, err)
}
}
return err
}
if file.Config == nil {
r := bytes.NewReader(file.Content)
decoder := yaml.NewDecoder(r)
for {
var raw interface{}
processor := &ResetProcessor{target: &raw}
err := decoder.Decode(processor)
if err != nil && errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if err := processRawYaml(raw, processor); err != nil {
return nil, err
}
}
} else {
if err := processRawYaml(file.Config); err != nil {
return nil, err
}
}
}
dict, err = transform.Canonical(dict)
if err != nil {
return nil, err
}
if !opts.SkipInclude {
included = append(included, config.ConfigFiles[0].Filename)
err = ApplyInclude(ctx, config, dict, opts, included)
if err != nil {
return nil, err
}
}
if !opts.SkipValidation {
if err := validation.Validate(dict); err != nil {
return nil, err
}
}
if opts.ResolvePaths {
var remotes []paths.RemoteResource
for _, loader := range opts.RemoteResourceLoaders() {
remotes = append(remotes, loader.Accept)
}
err = paths.ResolveRelativePaths(dict, config.WorkingDir, remotes)
if err != nil {
return nil, err
}
}
return dict, nil
}
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (*types.Project, error) {
mainFile := configDetails.ConfigFiles[0].Filename
for _, f := range loaded {
if f == mainFile {
loaded = append(loaded, mainFile)
return nil, fmt.Errorf("include cycle detected:\n%s\n include %s", loaded[0], strings.Join(loaded[1:], "\n include "))
}
}
loaded = append(loaded, mainFile)
includeRefs := make(map[string][]types.IncludeConfig)
dict, err := loadYamlModel(ctx, configDetails, opts, &cycleTracker{}, nil)
if err != nil {
return nil, err
}
if len(dict) == 0 {
return nil, errors.New("empty compose file")
}
project := &types.Project{
Name: opts.projectName,
WorkingDir: configDetails.WorkingDir,
Environment: configDetails.Environment,
}
delete(dict, "name") // project name set by yaml must be identified by caller as opts.projectName
dict = groupXFieldsIntoExtensions(dict, tree.NewPath())
err = Transform(dict, project)
if err != nil {
return nil, err
}
if len(includeRefs) != 0 {
project.IncludeReferences = includeRefs
}
if !opts.SkipNormalization {
err := Normalize(project)
if err != nil {
return nil, err
}
}
if opts.ConvertWindowsPaths {
for i, service := range project.Services {
for j, volume := range service.Volumes {
service.Volumes[j] = convertVolumePath(volume)
}
project.Services[i] = service
}
}
if !opts.SkipConsistencyCheck {
err := checkConsistency(project)
if err != nil {
return nil, err
}
}
if project, err = project.WithProfiles(opts.Profiles); err != nil {
return nil, err
}
if !opts.SkipResolveEnvironment {
project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
if err != nil {
return nil, err
}
}
return project, nil
}
func InvalidProjectNameErr(v string) error {
return fmt.Errorf(
"invalid project name %q: must consist only of lowercase alphanumeric characters, hyphens, and underscores as well as start with a letter or number",
v,
)
}
// projectName determines the canonical name to use for the project considering
// the loader Options as well as `name` fields in Compose YAML fields (which
// also support interpolation).
//
// TODO(milas): restructure loading so that we don't need to re-parse the YAML
// here, as it's both wasteful and makes this code error-prone.
func projectName(details types.ConfigDetails, opts *Options) (string, error) {
projectName, projectNameImperativelySet := opts.GetProjectName()
// if user did NOT provide a name explicitly, then see if one is defined
// in any of the config files
if !projectNameImperativelySet {
var pjNameFromConfigFile string
for _, configFile := range details.ConfigFiles {
content := configFile.Content
if content == nil {
// This can be hit when Filename is set but Content is not. One
// example is when using ToConfigFiles().
d, err := os.ReadFile(configFile.Filename)
if err != nil {
return "", fmt.Errorf("failed to read file %q: %w", configFile.Filename, err)
}
content = d
}
yml, err := ParseYAML(content)
if err != nil {
// HACK: the way that loading is currently structured, this is
// a duplicative parse just for the `name`. if it fails, we
// give up but don't return the error, knowing that it'll get
// caught downstream for us
return "", nil
}
if val, ok := yml["name"]; ok && val != "" {
sVal, ok := val.(string)
if !ok {
// HACK: see above - this is a temporary parsed version
// that hasn't been schema-validated, but we don't want
// to be the ones to actually report that, so give up,
// knowing that it'll get caught downstream for us
return "", nil
}
pjNameFromConfigFile = sVal
}
}
if !opts.SkipInterpolation {
interpolated, err := interp.Interpolate(
map[string]interface{}{"name": pjNameFromConfigFile},
*opts.Interpolate,
)
if err != nil {
return "", err
}
pjNameFromConfigFile = interpolated["name"].(string)
}
pjNameFromConfigFile = NormalizeProjectName(pjNameFromConfigFile)
if pjNameFromConfigFile != "" {
projectName = pjNameFromConfigFile
}
}
if projectName == "" {
return "", errors.New("project name must not be empty")
}
if NormalizeProjectName(projectName) != projectName {
return "", InvalidProjectNameErr(projectName)
}
return projectName, nil
}
func NormalizeProjectName(s string) string {
r := regexp.MustCompile("[a-z0-9_-]")
s = strings.ToLower(s)
s = strings.Join(r.FindAllString(s, -1), "")
return strings.TrimLeft(s, "_-")
}
var userDefinedKeys = []tree.Path{
"services",
"volumes",
"networks",
"secrets",
"configs",
}
func groupXFieldsIntoExtensions(dict map[string]interface{}, p tree.Path) map[string]interface{} {
extras := map[string]interface{}{}
for key, value := range dict {
skip := false
for _, uk := range userDefinedKeys {
if uk.Matches(p) {
skip = true
break
}
}
if !skip && strings.HasPrefix(key, "x-") {
extras[key] = value
delete(dict, key)
continue
}
switch v := value.(type) {
case map[string]interface{}:
dict[key] = groupXFieldsIntoExtensions(v, p.Next(key))
case []interface{}:
for i, e := range v {
if m, ok := e.(map[string]interface{}); ok {
v[i] = groupXFieldsIntoExtensions(m, p.Next(strconv.Itoa(i)))
}
}
}
}
if len(extras) > 0 {
dict[consts.Extensions] = extras
}
return dict
}
// Transform converts the source into the target struct with compose types transformer
// and the specified transformers if any.
func Transform(source interface{}, target interface{}) error {
data := mapstructure.Metadata{}
config := &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
nameServices,
decoderHook,
cast),
Result: target,
TagName: "yaml",
Metadata: &data,
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
return err
}
return decoder.Decode(source)
}
// nameServices create implicit `name` key for convenience accessing service
func nameServices(from reflect.Value, to reflect.Value) (interface{}, error) {
if to.Type() == reflect.TypeOf(types.Services{}) {
nameK := reflect.ValueOf("name")
iter := from.MapRange()
for iter.Next() {
name := iter.Key()
elem := iter.Value()
elem.Elem().SetMapIndex(nameK, name)
}
}
return from.Interface(), nil
}
// keys need to be converted to strings for jsonschema
func convertToStringKeysRecursive(value interface{}, keyPrefix string) (interface{}, error) {
if mapping, ok := value.(map[string]interface{}); ok {
for key, entry := range mapping {
var newKeyPrefix string
if keyPrefix == "" {
newKeyPrefix = key
} else {
newKeyPrefix = fmt.Sprintf("%s.%s", keyPrefix, key)
}
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
if err != nil {
return nil, err
}
mapping[key] = convertedEntry
}
return mapping, nil
}
if mapping, ok := value.(map[interface{}]interface{}); ok {
dict := make(map[string]interface{})
for key, entry := range mapping {
str, ok := key.(string)
if !ok {
return nil, formatInvalidKeyError(keyPrefix, key)
}
var newKeyPrefix string
if keyPrefix == "" {
newKeyPrefix = str
} else {
newKeyPrefix = fmt.Sprintf("%s.%s", keyPrefix, str)
}
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
if err != nil {
return nil, err
}
dict[str] = convertedEntry
}
return dict, nil
}
if list, ok := value.([]interface{}); ok {
var convertedList []interface{}
for index, entry := range list {
newKeyPrefix := fmt.Sprintf("%s[%d]", keyPrefix, index)
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
if err != nil {
return nil, err
}
convertedList = append(convertedList, convertedEntry)
}
return convertedList, nil
}
return value, nil
}
func formatInvalidKeyError(keyPrefix string, key interface{}) error {
var location string
if keyPrefix == "" {
location = "at top level"
} else {
location = fmt.Sprintf("in %s", keyPrefix)
}
return fmt.Errorf("Non-string key %s: %#v", location, key)
}
// Windows path, c:\\my\\path\\shiny, need to be changed to be compatible with
// the Engine. Volume path are expected to be linux style /c/my/path/shiny/
func convertVolumePath(volume types.ServiceVolumeConfig) types.ServiceVolumeConfig {
volumeName := strings.ToLower(filepath.VolumeName(volume.Source))
if len(volumeName) != 2 {
return volume
}
convertedSource := fmt.Sprintf("/%c%s", volumeName[0], volume.Source[len(volumeName):])
convertedSource = strings.ReplaceAll(convertedSource, "\\", "/")
volume.Source = convertedSource
return volume
}

View File

@ -0,0 +1,79 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"reflect"
"strconv"
)
// comparable to yaml.Unmarshaler, decoder allow a type to define it's own custom logic to convert value
// see https://github.com/mitchellh/mapstructure/pull/294
type decoder interface {
DecodeMapstructure(interface{}) error
}
// see https://github.com/mitchellh/mapstructure/issues/115#issuecomment-735287466
// adapted to support types derived from built-in types, as DecodeMapstructure would not be able to mutate internal
// value, so need to invoke DecodeMapstructure defined by pointer to type
func decoderHook(from reflect.Value, to reflect.Value) (interface{}, error) {
// If the destination implements the decoder interface
u, ok := to.Interface().(decoder)
if !ok {
// for non-struct types we need to invoke func (*type) DecodeMapstructure()
if to.CanAddr() {
pto := to.Addr()
u, ok = pto.Interface().(decoder)
}
if !ok {
return from.Interface(), nil
}
}
// If it is nil and a pointer, create and assign the target value first
if to.Type().Kind() == reflect.Ptr && to.IsNil() {
to.Set(reflect.New(to.Type().Elem()))
u = to.Interface().(decoder)
}
// Call the custom DecodeMapstructure method
if err := u.DecodeMapstructure(from.Interface()); err != nil {
return to.Interface(), err
}
return to.Interface(), nil
}
func cast(from reflect.Value, to reflect.Value) (interface{}, error) {
switch from.Type().Kind() {
case reflect.String:
switch to.Kind() {
case reflect.Bool:
return toBoolean(from.String())
case reflect.Int:
return toInt(from.String())
case reflect.Int64:
return toInt64(from.String())
case reflect.Float32:
return toFloat32(from.String())
case reflect.Float64:
return toFloat(from.String())
}
case reflect.Int:
if to.Kind() == reflect.String {
return strconv.FormatInt(from.Int(), 10), nil
}
}
return from.Interface(), nil
}

View File

@ -0,0 +1,283 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/types"
"github.com/sirupsen/logrus"
)
// Normalize compose project by moving deprecated attributes to their canonical position and injecting implicit defaults
func Normalize(project *types.Project) error {
if project.Networks == nil {
project.Networks = make(map[string]types.NetworkConfig)
}
// If not declared explicitly, Compose model involves an implicit "default" network
if _, ok := project.Networks["default"]; !ok {
project.Networks["default"] = types.NetworkConfig{}
}
for name, s := range project.Services {
if len(s.Networks) == 0 && s.NetworkMode == "" {
// Service without explicit network attachment are implicitly exposed on default network
s.Networks = map[string]*types.ServiceNetworkConfig{"default": nil}
}
if s.PullPolicy == types.PullPolicyIfNotPresent {
s.PullPolicy = types.PullPolicyMissing
}
fn := func(s string) (string, bool) {
v, ok := project.Environment[s]
return v, ok
}
if s.Build != nil {
if s.Build.Context == "" {
s.Build.Context = "."
}
if s.Build.Dockerfile == "" && s.Build.DockerfileInline == "" {
s.Build.Dockerfile = "Dockerfile"
}
s.Build.Args = s.Build.Args.Resolve(fn)
}
s.Environment = s.Environment.Resolve(fn)
for _, link := range s.Links {
parts := strings.Split(link, ":")
if len(parts) == 2 {
link = parts[0]
}
s.DependsOn = setIfMissing(s.DependsOn, link, types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: true,
Required: true,
})
}
for _, namespace := range []string{s.NetworkMode, s.Ipc, s.Pid, s.Uts, s.Cgroup} {
if strings.HasPrefix(namespace, types.ServicePrefix) {
name := namespace[len(types.ServicePrefix):]
s.DependsOn = setIfMissing(s.DependsOn, name, types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: true,
Required: true,
})
}
}
for _, vol := range s.VolumesFrom {
if !strings.HasPrefix(vol, types.ContainerPrefix) {
spec := strings.Split(vol, ":")
s.DependsOn = setIfMissing(s.DependsOn, spec[0], types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: false,
Required: true,
})
}
}
err := relocateLogDriver(&s)
if err != nil {
return err
}
err = relocateLogOpt(&s)
if err != nil {
return err
}
err = relocateDockerfile(&s)
if err != nil {
return err
}
inferImplicitDependencies(&s)
project.Services[name] = s
}
setNameFromKey(project)
return nil
}
// IsServiceDependency check the relation set by ref refers to a service
func IsServiceDependency(ref string) (string, bool) {
if strings.HasPrefix(
ref,
types.ServicePrefix,
) {
return ref[len(types.ServicePrefix):], true
}
return "", false
}
func inferImplicitDependencies(service *types.ServiceConfig) {
var dependencies []string
maybeReferences := []string{
service.NetworkMode,
service.Ipc,
service.Pid,
service.Uts,
service.Cgroup,
}
for _, ref := range maybeReferences {
if dep, ok := IsServiceDependency(ref); ok {
dependencies = append(dependencies, dep)
}
}
for _, vol := range service.VolumesFrom {
spec := strings.Split(vol, ":")
if len(spec) == 0 {
continue
}
if spec[0] == "container" {
continue
}
dependencies = append(dependencies, spec[0])
}
for _, link := range service.Links {
dependencies = append(dependencies, strings.Split(link, ":")[0])
}
if len(dependencies) > 0 && service.DependsOn == nil {
service.DependsOn = make(types.DependsOnConfig)
}
for _, d := range dependencies {
if _, ok := service.DependsOn[d]; !ok {
service.DependsOn[d] = types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Required: true,
}
}
}
}
// setIfMissing adds a ServiceDependency for service if not already defined
func setIfMissing(d types.DependsOnConfig, service string, dep types.ServiceDependency) types.DependsOnConfig {
if d == nil {
d = types.DependsOnConfig{}
}
if _, ok := d[service]; !ok {
d[service] = dep
}
return d
}
// Resources with no explicit name are actually named by their key in map
func setNameFromKey(project *types.Project) {
for key, n := range project.Networks {
if n.Name == "" {
if n.External {
n.Name = key
} else {
n.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Networks[key] = n
}
}
for key, v := range project.Volumes {
if v.Name == "" {
if v.External {
v.Name = key
} else {
v.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Volumes[key] = v
}
}
for key, c := range project.Configs {
if c.Name == "" {
if c.External {
c.Name = key
} else {
c.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Configs[key] = c
}
}
for key, s := range project.Secrets {
if s.Name == "" {
if s.External {
s.Name = key
} else {
s.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Secrets[key] = s
}
}
}
func relocateLogOpt(s *types.ServiceConfig) error {
if len(s.LogOpt) != 0 {
logrus.Warn("`log_opts` is deprecated. Use the `logging` element")
if s.Logging == nil {
s.Logging = &types.LoggingConfig{}
}
for k, v := range s.LogOpt {
if _, ok := s.Logging.Options[k]; !ok {
s.Logging.Options[k] = v
} else {
return fmt.Errorf("can't use both 'log_opt' (deprecated) and 'logging.options': %w", errdefs.ErrInvalid)
}
}
}
return nil
}
func relocateLogDriver(s *types.ServiceConfig) error {
if s.LogDriver != "" {
logrus.Warn("`log_driver` is deprecated. Use the `logging` element")
if s.Logging == nil {
s.Logging = &types.LoggingConfig{}
}
if s.Logging.Driver == "" {
s.Logging.Driver = s.LogDriver
} else {
return fmt.Errorf("can't use both 'log_driver' (deprecated) and 'logging.driver': %w", errdefs.ErrInvalid)
}
}
return nil
}
func relocateDockerfile(s *types.ServiceConfig) error {
if s.Dockerfile != "" {
logrus.Warn("`dockerfile` is deprecated. Use the `build` element")
if s.Build == nil {
s.Build = &types.BuildConfig{}
}
if s.Dockerfile == "" {
s.Build.Dockerfile = s.Dockerfile
} else {
return fmt.Errorf("can't use both 'dockerfile' (deprecated) and 'build.dockerfile': %w", errdefs.ErrInvalid)
}
}
return nil
}

View File

@ -0,0 +1,93 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"os"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/v2/types"
)
// ResolveRelativePaths resolves relative paths based on project WorkingDirectory
func ResolveRelativePaths(project *types.Project) error {
absWorkingDir, err := filepath.Abs(project.WorkingDir)
if err != nil {
return err
}
project.WorkingDir = absWorkingDir
absComposeFiles, err := absComposeFiles(project.ComposeFiles)
if err != nil {
return err
}
project.ComposeFiles = absComposeFiles
// don't coerce a nil map to an empty map
if project.IncludeReferences != nil {
absIncludes := make(map[string][]types.IncludeConfig, len(project.IncludeReferences))
for filename, config := range project.IncludeReferences {
filename = absPath(project.WorkingDir, filename)
absConfigs := make([]types.IncludeConfig, len(config))
for i, c := range config {
absConfigs[i] = types.IncludeConfig{
Path: resolvePaths(project.WorkingDir, c.Path),
ProjectDirectory: absPath(project.WorkingDir, c.ProjectDirectory),
EnvFile: resolvePaths(project.WorkingDir, c.EnvFile),
}
}
absIncludes[filename] = absConfigs
}
project.IncludeReferences = absIncludes
}
return nil
}
func absPath(workingDir string, filePath string) string {
if strings.HasPrefix(filePath, "~") {
home, _ := os.UserHomeDir()
return filepath.Join(home, filePath[1:])
}
if filepath.IsAbs(filePath) {
return filePath
}
return filepath.Join(workingDir, filePath)
}
func absComposeFiles(composeFiles []string) ([]string, error) {
for i, composeFile := range composeFiles {
absComposefile, err := filepath.Abs(composeFile)
if err != nil {
return nil, err
}
composeFiles[i] = absComposefile
}
return composeFiles, nil
}
func resolvePaths(basePath string, in types.StringList) types.StringList {
if in == nil {
return nil
}
ret := make(types.StringList, len(in))
for i := range in {
ret[i] = absPath(basePath, in[i])
}
return ret
}

View File

@ -0,0 +1,126 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"fmt"
"strconv"
"github.com/compose-spec/compose-go/v2/tree"
"gopkg.in/yaml.v3"
)
type ResetProcessor struct {
target interface{}
paths []tree.Path
}
// UnmarshalYAML implement yaml.Unmarshaler
func (p *ResetProcessor) UnmarshalYAML(value *yaml.Node) error {
resolved, err := p.resolveReset(value, tree.NewPath())
if err != nil {
return err
}
return resolved.Decode(p.target)
}
// resolveReset detects `!reset` tag being set on yaml nodes and record position in the yaml tree
func (p *ResetProcessor) resolveReset(node *yaml.Node, path tree.Path) (*yaml.Node, error) {
if node.Tag == "!reset" {
p.paths = append(p.paths, path)
return nil, nil
}
if node.Tag == "!override" {
p.paths = append(p.paths, path)
return node, nil
}
switch node.Kind {
case yaml.SequenceNode:
var nodes []*yaml.Node
for idx, v := range node.Content {
next := path.Next(strconv.Itoa(idx))
resolved, err := p.resolveReset(v, next)
if err != nil {
return nil, err
}
if resolved != nil {
nodes = append(nodes, resolved)
}
}
node.Content = nodes
case yaml.MappingNode:
var key string
var nodes []*yaml.Node
for idx, v := range node.Content {
if idx%2 == 0 {
key = v.Value
} else {
resolved, err := p.resolveReset(v, path.Next(key))
if err != nil {
return nil, err
}
if resolved != nil {
nodes = append(nodes, node.Content[idx-1], resolved)
}
}
}
node.Content = nodes
}
return node, nil
}
// Apply finds the go attributes matching recorded paths and reset them to zero value
func (p *ResetProcessor) Apply(target any) error {
return p.applyNullOverrides(target, tree.NewPath())
}
// applyNullOverrides set val to Zero if it matches any of the recorded paths
func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error {
switch v := target.(type) {
case map[string]any:
KEYS:
for k, e := range v {
next := path.Next(k)
for _, pattern := range p.paths {
if next.Matches(pattern) {
delete(v, k)
continue KEYS
}
}
err := p.applyNullOverrides(e, next)
if err != nil {
return err
}
}
case []any:
ITER:
for i, e := range v {
next := path.Next(fmt.Sprintf("[%d]", i))
for _, pattern := range p.paths {
if next.Matches(pattern) {
continue ITER
// TODO(ndeloof) support removal from sequence
}
}
err := p.applyNullOverrides(e, next)
if err != nil {
return err
}
}
}
return nil
}

View File

@ -0,0 +1,146 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"context"
"errors"
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/graph"
"github.com/compose-spec/compose-go/v2/types"
)
// checkConsistency validate a compose model is consistent
func checkConsistency(project *types.Project) error {
for _, s := range project.Services {
if s.Build == nil && s.Image == "" {
return fmt.Errorf("service %q has neither an image nor a build context specified: %w", s.Name, errdefs.ErrInvalid)
}
if s.Build != nil {
if s.Build.DockerfileInline != "" && s.Build.Dockerfile != "" {
return fmt.Errorf("service %q declares mutualy exclusive dockerfile and dockerfile_inline: %w", s.Name, errdefs.ErrInvalid)
}
if len(s.Build.Platforms) > 0 && s.Platform != "" {
var found bool
for _, platform := range s.Build.Platforms {
if platform == s.Platform {
found = true
break
}
}
if !found {
return fmt.Errorf("service.build.platforms MUST include service.platform %q: %w", s.Platform, errdefs.ErrInvalid)
}
}
}
if s.NetworkMode != "" && len(s.Networks) > 0 {
return fmt.Errorf("service %s declares mutually exclusive `network_mode` and `networks`: %w", s.Name, errdefs.ErrInvalid)
}
for network := range s.Networks {
if _, ok := project.Networks[network]; !ok {
return fmt.Errorf("service %q refers to undefined network %s: %w", s.Name, network, errdefs.ErrInvalid)
}
}
if s.HealthCheck != nil && len(s.HealthCheck.Test) > 0 {
switch s.HealthCheck.Test[0] {
case "CMD", "CMD-SHELL", "NONE":
default:
return errors.New(`healthcheck.test must start either by "CMD", "CMD-SHELL" or "NONE"`)
}
}
for dependedService := range s.DependsOn {
if _, err := project.GetService(dependedService); err != nil {
return fmt.Errorf("service %q depends on undefined service %s: %w", s.Name, dependedService, errdefs.ErrInvalid)
}
}
// Check there isn't a cycle in depends_on declarations
if err := graph.InDependencyOrder(context.Background(), project, func(ctx context.Context, s string, config types.ServiceConfig) error {
return nil
}); err != nil {
return err
}
if strings.HasPrefix(s.NetworkMode, types.ServicePrefix) {
serviceName := s.NetworkMode[len(types.ServicePrefix):]
if _, err := project.GetServices(serviceName); err != nil {
return fmt.Errorf("service %q not found for network_mode 'service:%s'", serviceName, serviceName)
}
}
for _, volume := range s.Volumes {
if volume.Type == types.VolumeTypeVolume && volume.Source != "" { // non anonymous volumes
if _, ok := project.Volumes[volume.Source]; !ok {
return fmt.Errorf("service %q refers to undefined volume %s: %w", s.Name, volume.Source, errdefs.ErrInvalid)
}
}
}
if s.Build != nil {
for _, secret := range s.Build.Secrets {
if _, ok := project.Secrets[secret.Source]; !ok {
return fmt.Errorf("service %q refers to undefined build secret %s: %w", s.Name, secret.Source, errdefs.ErrInvalid)
}
}
}
for _, config := range s.Configs {
if _, ok := project.Configs[config.Source]; !ok {
return fmt.Errorf("service %q refers to undefined config %s: %w", s.Name, config.Source, errdefs.ErrInvalid)
}
}
for _, secret := range s.Secrets {
if _, ok := project.Secrets[secret.Source]; !ok {
return fmt.Errorf("service %q refers to undefined secret %s: %w", s.Name, secret.Source, errdefs.ErrInvalid)
}
}
if s.Scale != nil && s.Deploy != nil {
if s.Deploy.Replicas != nil && *s.Scale != *s.Deploy.Replicas {
return fmt.Errorf("services.%s: can't set distinct values on 'scale' and 'deploy.replicas': %w",
s.Name, errdefs.ErrInvalid)
}
s.Deploy.Replicas = s.Scale
}
if s.GetScale() > 1 && s.ContainerName != "" {
attr := "scale"
if s.Scale == nil {
attr = "deploy.replicas"
}
return fmt.Errorf("services.%s: can't set container_name and %s as container name must be unique: %w", attr,
s.Name, errdefs.ErrInvalid)
}
}
for name, secret := range project.Secrets {
if secret.External {
continue
}
if secret.File == "" && secret.Environment == "" {
return fmt.Errorf("secret %q must declare either `file` or `environment`: %w", name, errdefs.ErrInvalid)
}
}
return nil
}

View File

@ -0,0 +1,27 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package override
import "github.com/compose-spec/compose-go/v2/tree"
func ExtendService(base, override map[string]any) (map[string]any, error) {
yaml, err := mergeYaml(base, override, tree.NewPath("services.x"))
if err != nil {
return nil, err
}
return yaml.(map[string]any), nil
}

View File

@ -0,0 +1,252 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package override
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/tree"
"golang.org/x/exp/slices"
)
// Merge applies overrides to a config model
func Merge(right, left map[string]any) (map[string]any, error) {
merged, err := mergeYaml(right, left, tree.NewPath())
if err != nil {
return nil, err
}
return merged.(map[string]any), nil
}
type merger func(any, any, tree.Path) (any, error)
// mergeSpecials defines the custom rules applied by compose when merging yaml trees
var mergeSpecials = map[tree.Path]merger{}
func init() {
mergeSpecials["networks.*.ipam.config"] = mergeIPAMConfig
mergeSpecials["services.*.annotations"] = mergeToSequence
mergeSpecials["services.*.build"] = mergeBuild
mergeSpecials["services.*.build.args"] = mergeToSequence
mergeSpecials["services.*.build.additional_contexts"] = mergeToSequence
mergeSpecials["services.*.build.labels"] = mergeToSequence
mergeSpecials["services.*.command"] = override
mergeSpecials["services.*.depends_on"] = mergeDependsOn
mergeSpecials["services.*.deploy.labels"] = mergeToSequence
mergeSpecials["services.*.dns"] = mergeToSequence
mergeSpecials["services.*.dns_opt"] = mergeToSequence
mergeSpecials["services.*.dns_search"] = mergeToSequence
mergeSpecials["services.*.entrypoint"] = override
mergeSpecials["services.*.env_file"] = mergeToSequence
mergeSpecials["services.*.environment"] = mergeToSequence
mergeSpecials["services.*.extra_hosts"] = mergeToSequence
mergeSpecials["services.*.healthcheck.test"] = override
mergeSpecials["services.*.labels"] = mergeToSequence
mergeSpecials["services.*.logging"] = mergeLogging
mergeSpecials["services.*.networks"] = mergeNetworks
mergeSpecials["services.*.sysctls"] = mergeToSequence
mergeSpecials["services.*.tmpfs"] = mergeToSequence
mergeSpecials["services.*.ulimits.*"] = mergeUlimit
}
// mergeYaml merges map[string]any yaml trees handling special rules
func mergeYaml(e any, o any, p tree.Path) (any, error) {
for pattern, merger := range mergeSpecials {
if p.Matches(pattern) {
merged, err := merger(e, o, p)
if err != nil {
return nil, err
}
return merged, nil
}
}
if o == nil {
return e, nil
}
switch value := e.(type) {
case map[string]any:
other, ok := o.(map[string]any)
if !ok {
return nil, fmt.Errorf("cannot override %s", p)
}
return mergeMappings(value, other, p)
case []any:
other, ok := o.([]any)
if !ok {
return nil, fmt.Errorf("cannot override %s", p)
}
return append(value, other...), nil
default:
return o, nil
}
}
func mergeMappings(mapping map[string]any, other map[string]any, p tree.Path) (map[string]any, error) {
for k, v := range other {
e, ok := mapping[k]
if !ok || strings.HasPrefix(k, "x-") {
mapping[k] = v
continue
}
next := p.Next(k)
merged, err := mergeYaml(e, v, next)
if err != nil {
return nil, err
}
mapping[k] = merged
}
return mapping, nil
}
// logging driver options are merged only when both compose file define the same driver
func mergeLogging(c any, o any, p tree.Path) (any, error) {
config := c.(map[string]any)
other := o.(map[string]any)
// we override logging config if source and override have the same driver set, or none
d, ok1 := other["driver"]
o, ok2 := config["driver"]
if d == o || !ok1 || !ok2 {
return mergeMappings(config, other, p)
}
return other, nil
}
func mergeBuild(c any, o any, path tree.Path) (any, error) {
toBuild := func(c any) map[string]any {
switch v := c.(type) {
case string:
return map[string]any{
"context": v,
}
case map[string]any:
return v
}
return nil
}
return mergeMappings(toBuild(c), toBuild(o), path)
}
func mergeDependsOn(c any, o any, path tree.Path) (any, error) {
right := convertIntoMapping(c, map[string]any{
"condition": "service_started",
"required": true,
})
left := convertIntoMapping(o, map[string]any{
"condition": "service_started",
"required": true,
})
return mergeMappings(right, left, path)
}
func mergeNetworks(c any, o any, path tree.Path) (any, error) {
right := convertIntoMapping(c, nil)
left := convertIntoMapping(o, nil)
return mergeMappings(right, left, path)
}
func mergeToSequence(c any, o any, _ tree.Path) (any, error) {
right := convertIntoSequence(c)
left := convertIntoSequence(o)
return append(right, left...), nil
}
func convertIntoSequence(value any) []any {
switch v := value.(type) {
case map[string]any:
seq := make([]any, len(v))
i := 0
for k, v := range v {
if v == nil {
seq[i] = k
} else {
seq[i] = fmt.Sprintf("%s=%v", k, v)
}
i++
}
slices.SortFunc(seq, func(a, b any) bool {
return a.(string) < b.(string)
})
return seq
case []any:
return v
case string:
return []any{v}
}
return nil
}
func mergeUlimit(_ any, o any, p tree.Path) (any, error) {
over, ismapping := o.(map[string]any)
if base, ok := o.(map[string]any); ok && ismapping {
return mergeMappings(base, over, p)
}
return o, nil
}
func mergeIPAMConfig(c any, o any, path tree.Path) (any, error) {
var ipamConfigs []any
for _, original := range c.([]any) {
right := convertIntoMapping(original, nil)
for _, override := range o.([]any) {
left := convertIntoMapping(override, nil)
if left["subnet"] != right["subnet"] {
// check if left is already in ipamConfigs, add it if not and continue with the next config
if !slices.ContainsFunc(ipamConfigs, func(a any) bool {
return a.(map[string]any)["subnet"] == left["subnet"]
}) {
ipamConfigs = append(ipamConfigs, left)
continue
}
}
merged, err := mergeMappings(right, left, path)
if err != nil {
return nil, err
}
// find index of potential previous config with the same subnet in ipamConfigs
indexIfExist := slices.IndexFunc(ipamConfigs, func(a any) bool {
return a.(map[string]any)["subnet"] == merged["subnet"]
})
// if a previous config is already in ipamConfigs, replace it
if indexIfExist >= 0 {
ipamConfigs[indexIfExist] = merged
} else {
// or add the new config to ipamConfigs
ipamConfigs = append(ipamConfigs, merged)
}
}
}
return ipamConfigs, nil
}
func convertIntoMapping(a any, defaultValue any) map[string]any {
switch v := a.(type) {
case map[string]any:
return v
case []any:
converted := map[string]any{}
for _, s := range v {
converted[s.(string)] = defaultValue
}
return converted
}
return nil
}
func override(_ any, other any, _ tree.Path) (any, error) {
return other, nil
}

View File

@ -0,0 +1,204 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package override
import (
"fmt"
"strconv"
"strings"
"github.com/compose-spec/compose-go/v2/format"
"github.com/compose-spec/compose-go/v2/tree"
)
type indexer func(any, tree.Path) (string, error)
// mergeSpecials defines the custom rules applied by compose when merging yaml trees
var unique = map[tree.Path]indexer{}
func init() {
unique["networks.*.labels"] = keyValueIndexer
unique["networks.*.ipam.options"] = keyValueIndexer
unique["services.*.annotations"] = keyValueIndexer
unique["services.*.build.args"] = keyValueIndexer
unique["services.*.build.additional_contexts"] = keyValueIndexer
unique["services.*.build.extra_hosts"] = keyValueIndexer
unique["services.*.build.platform"] = keyValueIndexer
unique["services.*.build.tags"] = keyValueIndexer
unique["services.*.build.labels"] = keyValueIndexer
unique["services.*.cap_add"] = keyValueIndexer
unique["services.*.cap_drop"] = keyValueIndexer
unique["services.*.configs"] = mountIndexer("")
unique["services.*.deploy.labels"] = keyValueIndexer
unique["services.*.dns"] = keyValueIndexer
unique["services.*.dns_opt"] = keyValueIndexer
unique["services.*.dns_search"] = keyValueIndexer
unique["services.*.environment"] = keyValueIndexer
unique["services.*.env_file"] = envFileIndexer
unique["services.*.expose"] = exposeIndexer
unique["services.*.extra_hosts"] = keyValueIndexer
unique["services.*.labels"] = keyValueIndexer
unique["services.*.links"] = keyValueIndexer
unique["services.*.networks.*.aliases"] = keyValueIndexer
unique["services.*.networks.*.link_local_ips"] = keyValueIndexer
unique["services.*.ports"] = portIndexer
unique["services.*.profiles"] = keyValueIndexer
unique["services.*.secrets"] = mountIndexer("/run/secrets")
unique["services.*.sysctls"] = keyValueIndexer
unique["services.*.tmpfs"] = keyValueIndexer
unique["services.*.volumes"] = volumeIndexer
}
// EnforceUnicity removes redefinition of elements declared in a sequence
func EnforceUnicity(value map[string]any) (map[string]any, error) {
uniq, err := enforceUnicity(value, tree.NewPath())
if err != nil {
return nil, err
}
return uniq.(map[string]any), nil
}
func enforceUnicity(value any, p tree.Path) (any, error) {
switch v := value.(type) {
case map[string]any:
for k, e := range v {
u, err := enforceUnicity(e, p.Next(k))
if err != nil {
return nil, err
}
v[k] = u
}
return v, nil
case []any:
for pattern, indexer := range unique {
if p.Matches(pattern) {
seq := []any{}
keys := map[string]int{}
for i, entry := range v {
key, err := indexer(entry, p.Next(fmt.Sprintf("[%d]", i)))
if err != nil {
return nil, err
}
if j, ok := keys[key]; ok {
seq[j] = entry
} else {
seq = append(seq, entry)
keys[key] = len(seq) - 1
}
}
return seq, nil
}
}
}
return value, nil
}
func keyValueIndexer(y any, _ tree.Path) (string, error) {
value := y.(string)
key, _, found := strings.Cut(value, "=")
if !found {
return value, nil
}
return key, nil
}
func volumeIndexer(y any, p tree.Path) (string, error) {
switch value := y.(type) {
case map[string]any:
target, ok := value["target"].(string)
if !ok {
return "", fmt.Errorf("service volume %s is missing a mount target", p)
}
return target, nil
case string:
volume, err := format.ParseVolume(value)
if err != nil {
return "", err
}
return volume.Target, nil
}
return "", nil
}
func exposeIndexer(a any, path tree.Path) (string, error) {
switch v := a.(type) {
case string:
return v, nil
case int:
return strconv.Itoa(v), nil
default:
return "", fmt.Errorf("%s: unsupported expose value %s", path, a)
}
}
func mountIndexer(defaultPath string) indexer {
return func(a any, path tree.Path) (string, error) {
switch v := a.(type) {
case string:
return fmt.Sprintf("%s/%s", defaultPath, v), nil
case map[string]any:
t, ok := v["target"]
if ok {
return t.(string), nil
}
return fmt.Sprintf("%s/%s", defaultPath, v["source"]), nil
default:
return "", fmt.Errorf("%s: unsupported expose value %s", path, a)
}
}
}
func portIndexer(y any, p tree.Path) (string, error) {
switch value := y.(type) {
case int:
return strconv.Itoa(value), nil
case map[string]any:
target, ok := value["target"]
if !ok {
return "", fmt.Errorf("service ports %s is missing a target port", p)
}
published, ok := value["published"]
if !ok {
// try to parse it as an int
if pub, ok := value["published"]; ok {
published = fmt.Sprintf("%d", pub)
}
}
host, ok := value["host_ip"]
if !ok {
host = "0.0.0.0"
}
protocol, ok := value["protocol"]
if !ok {
protocol = "tcp"
}
return fmt.Sprintf("%s:%s:%d/%s", host, published, target, protocol), nil
case string:
return value, nil
}
return "", nil
}
func envFileIndexer(y any, _ tree.Path) (string, error) {
switch value := y.(type) {
case string:
return value, nil
case map[string]any:
return value["path"].(string), nil
}
return "", nil
}

View File

@ -0,0 +1,44 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
import "strings"
func (r *relativePathsResolver) absContextPath(value any) (any, error) {
v := value.(string)
if strings.Contains(v, "://") { // `docker-image://` or any builder specific context type
return v, nil
}
if isRemoteContext(v) {
return v, nil
}
return r.absPath(v)
}
// isRemoteContext returns true if the value is a Git reference or HTTP(S) URL.
//
// Any other value is assumed to be a local filesystem path and returns false.
//
// See: https://github.com/moby/buildkit/blob/18fc875d9bfd6e065cd8211abc639434ba65aa56/frontend/dockerui/context.go#L76-L79
func isRemoteContext(maybeURL string) bool {
for _, prefix := range []string{"https://", "http://", "git://", "ssh://", "github.com/", "git@"} {
if strings.HasPrefix(maybeURL, prefix) {
return true
}
}
return false
}

View File

@ -0,0 +1,25 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
func (r *relativePathsResolver) absExtendsPath(value any) (any, error) {
v := value.(string)
if r.isRemoteResource(v) {
return v, nil
}
return r.absPath(v)
}

View File

@ -0,0 +1,37 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
import (
"os"
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
)
func ExpandUser(p string) string {
if strings.HasPrefix(p, "~") {
home, err := os.UserHomeDir()
if err != nil {
logrus.Warn("cannot expand '~', because the environment lacks HOME")
return p
}
return filepath.Join(home, p[1:])
}
return p
}

View File

@ -0,0 +1,161 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
import (
"errors"
"fmt"
"path/filepath"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
)
type resolver func(any) (any, error)
// ResolveRelativePaths make relative paths absolute
func ResolveRelativePaths(project map[string]any, base string, remotes []RemoteResource) error {
r := relativePathsResolver{
workingDir: base,
remotes: remotes,
}
r.resolvers = map[tree.Path]resolver{
"services.*.build.context": r.absContextPath,
"services.*.build.additional_contexts.*": r.absContextPath,
"services.*.env_file.*.path": r.absPath,
"services.*.extends.file": r.absExtendsPath,
"services.*.develop.watch.*.path": r.absPath,
"services.*.volumes.*": r.absVolumeMount,
"configs.*.file": r.maybeUnixPath,
"secrets.*.file": r.maybeUnixPath,
"include.path": r.absPath,
"include.project_directory": r.absPath,
"include.env_file": r.absPath,
"volumes.*": r.volumeDriverOpts,
}
_, err := r.resolveRelativePaths(project, tree.NewPath())
return err
}
type RemoteResource func(path string) bool
type relativePathsResolver struct {
workingDir string
remotes []RemoteResource
resolvers map[tree.Path]resolver
}
func (r *relativePathsResolver) isRemoteResource(path string) bool {
for _, remote := range r.remotes {
if remote(path) {
return true
}
}
return false
}
func (r *relativePathsResolver) resolveRelativePaths(value any, p tree.Path) (any, error) {
for pattern, resolver := range r.resolvers {
if p.Matches(pattern) {
return resolver(value)
}
}
switch v := value.(type) {
case map[string]any:
for k, e := range v {
resolved, err := r.resolveRelativePaths(e, p.Next(k))
if err != nil {
return nil, err
}
v[k] = resolved
}
case []any:
for i, e := range v {
resolved, err := r.resolveRelativePaths(e, p.Next("[]"))
if err != nil {
return nil, err
}
v[i] = resolved
}
}
return value, nil
}
func (r *relativePathsResolver) absPath(value any) (any, error) {
switch v := value.(type) {
case []any:
for i, s := range v {
abs, err := r.absPath(s)
if err != nil {
return nil, err
}
v[i] = abs
}
return v, nil
case string:
v = ExpandUser(v)
if filepath.IsAbs(v) {
return v, nil
}
if v != "" {
return filepath.Join(r.workingDir, v), nil
}
return v, nil
}
return nil, fmt.Errorf("unexpected type %T", value)
}
func (r *relativePathsResolver) absVolumeMount(a any) (any, error) {
vol := a.(map[string]any)
if vol["type"] != types.VolumeTypeBind {
return vol, nil
}
src, ok := vol["source"]
if !ok {
return nil, errors.New(`invalid mount config for type "bind": field Source must not be empty`)
}
abs, err := r.maybeUnixPath(src.(string))
if err != nil {
return nil, err
}
vol["source"] = abs
return vol, nil
}
func (r *relativePathsResolver) volumeDriverOpts(a any) (any, error) {
if a == nil {
return nil, nil
}
vol := a.(map[string]any)
if vol["driver"] != "local" {
return vol, nil
}
do, ok := vol["driver_opts"]
if !ok {
return vol, nil
}
opts := do.(map[string]any)
if dev, ok := opts["device"]; opts["o"] == "bind" && ok {
// This is actually a bind mount
path, err := r.maybeUnixPath(dev)
if err != nil {
return nil, err
}
opts["device"] = path
}
return vol, nil
}

View File

@ -0,0 +1,40 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
import (
"path"
"path/filepath"
)
func (r *relativePathsResolver) maybeUnixPath(a any) (any, error) {
p := a.(string)
p = ExpandUser(p)
// Check if source is an absolute path (either Unix or Windows), to
// handle a Windows client with a Unix daemon or vice-versa.
//
// Note that this is not required for Docker for Windows when specifying
// a local Windows path, because Docker for Windows translates the Windows
// path into a valid path within the VM.
if !path.IsAbs(p) && !isWindowsAbs(p) {
if filepath.IsAbs(p) {
return p, nil
}
return filepath.Join(r.workingDir, p), nil
}
return p, nil
}

View File

@ -0,0 +1,82 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package paths
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// https://github.com/golang/go/blob/master/LICENSE
// This file contains utilities to check for Windows absolute paths on Linux.
// The code in this file was largely copied from the Golang filepath package
// https://github.com/golang/go/blob/1d0e94b1e13d5e8a323a63cd1cc1ef95290c9c36/src/path/filepath/path_windows.go#L12-L65
func isSlash(c uint8) bool {
return c == '\\' || c == '/'
}
// isAbs reports whether the path is a Windows absolute path.
func isWindowsAbs(path string) (b bool) {
l := volumeNameLen(path)
if l == 0 {
return false
}
path = path[l:]
if path == "" {
return false
}
return isSlash(path[0])
}
// volumeNameLen returns length of the leading volume name on Windows.
// It returns 0 elsewhere.
// nolint: gocyclo
func volumeNameLen(path string) int {
if len(path) < 2 {
return 0
}
// with drive letter
c := path[0]
if path[1] == ':' && ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
return 2
}
// is it UNC? https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) &&
!isSlash(path[2]) && path[2] != '.' {
// first, leading `\\` and next shouldn't be `\`. its server name.
for n := 3; n < l-1; n++ {
// second, next '\' shouldn't be repeated.
if isSlash(path[n]) {
n++
// third, following something characters. its share name.
if !isSlash(path[n]) {
if path[n] == '.' {
break
}
for ; n < l; n++ {
if isSlash(path[n]) {
break
}
}
return n
}
break
}
}
}
return 0
}

View File

@ -0,0 +1,907 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema#",
"id": "compose_spec.json",
"type": "object",
"title": "Compose Specification",
"description": "The Compose file is a YAML file defining a multi-containers based application.",
"properties": {
"version": {
"type": "string",
"description": "declared for backward compatibility, ignored."
},
"name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*$",
"description": "define the Compose project name, until user defines one explicitly."
},
"include": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/include"
},
"description": "compose sub-projects to be included."
},
"services": {
"id": "#/properties/services",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/service"
}
},
"additionalProperties": false
},
"networks": {
"id": "#/properties/networks",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/network"
}
}
},
"volumes": {
"id": "#/properties/volumes",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/volume"
}
},
"additionalProperties": false
},
"secrets": {
"id": "#/properties/secrets",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/secret"
}
},
"additionalProperties": false
},
"configs": {
"id": "#/properties/configs",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/config"
}
},
"additionalProperties": false
}
},
"patternProperties": {"^x-": {}},
"additionalProperties": false,
"definitions": {
"service": {
"id": "#/definitions/service",
"type": "object",
"properties": {
"develop": {"$ref": "#/definitions/development"},
"deploy": {"$ref": "#/definitions/deployment"},
"annotations": {"$ref": "#/definitions/list_or_dict"},
"attach": {"type": "boolean"},
"build": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"context": {"type": "string"},
"dockerfile": {"type": "string"},
"dockerfile_inline": {"type": "string"},
"args": {"$ref": "#/definitions/list_or_dict"},
"ssh": {"$ref": "#/definitions/list_or_dict"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"cache_from": {"type": "array", "items": {"type": "string"}},
"cache_to": {"type": "array", "items": {"type": "string"}},
"no_cache": {"type": "boolean"},
"additional_contexts": {"$ref": "#/definitions/list_or_dict"},
"network": {"type": "string"},
"pull": {"type": "boolean"},
"target": {"type": "string"},
"shm_size": {"type": ["integer", "string"]},
"extra_hosts": {"$ref": "#/definitions/list_or_dict"},
"isolation": {"type": "string"},
"privileged": {"type": "boolean"},
"secrets": {"$ref": "#/definitions/service_config_or_secret"},
"tags": {"type": "array", "items": {"type": "string"}},
"ulimits": {"$ref": "#/definitions/ulimits"},
"platforms": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
]
},
"blkio_config": {
"type": "object",
"properties": {
"device_read_bps": {
"type": "array",
"items": {"$ref": "#/definitions/blkio_limit"}
},
"device_read_iops": {
"type": "array",
"items": {"$ref": "#/definitions/blkio_limit"}
},
"device_write_bps": {
"type": "array",
"items": {"$ref": "#/definitions/blkio_limit"}
},
"device_write_iops": {
"type": "array",
"items": {"$ref": "#/definitions/blkio_limit"}
},
"weight": {"type": "integer"},
"weight_device": {
"type": "array",
"items": {"$ref": "#/definitions/blkio_weight"}
}
},
"additionalProperties": false
},
"cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"cgroup": {"type": "string", "enum": ["host", "private"]},
"cgroup_parent": {"type": "string"},
"command": {"$ref": "#/definitions/command"},
"configs": {"$ref": "#/definitions/service_config_or_secret"},
"container_name": {"type": "string"},
"cpu_count": {"type": "integer", "minimum": 0},
"cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100},
"cpu_shares": {"type": ["number", "string"]},
"cpu_quota": {"type": ["number", "string"]},
"cpu_period": {"type": ["number", "string"]},
"cpu_rt_period": {"type": ["number", "string"]},
"cpu_rt_runtime": {"type": ["number", "string"]},
"cpus": {"type": ["number", "string"]},
"cpuset": {"type": "string"},
"credential_spec": {
"type": "object",
"properties": {
"config": {"type": "string"},
"file": {"type": "string"},
"registry": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"depends_on": {
"oneOf": [
{"$ref": "#/definitions/list_of_strings"},
{
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"type": "object",
"additionalProperties": false,
"properties": {
"restart": {"type": "boolean"},
"required": {
"type": "boolean",
"default": true
},
"condition": {
"type": "string",
"enum": ["service_started", "service_healthy", "service_completed_successfully"]
}
},
"required": ["condition"]
}
}
}
]
},
"device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"},
"devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"dns": {"$ref": "#/definitions/string_or_list"},
"dns_opt": {"type": "array","items": {"type": "string"}, "uniqueItems": true},
"dns_search": {"$ref": "#/definitions/string_or_list"},
"domainname": {"type": "string"},
"entrypoint": {"$ref": "#/definitions/command"},
"env_file": {"$ref": "#/definitions/env_file"},
"environment": {"$ref": "#/definitions/list_or_dict"},
"expose": {
"type": "array",
"items": {
"type": ["string", "number"],
"format": "expose"
},
"uniqueItems": true
},
"extends": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"service": {"type": "string"},
"file": {"type": "string"}
},
"required": ["service"],
"additionalProperties": false
}
]
},
"external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"extra_hosts": {"$ref": "#/definitions/list_or_dict"},
"group_add": {
"type": "array",
"items": {
"type": ["string", "number"]
},
"uniqueItems": true
},
"healthcheck": {"$ref": "#/definitions/healthcheck"},
"hostname": {"type": "string"},
"image": {"type": "string"},
"init": {"type": "boolean"},
"ipc": {"type": "string"},
"isolation": {"type": "string"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"logging": {
"type": "object",
"properties": {
"driver": {"type": "string"},
"options": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number", "null"]}
}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"mac_address": {"type": "string"},
"mem_limit": {"type": ["number", "string"]},
"mem_reservation": {"type": ["string", "integer"]},
"mem_swappiness": {"type": "integer"},
"memswap_limit": {"type": ["number", "string"]},
"network_mode": {"type": "string"},
"networks": {
"oneOf": [
{"$ref": "#/definitions/list_of_strings"},
{
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"oneOf": [
{
"type": "object",
"properties": {
"aliases": {"$ref": "#/definitions/list_of_strings"},
"ipv4_address": {"type": "string"},
"ipv6_address": {"type": "string"},
"link_local_ips": {"$ref": "#/definitions/list_of_strings"},
"mac_address": {"type": "string"},
"priority": {"type": "number"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
{"type": "null"}
]
}
},
"additionalProperties": false
}
]
},
"oom_kill_disable": {"type": "boolean"},
"oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000},
"pid": {"type": ["string", "null"]},
"pids_limit": {"type": ["number", "string"]},
"platform": {"type": "string"},
"ports": {
"type": "array",
"items": {
"oneOf": [
{"type": "number", "format": "ports"},
{"type": "string", "format": "ports"},
{
"type": "object",
"properties": {
"mode": {"type": "string"},
"host_ip": {"type": "string"},
"target": {"type": "integer"},
"published": {"type": ["string", "integer"]},
"protocol": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
]
},
"uniqueItems": true
},
"privileged": {"type": "boolean"},
"profiles": {"$ref": "#/definitions/list_of_strings"},
"pull_policy": {"type": "string", "enum": [
"always", "never", "if_not_present", "build", "missing"
]},
"read_only": {"type": "boolean"},
"restart": {"type": "string"},
"runtime": {
"type": "string"
},
"scale": {
"type": "integer"
},
"security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"shm_size": {"type": ["number", "string"]},
"secrets": {"$ref": "#/definitions/service_config_or_secret"},
"sysctls": {"$ref": "#/definitions/list_or_dict"},
"stdin_open": {"type": "boolean"},
"stop_grace_period": {"type": "string", "format": "duration"},
"stop_signal": {"type": "string"},
"storage_opt": {"type": "object"},
"tmpfs": {"$ref": "#/definitions/string_or_list"},
"tty": {"type": "boolean"},
"ulimits": {"$ref": "#/definitions/ulimits"},
"user": {"type": "string"},
"uts": {"type": "string"},
"userns_mode": {"type": "string"},
"volumes": {
"type": "array",
"items": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"required": ["type"],
"properties": {
"type": {"type": "string"},
"source": {"type": "string"},
"target": {"type": "string"},
"read_only": {"type": "boolean"},
"consistency": {"type": "string"},
"bind": {
"type": "object",
"properties": {
"propagation": {"type": "string"},
"create_host_path": {"type": "boolean"},
"selinux": {"type": "string", "enum": ["z", "Z"]}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"volume": {
"type": "object",
"properties": {
"nocopy": {"type": "boolean"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"tmpfs": {
"type": "object",
"properties": {
"size": {
"oneOf": [
{"type": "integer", "minimum": 0},
{"type": "string"}
]
},
"mode": {"type": "number"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
]
},
"uniqueItems": true
},
"volumes_from": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": true
},
"working_dir": {"type": "string"}
},
"patternProperties": {"^x-": {}},
"additionalProperties": false
},
"healthcheck": {
"id": "#/definitions/healthcheck",
"type": "object",
"properties": {
"disable": {"type": "boolean"},
"interval": {"type": "string", "format": "duration"},
"retries": {"type": "number"},
"test": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"timeout": {"type": "string", "format": "duration"},
"start_period": {"type": "string", "format": "duration"},
"start_interval": {"type": "string", "format": "duration"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"development": {
"id": "#/definitions/development",
"type": ["object", "null"],
"properties": {
"watch": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ignore": {"type": "array", "items": {"type": "string"}},
"path": {"type": "string"},
"action": {"type": "string", "enum": ["rebuild", "sync", "sync+restart"]},
"target": {"type": "string"}
}
},
"required": ["path", "action"],
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
}
},
"deployment": {
"id": "#/definitions/deployment",
"type": ["object", "null"],
"properties": {
"mode": {"type": "string"},
"endpoint_mode": {"type": "string"},
"replicas": {"type": "integer"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"rollback_config": {
"type": "object",
"properties": {
"parallelism": {"type": "integer"},
"delay": {"type": "string", "format": "duration"},
"failure_action": {"type": "string"},
"monitor": {"type": "string", "format": "duration"},
"max_failure_ratio": {"type": "number"},
"order": {"type": "string", "enum": [
"start-first", "stop-first"
]}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"update_config": {
"type": "object",
"properties": {
"parallelism": {"type": "integer"},
"delay": {"type": "string", "format": "duration"},
"failure_action": {"type": "string"},
"monitor": {"type": "string", "format": "duration"},
"max_failure_ratio": {"type": "number"},
"order": {"type": "string", "enum": [
"start-first", "stop-first"
]}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"resources": {
"type": "object",
"properties": {
"limits": {
"type": "object",
"properties": {
"cpus": {"type": ["number", "string"]},
"memory": {"type": "string"},
"pids": {"type": "integer"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"reservations": {
"type": "object",
"properties": {
"cpus": {"type": ["number", "string"]},
"memory": {"type": "string"},
"generic_resources": {"$ref": "#/definitions/generic_resources"},
"devices": {"$ref": "#/definitions/devices"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"restart_policy": {
"type": "object",
"properties": {
"condition": {"type": "string"},
"delay": {"type": "string", "format": "duration"},
"max_attempts": {"type": "integer"},
"window": {"type": "string", "format": "duration"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"placement": {
"type": "object",
"properties": {
"constraints": {"type": "array", "items": {"type": "string"}},
"preferences": {
"type": "array",
"items": {
"type": "object",
"properties": {
"spread": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"max_replicas_per_node": {"type": "integer"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"generic_resources": {
"id": "#/definitions/generic_resources",
"type": "array",
"items": {
"type": "object",
"properties": {
"discrete_resource_spec": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"value": {"type": "number"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"devices": {
"id": "#/definitions/devices",
"type": "array",
"items": {
"type": "object",
"properties": {
"capabilities": {"$ref": "#/definitions/list_of_strings"},
"count": {"type": ["string", "integer"]},
"device_ids": {"$ref": "#/definitions/list_of_strings"},
"driver":{"type": "string"},
"options":{"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"include": {
"id": "#/definitions/include",
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"path": {"$ref": "#/definitions/string_or_list"},
"env_file": {"$ref": "#/definitions/string_or_list"},
"project_directory": {"type": "string"}
},
"additionalProperties": false
}
]
},
"network": {
"id": "#/definitions/network",
"type": ["object", "null"],
"properties": {
"name": {"type": "string"},
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number"]}
}
},
"ipam": {
"type": "object",
"properties": {
"driver": {"type": "string"},
"config": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string", "format": "subnet_ip_address"},
"ip_range": {"type": "string"},
"gateway": {"type": "string"},
"aux_addresses": {
"type": "object",
"additionalProperties": false,
"patternProperties": {"^.+$": {"type": "string"}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
},
"options": {
"type": "object",
"additionalProperties": false,
"patternProperties": {"^.+$": {"type": "string"}}
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {
"deprecated": true,
"type": "string"
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"internal": {"type": "boolean"},
"enable_ipv6": {"type": "boolean"},
"attachable": {"type": "boolean"},
"labels": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"volume": {
"id": "#/definitions/volume",
"type": ["object", "null"],
"properties": {
"name": {"type": "string"},
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number"]}
}
},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {
"deprecated": true,
"type": "string"
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"labels": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"secret": {
"id": "#/definitions/secret",
"type": "object",
"properties": {
"name": {"type": "string"},
"environment": {"type": "string"},
"file": {"type": "string"},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {"type": "string"}
}
},
"labels": {"$ref": "#/definitions/list_or_dict"},
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number"]}
}
},
"template_driver": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"config": {
"id": "#/definitions/config",
"type": "object",
"properties": {
"name": {"type": "string"},
"content": {"type": "string"},
"environment": {"type": "string"},
"file": {"type": "string"},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {
"deprecated": true,
"type": "string"
}
}
},
"labels": {"$ref": "#/definitions/list_or_dict"},
"template_driver": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"command": {
"oneOf": [
{"type": "null"},
{"type": "string"},
{"type": "array","items": {"type": "string"}}
]
},
"env_file": {
"oneOf": [
{"type": "string"},
{
"type": "array",
"items": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"required": {
"type": "boolean",
"default": true
}
},
"required": [
"path"
]
}
]
}
}
]
},
"string_or_list": {
"oneOf": [
{"type": "string"},
{"$ref": "#/definitions/list_of_strings"}
]
},
"list_of_strings": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": true
},
"list_or_dict": {
"oneOf": [
{
"type": "object",
"patternProperties": {
".+": {
"type": ["string", "number", "boolean", "null"]
}
},
"additionalProperties": false
},
{"type": "array", "items": {"type": "string"}, "uniqueItems": true}
]
},
"blkio_limit": {
"type": "object",
"properties": {
"path": {"type": "string"},
"rate": {"type": ["integer", "string"]}
},
"additionalProperties": false
},
"blkio_weight": {
"type": "object",
"properties": {
"path": {"type": "string"},
"weight": {"type": "integer"}
},
"additionalProperties": false
},
"service_config_or_secret": {
"type": "array",
"items": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"uid": {"type": "string"},
"gid": {"type": "string"},
"mode": {"type": "number"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
]
}
},
"ulimits": {
"type": "object",
"patternProperties": {
"^[a-z]+$": {
"oneOf": [
{"type": "integer"},
{
"type": "object",
"properties": {
"hard": {"type": "integer"},
"soft": {"type": "integer"}
},
"required": ["soft", "hard"],
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}
]
}
}
},
"constraints": {
"service": {
"id": "#/definitions/constraints/service",
"anyOf": [
{"required": ["build"]},
{"required": ["image"]}
],
"properties": {
"build": {
"required": ["context"]
}
}
}
}
}
}

View File

@ -0,0 +1,164 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package schema
import (
// Enable support for embedded static resources
_ "embed"
"fmt"
"strings"
"time"
"github.com/xeipuuv/gojsonschema"
)
type portsFormatChecker struct{}
func (checker portsFormatChecker) IsFormat(_ interface{}) bool {
// TODO: implement this
return true
}
type durationFormatChecker struct{}
func (checker durationFormatChecker) IsFormat(input interface{}) bool {
value, ok := input.(string)
if !ok {
return false
}
_, err := time.ParseDuration(value)
return err == nil
}
func init() {
gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{})
gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{})
gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
}
// Schema is the compose-spec JSON schema
//
//go:embed compose-spec.json
var Schema string
// Validate uses the jsonschema to validate the configuration
func Validate(config map[string]interface{}) error {
schemaLoader := gojsonschema.NewStringLoader(Schema)
dataLoader := gojsonschema.NewGoLoader(config)
result, err := gojsonschema.Validate(schemaLoader, dataLoader)
if err != nil {
return err
}
if !result.Valid() {
return toError(result)
}
return nil
}
func toError(result *gojsonschema.Result) error {
err := getMostSpecificError(result.Errors())
return err
}
const (
jsonschemaOneOf = "number_one_of"
jsonschemaAnyOf = "number_any_of"
)
func getDescription(err validationError) string {
switch err.parent.Type() {
case "invalid_type":
if expectedType, ok := err.parent.Details()["expected"].(string); ok {
return fmt.Sprintf("must be a %s", humanReadableType(expectedType))
}
case jsonschemaOneOf, jsonschemaAnyOf:
if err.child == nil {
return err.parent.Description()
}
return err.child.Description()
}
return err.parent.Description()
}
func humanReadableType(definition string) string {
if definition[0:1] == "[" {
allTypes := strings.Split(definition[1:len(definition)-1], ",")
for i, t := range allTypes {
allTypes[i] = humanReadableType(t)
}
return fmt.Sprintf(
"%s or %s",
strings.Join(allTypes[0:len(allTypes)-1], ", "),
allTypes[len(allTypes)-1],
)
}
if definition == "object" {
return "mapping"
}
if definition == "array" {
return "list"
}
return definition
}
type validationError struct {
parent gojsonschema.ResultError
child gojsonschema.ResultError
}
func (err validationError) Error() string {
description := getDescription(err)
return fmt.Sprintf("%s %s", err.parent.Field(), description)
}
func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
mostSpecificError := 0
for i, err := range errors {
if specificity(err) > specificity(errors[mostSpecificError]) {
mostSpecificError = i
continue
}
if specificity(err) == specificity(errors[mostSpecificError]) {
// Invalid type errors win in a tie-breaker for most specific field name
if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" {
mostSpecificError = i
}
}
}
if mostSpecificError+1 == len(errors) {
return validationError{parent: errors[mostSpecificError]}
}
switch errors[mostSpecificError].Type() {
case "number_one_of", "number_any_of":
return validationError{
parent: errors[mostSpecificError],
child: errors[mostSpecificError+1],
}
default:
return validationError{parent: errors[mostSpecificError]}
}
}
func specificity(err gojsonschema.ResultError) int {
return len(strings.Split(err.Field(), "."))
}

View File

@ -0,0 +1,473 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package template
import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"github.com/sirupsen/logrus"
)
var delimiter = "\\$"
var substitutionNamed = "[_a-z][_a-z0-9]*"
var substitutionBraced = "[_a-z][_a-z0-9]*(?::?[-+?](.*))?"
var groupEscaped = "escaped"
var groupNamed = "named"
var groupBraced = "braced"
var groupInvalid = "invalid"
var patternString = fmt.Sprintf(
"%s(?i:(?P<%s>%s)|(?P<%s>%s)|{(?:(?P<%s>%s)}|(?P<%s>)))",
delimiter,
groupEscaped, delimiter,
groupNamed, substitutionNamed,
groupBraced, substitutionBraced,
groupInvalid,
)
var defaultPattern = regexp.MustCompile(patternString)
// InvalidTemplateError is returned when a variable template is not in a valid
// format
type InvalidTemplateError struct {
Template string
}
func (e InvalidTemplateError) Error() string {
return fmt.Sprintf("Invalid template: %#v", e.Template)
}
// MissingRequiredError is returned when a variable template is missing
type MissingRequiredError struct {
Variable string
Reason string
}
func (e MissingRequiredError) Error() string {
if e.Reason != "" {
return fmt.Sprintf("required variable %s is missing a value: %s", e.Variable, e.Reason)
}
return fmt.Sprintf("required variable %s is missing a value", e.Variable)
}
// Mapping is a user-supplied function which maps from variable names to values.
// Returns the value as a string and a bool indicating whether
// the value is present, to distinguish between an empty string
// and the absence of a value.
type Mapping func(string) (string, bool)
// SubstituteFunc is a user-supplied function that apply substitution.
// Returns the value as a string, a bool indicating if the function could apply
// the substitution and an error.
type SubstituteFunc func(string, Mapping) (string, bool, error)
// ReplacementFunc is a user-supplied function that is apply to the matching
// substring. Returns the value as a string and an error.
type ReplacementFunc func(string, Mapping, *Config) (string, error)
type Config struct {
pattern *regexp.Regexp
substituteFunc SubstituteFunc
replacementFunc ReplacementFunc
logging bool
}
type Option func(*Config)
func WithPattern(pattern *regexp.Regexp) Option {
return func(cfg *Config) {
cfg.pattern = pattern
}
}
func WithSubstitutionFunction(subsFunc SubstituteFunc) Option {
return func(cfg *Config) {
cfg.substituteFunc = subsFunc
}
}
func WithReplacementFunction(replacementFunc ReplacementFunc) Option {
return func(cfg *Config) {
cfg.replacementFunc = replacementFunc
}
}
func WithoutLogging(cfg *Config) {
cfg.logging = false
}
// SubstituteWithOptions substitute variables in the string with their values.
// It accepts additional options such as a custom function or pattern.
func SubstituteWithOptions(template string, mapping Mapping, options ...Option) (string, error) {
var returnErr error
cfg := &Config{
pattern: defaultPattern,
replacementFunc: DefaultReplacementFunc,
logging: true,
}
for _, o := range options {
o(cfg)
}
result := cfg.pattern.ReplaceAllStringFunc(template, func(substring string) string {
replacement, err := cfg.replacementFunc(substring, mapping, cfg)
if err != nil {
// Add the template for template errors
var tmplErr *InvalidTemplateError
if errors.As(err, &tmplErr) {
if tmplErr.Template == "" {
tmplErr.Template = template
}
}
// Save the first error to be returned
if returnErr == nil {
returnErr = err
}
}
return replacement
})
return result, returnErr
}
func DefaultReplacementFunc(substring string, mapping Mapping, cfg *Config) (string, error) {
value, _, err := DefaultReplacementAppliedFunc(substring, mapping, cfg)
return value, err
}
func DefaultReplacementAppliedFunc(substring string, mapping Mapping, cfg *Config) (string, bool, error) {
pattern := cfg.pattern
subsFunc := cfg.substituteFunc
if subsFunc == nil {
_, subsFunc = getSubstitutionFunctionForTemplate(substring)
}
closingBraceIndex := getFirstBraceClosingIndex(substring)
rest := ""
if closingBraceIndex > -1 {
rest = substring[closingBraceIndex+1:]
substring = substring[0 : closingBraceIndex+1]
}
matches := pattern.FindStringSubmatch(substring)
groups := matchGroups(matches, pattern)
if escaped := groups[groupEscaped]; escaped != "" {
return escaped, true, nil
}
braced := false
substitution := groups[groupNamed]
if substitution == "" {
substitution = groups[groupBraced]
braced = true
}
if substitution == "" {
return "", false, &InvalidTemplateError{}
}
if braced {
value, applied, err := subsFunc(substitution, mapping)
if err != nil {
return "", false, err
}
if applied {
interpolatedNested, err := SubstituteWith(rest, mapping, pattern)
if err != nil {
return "", false, err
}
return value + interpolatedNested, true, nil
}
}
value, ok := mapping(substitution)
if !ok && cfg.logging {
logrus.Warnf("The %q variable is not set. Defaulting to a blank string.", substitution)
}
return value, ok, nil
}
// SubstituteWith substitute variables in the string with their values.
// It accepts additional substitute function.
func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) {
options := []Option{
WithPattern(pattern),
}
if len(subsFuncs) > 0 {
options = append(options, WithSubstitutionFunction(subsFuncs[0]))
}
return SubstituteWithOptions(template, mapping, options...)
}
func getSubstitutionFunctionForTemplate(template string) (string, SubstituteFunc) {
interpolationMapping := []struct {
string
SubstituteFunc
}{
{":?", requiredErrorWhenEmptyOrUnset},
{"?", requiredErrorWhenUnset},
{":-", defaultWhenEmptyOrUnset},
{"-", defaultWhenUnset},
{":+", defaultWhenNotEmpty},
{"+", defaultWhenSet},
}
sort.Slice(interpolationMapping, func(i, j int) bool {
idxI := strings.Index(template, interpolationMapping[i].string)
idxJ := strings.Index(template, interpolationMapping[j].string)
if idxI < 0 {
return false
}
if idxJ < 0 {
return true
}
return idxI < idxJ
})
return interpolationMapping[0].string, interpolationMapping[0].SubstituteFunc
}
func getFirstBraceClosingIndex(s string) int {
openVariableBraces := 0
for i := 0; i < len(s); i++ {
if s[i] == '}' {
openVariableBraces--
if openVariableBraces == 0 {
return i
}
}
if strings.HasPrefix(s[i:], "${") {
openVariableBraces++
i++
}
}
return -1
}
// Substitute variables in the string with their values
func Substitute(template string, mapping Mapping) (string, error) {
return SubstituteWith(template, mapping, defaultPattern)
}
// ExtractVariables returns a map of all the variables defined in the specified
// composefile (dict representation) and their default value if any.
func ExtractVariables(configDict map[string]interface{}, pattern *regexp.Regexp) map[string]Variable {
if pattern == nil {
pattern = defaultPattern
}
return recurseExtract(configDict, pattern)
}
func recurseExtract(value interface{}, pattern *regexp.Regexp) map[string]Variable {
m := map[string]Variable{}
switch value := value.(type) {
case string:
if values, is := extractVariable(value, pattern); is {
for _, v := range values {
m[v.Name] = v
}
}
case map[string]interface{}:
for _, elem := range value {
submap := recurseExtract(elem, pattern)
for key, value := range submap {
m[key] = value
}
}
case []interface{}:
for _, elem := range value {
if values, is := extractVariable(elem, pattern); is {
for _, v := range values {
m[v.Name] = v
}
}
}
}
return m
}
type Variable struct {
Name string
DefaultValue string
PresenceValue string
Required bool
}
func extractVariable(value interface{}, pattern *regexp.Regexp) ([]Variable, bool) {
sValue, ok := value.(string)
if !ok {
return []Variable{}, false
}
matches := pattern.FindAllStringSubmatch(sValue, -1)
if len(matches) == 0 {
return []Variable{}, false
}
values := []Variable{}
for _, match := range matches {
groups := matchGroups(match, pattern)
if escaped := groups[groupEscaped]; escaped != "" {
continue
}
val := groups[groupNamed]
if val == "" {
val = groups[groupBraced]
}
name := val
var defaultValue string
var presenceValue string
var required bool
switch {
case strings.Contains(val, ":?"):
name, _ = partition(val, ":?")
required = true
case strings.Contains(val, "?"):
name, _ = partition(val, "?")
required = true
case strings.Contains(val, ":-"):
name, defaultValue = partition(val, ":-")
case strings.Contains(val, "-"):
name, defaultValue = partition(val, "-")
case strings.Contains(val, ":+"):
name, presenceValue = partition(val, ":+")
case strings.Contains(val, "+"):
name, presenceValue = partition(val, "+")
}
values = append(values, Variable{
Name: name,
DefaultValue: defaultValue,
PresenceValue: presenceValue,
Required: required,
})
}
return values, len(values) > 0
}
// Soft default (fall back if unset or empty)
func defaultWhenEmptyOrUnset(substitution string, mapping Mapping) (string, bool, error) {
return withDefaultWhenAbsence(substitution, mapping, true)
}
// Hard default (fall back if-and-only-if empty)
func defaultWhenUnset(substitution string, mapping Mapping) (string, bool, error) {
return withDefaultWhenAbsence(substitution, mapping, false)
}
func defaultWhenNotEmpty(substitution string, mapping Mapping) (string, bool, error) {
return withDefaultWhenPresence(substitution, mapping, true)
}
func defaultWhenSet(substitution string, mapping Mapping) (string, bool, error) {
return withDefaultWhenPresence(substitution, mapping, false)
}
func requiredErrorWhenEmptyOrUnset(substitution string, mapping Mapping) (string, bool, error) {
return withRequired(substitution, mapping, ":?", func(v string) bool { return v != "" })
}
func requiredErrorWhenUnset(substitution string, mapping Mapping) (string, bool, error) {
return withRequired(substitution, mapping, "?", func(_ string) bool { return true })
}
func withDefaultWhenPresence(substitution string, mapping Mapping, notEmpty bool) (string, bool, error) {
sep := "+"
if notEmpty {
sep = ":+"
}
if !strings.Contains(substitution, sep) {
return "", false, nil
}
name, defaultValue := partition(substitution, sep)
defaultValue, err := Substitute(defaultValue, mapping)
if err != nil {
return "", false, err
}
value, ok := mapping(name)
if ok && (!notEmpty || (notEmpty && value != "")) {
return defaultValue, true, nil
}
return value, true, nil
}
func withDefaultWhenAbsence(substitution string, mapping Mapping, emptyOrUnset bool) (string, bool, error) {
sep := "-"
if emptyOrUnset {
sep = ":-"
}
if !strings.Contains(substitution, sep) {
return "", false, nil
}
name, defaultValue := partition(substitution, sep)
defaultValue, err := Substitute(defaultValue, mapping)
if err != nil {
return "", false, err
}
value, ok := mapping(name)
if !ok || (emptyOrUnset && value == "") {
return defaultValue, true, nil
}
return value, true, nil
}
func withRequired(substitution string, mapping Mapping, sep string, valid func(string) bool) (string, bool, error) {
if !strings.Contains(substitution, sep) {
return "", false, nil
}
name, errorMessage := partition(substitution, sep)
errorMessage, err := Substitute(errorMessage, mapping)
if err != nil {
return "", false, err
}
value, ok := mapping(name)
if !ok || !valid(value) {
return "", true, &MissingRequiredError{
Reason: errorMessage,
Variable: name,
}
}
return value, true, nil
}
func matchGroups(matches []string, pattern *regexp.Regexp) map[string]string {
groups := make(map[string]string)
for i, name := range pattern.SubexpNames()[1:] {
groups[name] = matches[i+1]
}
return groups
}
// Split the string at the first occurrence of sep, and return the part before the separator,
// and the part after the separator.
//
// If the separator is not found, return the string itself, followed by an empty string.
func partition(s, sep string) (string, string) {
if strings.Contains(s, sep) {
parts := strings.SplitN(s, sep, 2)
return parts[0], parts[1]
}
return s, ""
}

View File

@ -0,0 +1,39 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformBuild(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["context"]; !ok {
v["context"] = "." // TODO(ndeloof) maybe we miss an explicit "set-defaults" loading phase
}
return transformMapping(v, p)
case string:
return map[string]any{
"context": v,
}, nil
default:
return data, fmt.Errorf("%s: invalid type %T for build", p, v)
}
}

View File

@ -0,0 +1,107 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"github.com/compose-spec/compose-go/v2/tree"
)
type transformFunc func(data any, p tree.Path) (any, error)
var transformers = map[tree.Path]transformFunc{}
func init() {
transformers["services.*"] = transformService
transformers["services.*.build.secrets.*"] = transformFileMount
transformers["services.*.build.additional_contexts"] = transformKeyValue
transformers["services.*.depends_on"] = transformDependsOn
transformers["services.*.env_file"] = transformEnvFile
transformers["services.*.extends"] = transformExtends
transformers["services.*.networks"] = transformServiceNetworks
transformers["services.*.volumes.*"] = transformVolumeMount
transformers["services.*.secrets.*"] = transformFileMount
transformers["services.*.configs.*"] = transformFileMount
transformers["services.*.ports"] = transformPorts
transformers["services.*.build"] = transformBuild
transformers["services.*.build.ssh"] = transformSSH
transformers["services.*.ulimits.*"] = transformUlimits
transformers["services.*.build.ulimits.*"] = transformUlimits
transformers["volumes.*"] = transformMaybeExternal
transformers["networks.*"] = transformMaybeExternal
transformers["secrets.*"] = transformMaybeExternal
transformers["configs.*"] = transformMaybeExternal
transformers["include.*"] = transformInclude
}
// Canonical transforms a compose model into canonical syntax
func Canonical(yaml map[string]any) (map[string]any, error) {
canonical, err := transform(yaml, tree.NewPath())
if err != nil {
return nil, err
}
return canonical.(map[string]any), nil
}
func transform(data any, p tree.Path) (any, error) {
for pattern, transformer := range transformers {
if p.Matches(pattern) {
t, err := transformer(data, p)
if err != nil {
return nil, err
}
return t, nil
}
}
switch v := data.(type) {
case map[string]any:
a, err := transformMapping(v, p)
if err != nil {
return a, err
}
return v, nil
case []any:
a, err := transformSequence(v, p)
if err != nil {
return a, err
}
return v, nil
default:
return data, nil
}
}
func transformSequence(v []any, p tree.Path) ([]any, error) {
for i, e := range v {
t, err := transform(e, p.Next("[]"))
if err != nil {
return nil, err
}
v[i] = t
}
return v, nil
}
func transformMapping(v map[string]any, p tree.Path) (map[string]any, error) {
for k, e := range v {
t, err := transform(e, p.Next(k))
if err != nil {
return nil, err
}
v[k] = t
}
return v, nil
}

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformDependsOn(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
for i, e := range v {
d, ok := e.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s.%s: unsupported value %s", p, i, v)
}
if _, ok := d["condition"]; !ok {
d["condition"] = "service_started"
}
if _, ok := d["required"]; !ok {
d["required"] = true
}
}
return v, nil
case []any:
d := map[string]any{}
for _, k := range v {
d[k.(string)] = map[string]any{
"condition": "service_started",
"required": true,
}
}
return d, nil
default:
return data, fmt.Errorf("%s: invalid type %T for depend_on", p, v)
}
}

View File

@ -0,0 +1,55 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformEnvFile(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case string:
return []any{
transformEnvFileValue(v),
}, nil
case []any:
for i, e := range v {
v[i] = transformEnvFileValue(e)
}
return v, nil
default:
return nil, fmt.Errorf("%s: invalid type %T for env_file", p, v)
}
}
func transformEnvFileValue(data any) any {
switch v := data.(type) {
case string:
return map[string]any{
"path": v,
"required": true,
}
case map[string]any:
if _, ok := v["required"]; !ok {
v["required"] = true
}
return v
}
return nil
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformExtends(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return transformMapping(v, p)
case string:
return map[string]any{
"service": v,
}, nil
default:
return data, fmt.Errorf("%s: invalid type %T for extends", p, v)
}
}

View File

@ -0,0 +1,54 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/sirupsen/logrus"
)
func transformMaybeExternal(data any, p tree.Path) (any, error) {
if data == nil {
return nil, nil
}
resource, err := transformMapping(data.(map[string]any), p)
if err != nil {
return nil, err
}
if ext, ok := resource["external"]; ok {
name, named := resource["name"]
if external, ok := ext.(map[string]any); ok {
resource["external"] = true
if extname, extNamed := external["name"]; extNamed {
logrus.Warnf("%s: external.name is deprecated. Please set name and external: true", p)
if named && extname != name {
return nil, fmt.Errorf("%s: name and external.name conflict; only use name", p)
}
if !named {
// adopt (deprecated) external.name if set
resource["name"] = extname
return resource, nil
}
}
}
}
return resource, nil
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformInclude(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return v, nil
case string:
return map[string]any{
"path": v,
}, nil
default:
return data, fmt.Errorf("%s: invalid type %T for external", p, v)
}
}

View File

@ -0,0 +1,43 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformKeyValue(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return v, nil
case []any:
mapping := map[string]any{}
for _, e := range v {
before, after, found := strings.Cut(e.(string), "=")
if !found {
return nil, fmt.Errorf("%s: invalid value %s, expected key=value", p, e)
}
mapping[before] = after
}
return mapping, nil
default:
return nil, fmt.Errorf("%s: invalid type %T", p, v)
}
}

View File

@ -0,0 +1,86 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
"github.com/mitchellh/mapstructure"
)
func transformPorts(data any, p tree.Path) (any, error) {
switch entries := data.(type) {
case []any:
// We process the list instead of individual items here.
// The reason is that one entry might be mapped to multiple ServicePortConfig.
// Therefore we take an input of a list and return an output of a list.
var ports []any
for _, entry := range entries {
switch value := entry.(type) {
case int:
parsed, err := types.ParsePortConfig(fmt.Sprint(value))
if err != nil {
return data, err
}
for _, v := range parsed {
m, err := encode(v)
if err != nil {
return nil, err
}
ports = append(ports, m)
}
case string:
parsed, err := types.ParsePortConfig(value)
if err != nil {
return data, err
}
if err != nil {
return nil, err
}
for _, v := range parsed {
m, err := encode(v)
if err != nil {
return nil, err
}
ports = append(ports, m)
}
case map[string]any:
ports = append(ports, value)
default:
return data, fmt.Errorf("%s: invalid type %T for port", p, value)
}
}
return ports, nil
default:
return data, fmt.Errorf("%s: invalid type %T for port", p, entries)
}
}
func encode(v any) (map[string]any, error) {
m := map[string]any{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &m,
TagName: "yaml",
})
if err != nil {
return nil, err
}
err = decoder.Decode(v)
return m, err
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformFileMount(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return data, nil
case string:
return map[string]any{
"source": v,
}, nil
default:
return nil, fmt.Errorf("%s: unsupported type %T", p, data)
}
}

View File

@ -0,0 +1,41 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"github.com/compose-spec/compose-go/v2/tree"
)
func transformService(data any, p tree.Path) (any, error) {
switch value := data.(type) {
case map[string]any:
return transformMapping(value, p)
default:
return value, nil
}
}
func transformServiceNetworks(data any, _ tree.Path) (any, error) {
if slice, ok := data.([]any); ok {
networks := make(map[string]any, len(slice))
for _, net := range slice {
networks[net.(string)] = nil
}
return networks, nil
}
return data, nil
}

View File

@ -0,0 +1,51 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformSSH(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return v, nil
case []any:
result := make(map[string]any, len(v))
for _, e := range v {
s, ok := e.(string)
if !ok {
return nil, fmt.Errorf("invalid ssh key type %T", e)
}
id, path, ok := strings.Cut(s, "=")
if !ok {
if id != "default" {
return nil, fmt.Errorf("invalid ssh key %q", s)
}
result[id] = nil
continue
}
result[id] = path
}
return result, nil
default:
return data, fmt.Errorf("%s: invalid type %T for ssh", p, v)
}
}

View File

@ -0,0 +1,34 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformUlimits(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return v, nil
case int:
return v, nil
default:
return data, fmt.Errorf("%s: invalid type %T for external", p, v)
}
}

View File

@ -0,0 +1,49 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"fmt"
"path"
"github.com/compose-spec/compose-go/v2/format"
"github.com/compose-spec/compose-go/v2/tree"
)
func transformVolumeMount(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
return v, nil
case string:
volume, err := format.ParseVolume(v) // TODO(ndeloof) ParseVolume should not rely on types and return map[string]
if err != nil {
return nil, err
}
volume.Target = cleanTarget(volume.Target)
return encode(volume)
default:
return data, fmt.Errorf("%s: invalid type %T for service volume mount", p, v)
}
}
func cleanTarget(target string) string {
if target == "" {
return ""
}
return path.Clean(target)
}

View File

@ -0,0 +1,87 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package tree
import (
"strings"
)
const pathSeparator = "."
// PathMatchAll is a token used as part of a Path to match any key at that level
// in the nested structure
const PathMatchAll = "*"
// PathMatchList is a token used as part of a Path to match items in a list
const PathMatchList = "[]"
// Path is a dotted path of keys to a value in a nested mapping structure. A *
// section in a path will match any key in the mapping structure.
type Path string
// NewPath returns a new Path
func NewPath(items ...string) Path {
return Path(strings.Join(items, pathSeparator))
}
// Next returns a new path by append part to the current path
func (p Path) Next(part string) Path {
if p == "" {
return Path(part)
}
part = strings.ReplaceAll(part, pathSeparator, "👻")
return Path(string(p) + pathSeparator + part)
}
func (p Path) Parts() []string {
return strings.Split(string(p), pathSeparator)
}
func (p Path) Matches(pattern Path) bool {
patternParts := pattern.Parts()
parts := p.Parts()
if len(patternParts) != len(parts) {
return false
}
for index, part := range parts {
switch patternParts[index] {
case PathMatchAll, part:
continue
default:
return false
}
}
return true
}
func (p Path) Last() string {
parts := p.Parts()
return parts[len(parts)-1]
}
func (p Path) Parent() Path {
index := strings.LastIndex(string(p), pathSeparator)
if index > 0 {
return p[0:index]
}
return ""
}
func (p Path) String() string {
return strings.ReplaceAll(string(p), "👻", pathSeparator)
}

View File

@ -0,0 +1,48 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
"github.com/docker/go-units"
)
// UnitBytes is the bytes type
type UnitBytes int64
// MarshalYAML makes UnitBytes implement yaml.Marshaller
func (u UnitBytes) MarshalYAML() (interface{}, error) {
return fmt.Sprintf("%d", u), nil
}
// MarshalJSON makes UnitBytes implement json.Marshaler
func (u UnitBytes) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%d"`, u)), nil
}
func (u *UnitBytes) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case int:
*u = UnitBytes(v)
case string:
b, err := units.RAMInBytes(fmt.Sprint(value))
*u = UnitBytes(b)
return err
}
return nil
}

View File

@ -0,0 +1,86 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import "github.com/mattn/go-shellwords"
// ShellCommand is a string or list of string args.
//
// When marshaled to YAML, nil command fields will be omitted if `omitempty`
// is specified as a struct tag. Explicitly empty commands (i.e. `[]` or
// empty string will serialize to an empty array (`[]`).
//
// When marshaled to JSON, the `omitempty` struct must NOT be specified.
// If the command field is nil, it will be serialized as `null`.
// Explicitly empty commands (i.e. `[]` or empty string) will serialize to
// an empty array (`[]`).
//
// The distinction between nil and explicitly empty is important to distinguish
// between an unset value and a provided, but empty, value, which should be
// preserved so that it can override any base value (e.g. container entrypoint).
//
// The different semantics between YAML and JSON are due to limitations with
// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v3 gives
// us more flexibility via the yaml.IsZeroer interface.
//
// In the future, it might make sense to make fields of this type be
// `*ShellCommand` to avoid this situation, but that would constitute a
// breaking change.
type ShellCommand []string
// IsZero returns true if the slice is nil.
//
// Empty (but non-nil) slices are NOT considered zero values.
func (s ShellCommand) IsZero() bool {
// we do NOT want len(s) == 0, ONLY explicitly nil
return s == nil
}
// MarshalYAML returns nil (which will be serialized as `null`) for nil slices
// and delegates to the standard marshaller behavior otherwise.
//
// NOTE: Typically the nil case here is not hit because IsZero has already
// short-circuited marshalling, but this ensures that the type serializes
// accurately if the `omitempty` struct tag is omitted/forgotten.
//
// A similar MarshalJSON() implementation is not needed because the Go stdlib
// already serializes nil slices to `null`, whereas gopkg.in/yaml.v3 by default
// serializes nil slices to `[]`.
func (s ShellCommand) MarshalYAML() (interface{}, error) {
if s == nil {
return nil, nil
}
return []string(s), nil
}
func (s *ShellCommand) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case string:
cmd, err := shellwords.Parse(v)
if err != nil {
return err
}
*s = cmd
case []interface{}:
cmd := make([]string, len(v))
for i, s := range v {
cmd[i] = s.(string)
}
*s = cmd
}
return nil
}

View File

@ -0,0 +1,135 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"encoding/json"
"runtime"
"strings"
"github.com/mitchellh/mapstructure"
)
var (
// isCaseInsensitiveEnvVars is true on platforms where environment variable names are treated case-insensitively.
isCaseInsensitiveEnvVars = (runtime.GOOS == "windows")
)
// ConfigDetails are the details about a group of ConfigFiles
type ConfigDetails struct {
Version string
WorkingDir string
ConfigFiles []ConfigFile
Environment Mapping
}
// LookupEnv provides a lookup function for environment variables
func (cd *ConfigDetails) LookupEnv(key string) (string, bool) {
v, ok := cd.Environment[key]
if !isCaseInsensitiveEnvVars || ok {
return v, ok
}
// variable names must be treated case-insensitively on some platforms (that is, Windows).
// Resolves in this way:
// * Return the value if its name matches with the passed name case-sensitively.
// * Otherwise, return the value if its lower-cased name matches lower-cased passed name.
// * The value is indefinite if multiple variables match.
lowerKey := strings.ToLower(key)
for k, v := range cd.Environment {
if strings.ToLower(k) == lowerKey {
return v, true
}
}
return "", false
}
// ConfigFile is a filename and the contents of the file as a Dict
type ConfigFile struct {
// Filename is the name of the yaml configuration file
Filename string
// Content is the raw yaml content. Will be loaded from Filename if not set
Content []byte
// Config if the yaml tree for this config file. Will be parsed from Content if not set
Config map[string]interface{}
}
func ToConfigFiles(path []string) (f []ConfigFile) {
for _, p := range path {
f = append(f, ConfigFile{Filename: p})
}
return
}
// Config is a full compose file configuration and model
type Config struct {
Filename string `yaml:"-" json:"-"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Services Services `yaml:"services" json:"services"`
Networks Networks `yaml:"networks,omitempty" json:"networks,omitempty"`
Volumes Volumes `yaml:"volumes,omitempty" json:"volumes,omitempty"`
Secrets Secrets `yaml:"secrets,omitempty" json:"secrets,omitempty"`
Configs Configs `yaml:"configs,omitempty" json:"configs,omitempty"`
Extensions Extensions `yaml:",inline" json:"-"`
Include []IncludeConfig `yaml:"include,omitempty" json:"include,omitempty"`
}
// Volumes is a map of VolumeConfig
type Volumes map[string]VolumeConfig
// Networks is a map of NetworkConfig
type Networks map[string]NetworkConfig
// Secrets is a map of SecretConfig
type Secrets map[string]SecretConfig
// Configs is a map of ConfigObjConfig
type Configs map[string]ConfigObjConfig
// Extensions is a map of custom extension
type Extensions map[string]interface{}
// MarshalJSON makes Config implement json.Marshaler
func (c Config) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{
"services": c.Services,
}
if len(c.Networks) > 0 {
m["networks"] = c.Networks
}
if len(c.Volumes) > 0 {
m["volumes"] = c.Volumes
}
if len(c.Secrets) > 0 {
m["secrets"] = c.Secrets
}
if len(c.Configs) > 0 {
m["configs"] = c.Configs
}
for k, v := range c.Extensions {
m[k] = v
}
return json.Marshal(m)
}
func (e Extensions) Get(name string, target interface{}) (bool, error) {
if v, ok := e[name]; ok {
err := mapstructure.Decode(v, target)
return true, err
}
return false, nil
}

View File

@ -0,0 +1,38 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
type DevelopConfig struct {
Watch []Trigger `json:"watch,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
type WatchAction string
const (
WatchActionSync WatchAction = "sync"
WatchActionRebuild WatchAction = "rebuild"
WatchActionSyncRestart WatchAction = "sync+restart"
)
type Trigger struct {
Path string `json:"path,omitempty"`
Action WatchAction `json:"action,omitempty"`
Target string `json:"target,omitempty"`
Ignore []string `json:"ignore,omitempty"`
}

View File

@ -0,0 +1,52 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
"strconv"
"strings"
)
type DeviceRequest struct {
Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
Count DeviceCount `yaml:"count,omitempty" json:"count,omitempty"`
IDs []string `yaml:"device_ids,omitempty" json:"device_ids,omitempty"`
}
type DeviceCount int64
func (c *DeviceCount) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case int:
*c = DeviceCount(v)
case string:
if strings.ToLower(v) == "all" {
*c = -1
return nil
}
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("invalid value %q, the only value allowed is 'all' or a number", v)
}
*c = DeviceCount(i)
default:
return fmt.Errorf("invalid type %T for device count", v)
}
return nil
}

View File

@ -0,0 +1,60 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// Duration is a thin wrapper around time.Duration with improved JSON marshalling
type Duration time.Duration
func (d Duration) String() string {
return time.Duration(d).String()
}
func (d *Duration) DecodeMapstructure(value interface{}) error {
v, err := time.ParseDuration(fmt.Sprint(value))
if err != nil {
return err
}
*d = Duration(v)
return nil
}
// MarshalJSON makes Duration implement json.Marshaler
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// MarshalYAML makes Duration implement yaml.Marshaler
func (d Duration) MarshalYAML() (interface{}, error) {
return d.String(), nil
}
func (d *Duration) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
timeDuration, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(timeDuration)
return nil
}

View File

@ -0,0 +1,46 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"encoding/json"
)
type EnvFile struct {
Path string `yaml:"path,omitempty" json:"path,omitempty"`
Required bool `yaml:"required" json:"required"`
}
// MarshalYAML makes EnvFile implement yaml.Marshaler
func (e EnvFile) MarshalYAML() (interface{}, error) {
if e.Required {
return e.Path, nil
}
return map[string]any{
"path": e.Path,
"required": e.Required,
}, nil
}
// MarshalJSON makes EnvFile implement json.Marshaler
func (e *EnvFile) MarshalJSON() ([]byte, error) {
if e.Required {
return json.Marshal(e.Path)
}
// Pass as a value to avoid re-entering this method and use the default implementation
return json.Marshal(*e)
}

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
)
// HealthCheckConfig the healthcheck configuration for a service
type HealthCheckConfig struct {
Test HealthCheckTest `yaml:"test,omitempty" json:"test,omitempty"`
Timeout *Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
Interval *Duration `yaml:"interval,omitempty" json:"interval,omitempty"`
Retries *uint64 `yaml:"retries,omitempty" json:"retries,omitempty"`
StartPeriod *Duration `yaml:"start_period,omitempty" json:"start_period,omitempty"`
StartInterval *Duration `yaml:"start_interval,omitempty" json:"start_interval,omitempty"`
Disable bool `yaml:"disable,omitempty" json:"disable,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// HealthCheckTest is the command run to test the health of a service
type HealthCheckTest []string
func (l *HealthCheckTest) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case string:
*l = []string{"CMD-SHELL", v}
case []interface{}:
seq := make([]string, len(v))
for i, e := range v {
seq[i] = e.(string)
}
*l = seq
default:
return fmt.Errorf("unexpected value type %T for healthcheck.test", value)
}
return nil
}

View File

@ -0,0 +1,83 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// HostsList is a list of colon-separated host-ip mappings
type HostsList map[string]string
// AsList returns host-ip mappings as a list of strings, using the given
// separator. The Docker Engine API expects ':' separators, the original format
// for '--add-hosts'. But an '=' separator is used in YAML/JSON renderings to
// make IPv6 addresses more readable (for example "my-host=::1" instead of
// "my-host:::1").
func (h HostsList) AsList(sep string) []string {
l := make([]string, 0, len(h))
for k, v := range h {
l = append(l, fmt.Sprintf("%s%s%s", k, sep, v))
}
return l
}
func (h HostsList) MarshalYAML() (interface{}, error) {
list := h.AsList("=")
sort.Strings(list)
return list, nil
}
func (h HostsList) MarshalJSON() ([]byte, error) {
list := h.AsList("=")
sort.Strings(list)
return json.Marshal(list)
}
func (h *HostsList) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
list := make(HostsList, len(v))
for i, e := range v {
if e == nil {
e = ""
}
list[i] = fmt.Sprint(e)
}
*h = list
case []interface{}:
*h = decodeMapping(v, "=", ":")
default:
return fmt.Errorf("unexpected value type %T for mapping", value)
}
for host, ip := range *h {
// Check that there is a hostname and that it doesn't contain either
// of the allowed separators, to generate a clearer error than the
// engine would do if it splits the string differently.
if host == "" || strings.ContainsAny(host, ":=") {
return fmt.Errorf("bad host name '%s'", host)
}
// Remove brackets from IP addresses (for example "[::1]" -> "::1").
if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
(*h)[host] = ip[1 : len(ip)-1]
}
}
return nil
}

View File

@ -0,0 +1,77 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
"strings"
)
// Labels is a mapping type for labels
type Labels map[string]string
func (l Labels) Add(key, value string) Labels {
if l == nil {
l = Labels{}
}
l[key] = value
return l
}
func (l Labels) AsList() []string {
s := make([]string, len(l))
i := 0
for k, v := range l {
s[i] = fmt.Sprintf("%s=%s", k, v)
i++
}
return s
}
// label value can be a string | number | boolean | null (empty)
func labelValue(e interface{}) string {
if e == nil {
return ""
}
switch v := e.(type) {
case string:
return v
default:
return fmt.Sprint(v)
}
}
func (l *Labels) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
labels := make(map[string]string, len(v))
for k, e := range v {
labels[k] = labelValue(e)
}
*l = labels
case []interface{}:
labels := make(map[string]string, len(v))
for _, s := range v {
k, e, _ := strings.Cut(fmt.Sprint(s), "=")
labels[k] = labelValue(e)
}
*l = labels
default:
return fmt.Errorf("unexpected value type %T for labels", value)
}
return nil
}

View File

@ -0,0 +1,217 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
"sort"
"strings"
)
// MappingWithEquals is a mapping type that can be converted from a list of
// key[=value] strings.
// For the key with an empty value (`key=`), the mapped value is set to a pointer to `""`.
// For the key without value (`key`), the mapped value is set to nil.
type MappingWithEquals map[string]*string
// NewMappingWithEquals build a new Mapping from a set of KEY=VALUE strings
func NewMappingWithEquals(values []string) MappingWithEquals {
mapping := MappingWithEquals{}
for _, env := range values {
tokens := strings.SplitN(env, "=", 2)
if len(tokens) > 1 {
mapping[tokens[0]] = &tokens[1]
} else {
mapping[env] = nil
}
}
return mapping
}
// OverrideBy update MappingWithEquals with values from another MappingWithEquals
func (m MappingWithEquals) OverrideBy(other MappingWithEquals) MappingWithEquals {
for k, v := range other {
m[k] = v
}
return m
}
// Resolve update a MappingWithEquals for keys without value (`key`, but not `key=`)
func (m MappingWithEquals) Resolve(lookupFn func(string) (string, bool)) MappingWithEquals {
for k, v := range m {
if v == nil {
if value, ok := lookupFn(k); ok {
m[k] = &value
}
}
}
return m
}
// RemoveEmpty excludes keys that are not associated with a value
func (m MappingWithEquals) RemoveEmpty() MappingWithEquals {
for k, v := range m {
if v == nil {
delete(m, k)
}
}
return m
}
func (m *MappingWithEquals) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
mapping := make(MappingWithEquals, len(v))
for k, e := range v {
mapping[k] = mappingValue(e)
}
*m = mapping
case []interface{}:
mapping := make(MappingWithEquals, len(v))
for _, s := range v {
k, e, ok := strings.Cut(fmt.Sprint(s), "=")
if !ok {
mapping[k] = nil
} else {
mapping[k] = mappingValue(e)
}
}
*m = mapping
default:
return fmt.Errorf("unexpected value type %T for mapping", value)
}
return nil
}
// label value can be a string | number | boolean | null
func mappingValue(e interface{}) *string {
if e == nil {
return nil
}
switch v := e.(type) {
case string:
return &v
default:
s := fmt.Sprint(v)
return &s
}
}
// Mapping is a mapping type that can be converted from a list of
// key[=value] strings.
// For the key with an empty value (`key=`), or key without value (`key`), the
// mapped value is set to an empty string `""`.
type Mapping map[string]string
// NewMapping build a new Mapping from a set of KEY=VALUE strings
func NewMapping(values []string) Mapping {
mapping := Mapping{}
for _, value := range values {
parts := strings.SplitN(value, "=", 2)
key := parts[0]
switch {
case len(parts) == 1:
mapping[key] = ""
default:
mapping[key] = parts[1]
}
}
return mapping
}
// convert values into a set of KEY=VALUE strings
func (m Mapping) Values() []string {
values := make([]string, 0, len(m))
for k, v := range m {
values = append(values, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(values)
return values
}
// ToMappingWithEquals converts Mapping into a MappingWithEquals with pointer references
func (m Mapping) ToMappingWithEquals() MappingWithEquals {
mapping := MappingWithEquals{}
for k, v := range m {
v := v
mapping[k] = &v
}
return mapping
}
func (m Mapping) Resolve(s string) (string, bool) {
v, ok := m[s]
return v, ok
}
func (m Mapping) Clone() Mapping {
clone := Mapping{}
for k, v := range m {
clone[k] = v
}
return clone
}
// Merge adds all values from second mapping which are not already defined
func (m Mapping) Merge(o Mapping) Mapping {
for k, v := range o {
if _, set := m[k]; !set {
m[k] = v
}
}
return m
}
func (m *Mapping) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
mapping := make(Mapping, len(v))
for k, e := range v {
if e == nil {
e = ""
}
mapping[k] = fmt.Sprint(e)
}
*m = mapping
case []interface{}:
*m = decodeMapping(v, "=")
default:
return fmt.Errorf("unexpected value type %T for mapping", value)
}
return nil
}
// Generate a mapping by splitting strings at any of seps, which will be tried
// in-order for each input string. (For example, to allow the preferred 'host=ip'
// in 'extra_hosts', as well as 'host:ip' for backwards compatibility.)
func decodeMapping(v []interface{}, seps ...string) map[string]string {
mapping := make(Mapping, len(v))
for _, s := range v {
for i, sep := range seps {
k, e, ok := strings.Cut(fmt.Sprint(s), sep)
if ok {
// Mapping found with this separator, stop here.
mapping[k] = e
break
} else if i == len(seps)-1 {
// No more separators to try, map to empty string.
mapping[k] = ""
}
}
}
return mapping
}

View File

@ -0,0 +1,42 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import "fmt"
// Options is a mapping type for options we pass as-is to container runtime
type Options map[string]string
func (d *Options) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
m := make(map[string]string)
for key, e := range v {
if e == nil {
m[key] = ""
} else {
m[key] = fmt.Sprint(e)
}
}
*d = m
case map[string]string:
*d = v
default:
return fmt.Errorf("invalid type %T for options", value)
}
return nil
}

View File

@ -0,0 +1,616 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/utils"
"github.com/distribution/reference"
"github.com/mitchellh/copystructure"
godigest "github.com/opencontainers/go-digest"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
// Project is the result of loading a set of compose files
// Since v2, Project are managed as immutable objects.
// Each public functions which mutate Project state now return a copy of the original Project with the expected changes.
type Project struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
WorkingDir string `yaml:"-" json:"-"`
Services Services `yaml:"services" json:"services"`
Networks Networks `yaml:"networks,omitempty" json:"networks,omitempty"`
Volumes Volumes `yaml:"volumes,omitempty" json:"volumes,omitempty"`
Secrets Secrets `yaml:"secrets,omitempty" json:"secrets,omitempty"`
Configs Configs `yaml:"configs,omitempty" json:"configs,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"` // https://github.com/golang/go/issues/6213
// IncludeReferences is keyed by Compose YAML filename and contains config for
// other Compose YAML files it directly triggered a load of via `include`.
//
// Note: this is
IncludeReferences map[string][]IncludeConfig `yaml:"-" json:"-"`
ComposeFiles []string `yaml:"-" json:"-"`
Environment Mapping `yaml:"-" json:"-"`
// DisabledServices track services which have been disable as profile is not active
DisabledServices Services `yaml:"-" json:"-"`
Profiles []string `yaml:"-" json:"-"`
}
// ServiceNames return names for all services in this Compose config
func (p *Project) ServiceNames() []string {
var names []string
for k := range p.Services {
names = append(names, k)
}
sort.Strings(names)
return names
}
// DisabledServiceNames return names for all disabled services in this Compose config
func (p *Project) DisabledServiceNames() []string {
var names []string
for k := range p.DisabledServices {
names = append(names, k)
}
sort.Strings(names)
return names
}
// VolumeNames return names for all volumes in this Compose config
func (p *Project) VolumeNames() []string {
var names []string
for k := range p.Volumes {
names = append(names, k)
}
sort.Strings(names)
return names
}
// NetworkNames return names for all volumes in this Compose config
func (p *Project) NetworkNames() []string {
var names []string
for k := range p.Networks {
names = append(names, k)
}
sort.Strings(names)
return names
}
// SecretNames return names for all secrets in this Compose config
func (p *Project) SecretNames() []string {
var names []string
for k := range p.Secrets {
names = append(names, k)
}
sort.Strings(names)
return names
}
// ConfigNames return names for all configs in this Compose config
func (p *Project) ConfigNames() []string {
var names []string
for k := range p.Configs {
names = append(names, k)
}
sort.Strings(names)
return names
}
// GetServices retrieve services by names, or return all services if no name specified
func (p *Project) GetServices(names ...string) (Services, error) {
if len(names) == 0 {
return p.Services, nil
}
services := Services{}
for _, name := range names {
service, err := p.GetService(name)
if err != nil {
return nil, err
}
services[name] = service
}
return services, nil
}
func (p *Project) getServicesByNames(names ...string) (Services, []string) {
if len(names) == 0 {
return p.Services, nil
}
services := Services{}
var servicesNotFound []string
for _, name := range names {
service, ok := p.Services[name]
if !ok {
servicesNotFound = append(servicesNotFound, name)
continue
}
services[name] = service
}
return services, servicesNotFound
}
// GetDisabledService retrieve disabled service by name
func (p Project) GetDisabledService(name string) (ServiceConfig, error) {
service, ok := p.DisabledServices[name]
if !ok {
return ServiceConfig{}, fmt.Errorf("no such service: %s", name)
}
return service, nil
}
// GetService retrieve a specific service by name
func (p *Project) GetService(name string) (ServiceConfig, error) {
service, ok := p.Services[name]
if !ok {
_, ok := p.DisabledServices[name]
if ok {
return ServiceConfig{}, fmt.Errorf("service %s is disabled", name)
}
return ServiceConfig{}, fmt.Errorf("no such service: %s", name)
}
return service, nil
}
func (p *Project) AllServices() Services {
all := Services{}
for name, service := range p.Services {
all[name] = service
}
for name, service := range p.DisabledServices {
all[name] = service
}
return all
}
type ServiceFunc func(name string, service *ServiceConfig) error
// ForEachService runs ServiceFunc on each service and dependencies according to DependencyPolicy
func (p *Project) ForEachService(names []string, fn ServiceFunc, options ...DependencyOption) error {
if len(options) == 0 {
// backward compatibility
options = []DependencyOption{IncludeDependencies}
}
return p.withServices(names, fn, map[string]bool{}, options, map[string]ServiceDependency{})
}
type withServicesOptions struct {
dependencyPolicy int
}
const (
includeDependencies = iota
includeDependents
ignoreDependencies
)
func (p *Project) withServices(names []string, fn ServiceFunc, seen map[string]bool, options []DependencyOption, dependencies map[string]ServiceDependency) error {
services, servicesNotFound := p.getServicesByNames(names...)
if len(servicesNotFound) > 0 {
for _, serviceNotFound := range servicesNotFound {
if dependency, ok := dependencies[serviceNotFound]; !ok || dependency.Required {
return fmt.Errorf("no such service: %s", serviceNotFound)
}
}
}
opts := withServicesOptions{
dependencyPolicy: includeDependencies,
}
for _, option := range options {
option(&opts)
}
for name, service := range services {
if seen[name] {
continue
}
seen[name] = true
var dependencies map[string]ServiceDependency
switch opts.dependencyPolicy {
case includeDependents:
dependencies = utils.MapsAppend(dependencies, p.dependentsForService(service))
case includeDependencies:
dependencies = utils.MapsAppend(dependencies, service.DependsOn)
case ignoreDependencies:
// Noop
}
if len(dependencies) > 0 {
err := p.withServices(utils.MapKeys(dependencies), fn, seen, options, dependencies)
if err != nil {
return err
}
}
if err := fn(name, service.deepCopy()); err != nil {
return err
}
}
return nil
}
func (p *Project) GetDependentsForService(s ServiceConfig) []string {
return utils.MapKeys(p.dependentsForService(s))
}
func (p *Project) dependentsForService(s ServiceConfig) map[string]ServiceDependency {
dependent := make(map[string]ServiceDependency)
for _, service := range p.Services {
for name, dependency := range service.DependsOn {
if name == s.Name {
dependent[service.Name] = dependency
}
}
}
return dependent
}
// RelativePath resolve a relative path based project's working directory
func (p *Project) RelativePath(path string) string {
if path[0] == '~' {
home, _ := os.UserHomeDir()
path = filepath.Join(home, path[1:])
}
if filepath.IsAbs(path) {
return path
}
return filepath.Join(p.WorkingDir, path)
}
// HasProfile return true if service has no profile declared or has at least one profile matching
func (s ServiceConfig) HasProfile(profiles []string) bool {
if len(s.Profiles) == 0 {
return true
}
for _, p := range profiles {
for _, sp := range s.Profiles {
if sp == p {
return true
}
}
}
return false
}
// WithProfiles disables services which don't match selected profiles
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithProfiles(profiles []string) (*Project, error) {
newProject := p.deepCopy()
for _, p := range profiles {
if p == "*" {
return newProject, nil
}
}
enabled := Services{}
disabled := Services{}
for name, service := range newProject.AllServices() {
if service.HasProfile(profiles) {
enabled[name] = service
} else {
disabled[name] = service
}
}
newProject.Services = enabled
newProject.DisabledServices = disabled
newProject.Profiles = profiles
return newProject, nil
}
// WithServicesEnabled ensures services are enabled and activate profiles accordingly
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithServicesEnabled(names ...string) (*Project, error) {
newProject := p.deepCopy()
if len(names) == 0 {
return newProject, nil
}
profiles := append([]string{}, p.Profiles...)
for _, name := range names {
if _, ok := newProject.Services[name]; ok {
// already enabled
continue
}
service := p.DisabledServices[name]
profiles = append(profiles, service.Profiles...)
}
newProject, err := newProject.WithProfiles(profiles)
if err != nil {
return newProject, err
}
return newProject.WithServicesEnvironmentResolved(true)
}
// WithoutUnnecessaryResources drops networks/volumes/secrets/configs that are not referenced by active services
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithoutUnnecessaryResources() *Project {
newProject := p.deepCopy()
requiredNetworks := map[string]struct{}{}
requiredVolumes := map[string]struct{}{}
requiredSecrets := map[string]struct{}{}
requiredConfigs := map[string]struct{}{}
for _, s := range newProject.Services {
for k := range s.Networks {
requiredNetworks[k] = struct{}{}
}
for _, v := range s.Volumes {
if v.Type != VolumeTypeVolume || v.Source == "" {
continue
}
requiredVolumes[v.Source] = struct{}{}
}
for _, v := range s.Secrets {
requiredSecrets[v.Source] = struct{}{}
}
if s.Build != nil {
for _, v := range s.Build.Secrets {
requiredSecrets[v.Source] = struct{}{}
}
}
for _, v := range s.Configs {
requiredConfigs[v.Source] = struct{}{}
}
}
networks := Networks{}
for k := range requiredNetworks {
if value, ok := p.Networks[k]; ok {
networks[k] = value
}
}
newProject.Networks = networks
volumes := Volumes{}
for k := range requiredVolumes {
if value, ok := p.Volumes[k]; ok {
volumes[k] = value
}
}
newProject.Volumes = volumes
secrets := Secrets{}
for k := range requiredSecrets {
if value, ok := p.Secrets[k]; ok {
secrets[k] = value
}
}
newProject.Secrets = secrets
configs := Configs{}
for k := range requiredConfigs {
if value, ok := p.Configs[k]; ok {
configs[k] = value
}
}
newProject.Configs = configs
return newProject
}
type DependencyOption func(options *withServicesOptions)
func IncludeDependencies(options *withServicesOptions) {
options.dependencyPolicy = includeDependencies
}
func IncludeDependents(options *withServicesOptions) {
options.dependencyPolicy = includeDependents
}
func IgnoreDependencies(options *withServicesOptions) {
options.dependencyPolicy = ignoreDependencies
}
// WithSelectedServices restricts the project model to selected services and dependencies
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithSelectedServices(names []string, options ...DependencyOption) (*Project, error) {
newProject := p.deepCopy()
if len(names) == 0 {
// All services
return newProject, nil
}
set := utils.NewSet[string]()
err := p.ForEachService(names, func(name string, service *ServiceConfig) error {
set.Add(name)
return nil
}, options...)
if err != nil {
return nil, err
}
// Disable all services which are not explicit target or dependencies
enabled := Services{}
for name, s := range newProject.Services {
if _, ok := set[name]; ok {
// remove all dependencies but those implied by explicitly selected services
dependencies := s.DependsOn
for d := range dependencies {
if _, ok := set[d]; !ok {
delete(dependencies, d)
}
}
s.DependsOn = dependencies
enabled[name] = s
} else {
newProject = newProject.WithServicesDisabled(name)
}
}
newProject.Services = enabled
return newProject, nil
}
// WithServicesDisabled removes from the project model the given services and their references in all dependencies
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithServicesDisabled(names ...string) *Project {
newProject := p.deepCopy()
if len(names) == 0 {
return newProject
}
if newProject.DisabledServices == nil {
newProject.DisabledServices = Services{}
}
for _, name := range names {
// We should remove all dependencies which reference the disabled service
for i, s := range newProject.Services {
if _, ok := s.DependsOn[name]; ok {
delete(s.DependsOn, name)
newProject.Services[i] = s
}
}
if service, ok := newProject.Services[name]; ok {
newProject.DisabledServices[name] = service
delete(newProject.Services, name)
}
}
return newProject
}
// WithImagesResolved updates services images to include digest computed by a resolver function
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godigest.Digest, error)) (*Project, error) {
newProject := p.deepCopy()
eg := errgroup.Group{}
for i, s := range newProject.Services {
idx := i
service := s
if service.Image == "" {
continue
}
eg.Go(func() error {
named, err := reference.ParseDockerRef(service.Image)
if err != nil {
return err
}
if _, ok := named.(reference.Canonical); !ok {
// image is named but not digested reference
digest, err := resolver(named)
if err != nil {
return err
}
named, err = reference.WithDigest(named, digest)
if err != nil {
return err
}
}
service.Image = named.String()
newProject.Services[idx] = service
return nil
})
}
return newProject, eg.Wait()
}
// MarshalYAML marshal Project into a yaml tree
func (p *Project) MarshalYAML() ([]byte, error) {
buf := bytes.NewBuffer([]byte{})
encoder := yaml.NewEncoder(buf)
encoder.SetIndent(2)
// encoder.CompactSeqIndent() FIXME https://github.com/go-yaml/yaml/pull/753
err := encoder.Encode(p)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// MarshalJSON makes Config implement json.Marshaler
func (p *Project) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{
"name": p.Name,
"services": p.Services,
}
if len(p.Networks) > 0 {
m["networks"] = p.Networks
}
if len(p.Volumes) > 0 {
m["volumes"] = p.Volumes
}
if len(p.Secrets) > 0 {
m["secrets"] = p.Secrets
}
if len(p.Configs) > 0 {
m["configs"] = p.Configs
}
for k, v := range p.Extensions {
m[k] = v
}
return json.Marshal(m)
}
// WithServicesEnvironmentResolved parses env_files set for services to resolve the actual environment map for services
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project, error) {
newProject := p.deepCopy()
for i, service := range newProject.Services {
service.Environment = service.Environment.Resolve(newProject.Environment.Resolve)
environment := MappingWithEquals{}
// resolve variables based on other files we already parsed, + project's environment
var resolve dotenv.LookupFn = func(s string) (string, bool) {
v, ok := environment[s]
if ok && v != nil {
return *v, ok
}
return newProject.Environment.Resolve(s)
}
for _, envFile := range service.EnvFiles {
if _, err := os.Stat(envFile.Path); os.IsNotExist(err) {
if envFile.Required {
return nil, fmt.Errorf("env file %s not found: %w", envFile.Path, err)
}
continue
}
b, err := os.ReadFile(envFile.Path)
if err != nil {
return nil, fmt.Errorf("failed to load %s: %w", envFile.Path, err)
}
fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", envFile.Path, err)
}
environment.OverrideBy(Mapping(fileVars).ToMappingWithEquals())
}
service.Environment = environment.OverrideBy(service.Environment)
if discardEnvFiles {
service.EnvFiles = nil
}
newProject.Services[i] = service
}
return newProject, nil
}
func (p *Project) deepCopy() *Project {
instance, err := copystructure.Copy(p)
if err != nil {
panic(err)
}
return instance.(*Project)
}

View File

@ -0,0 +1,35 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
// Services is a map of ServiceConfig
type Services map[string]ServiceConfig
// GetProfiles retrieve the profiles implicitly enabled by explicitly targeting selected services
func (s Services) GetProfiles() []string {
set := map[string]struct{}{}
for _, service := range s {
for _, p := range service.Profiles {
set[p] = struct{}{}
}
}
var profiles []string
for k := range set {
profiles = append(profiles, k)
}
return profiles
}

View File

@ -0,0 +1,73 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"fmt"
)
type SSHKey struct {
ID string `yaml:"id,omitempty" json:"id,omitempty"`
Path string `path:"path,omitempty" json:"path,omitempty"`
}
// SSHConfig is a mapping type for SSH build config
type SSHConfig []SSHKey
func (s SSHConfig) Get(id string) (string, error) {
for _, sshKey := range s {
if sshKey.ID == id {
return sshKey.Path, nil
}
}
return "", fmt.Errorf("ID %s not found in SSH keys", id)
}
// MarshalYAML makes SSHKey implement yaml.Marshaller
func (s SSHKey) MarshalYAML() (interface{}, error) {
if s.Path == "" {
return s.ID, nil
}
return fmt.Sprintf("%s: %s", s.ID, s.Path), nil
}
// MarshalJSON makes SSHKey implement json.Marshaller
func (s SSHKey) MarshalJSON() ([]byte, error) {
if s.Path == "" {
return []byte(fmt.Sprintf(`%q`, s.ID)), nil
}
return []byte(fmt.Sprintf(`%q: %s`, s.ID, s.Path)), nil
}
func (s *SSHConfig) DecodeMapstructure(value interface{}) error {
v, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("invalid ssh config type %T", value)
}
result := make(SSHConfig, len(v))
i := 0
for id, path := range v {
key := SSHKey{ID: id}
if path != nil {
key.Path = fmt.Sprint(path)
}
result[i] = key
i++
}
*s = result
return nil
}

View File

@ -0,0 +1,57 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import "fmt"
// StringList is a type for fields that can be a string or list of strings
type StringList []string
func (l *StringList) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case string:
*l = []string{v}
case []interface{}:
list := make([]string, len(v))
for i, e := range v {
list[i] = e.(string)
}
*l = list
default:
return fmt.Errorf("invalid type %T for string list", value)
}
return nil
}
// StringOrNumberList is a type for fields that can be a list of strings or numbers
type StringOrNumberList []string
func (l *StringOrNumberList) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case string:
*l = []string{v}
case []interface{}:
list := make([]string, len(v))
for i, e := range v {
list[i] = fmt.Sprint(e)
}
*l = list
default:
return fmt.Errorf("invalid type %T for string list", value)
}
return nil
}

View File

@ -0,0 +1,756 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/docker/go-connections/nat"
"github.com/mitchellh/copystructure"
)
// ServiceConfig is the configuration of one service
type ServiceConfig struct {
Name string `yaml:"name,omitempty" json:"-"`
Profiles []string `yaml:"profiles,omitempty" json:"profiles,omitempty"`
Annotations Mapping `yaml:"annotations,omitempty" json:"annotations,omitempty"`
Attach *bool `yaml:"attach,omitempty" json:"attach,omitempty"`
Build *BuildConfig `yaml:"build,omitempty" json:"build,omitempty"`
Develop *DevelopConfig `yaml:"develop,omitempty" json:"develop,omitempty"`
BlkioConfig *BlkioConfig `yaml:"blkio_config,omitempty" json:"blkio_config,omitempty"`
CapAdd []string `yaml:"cap_add,omitempty" json:"cap_add,omitempty"`
CapDrop []string `yaml:"cap_drop,omitempty" json:"cap_drop,omitempty"`
CgroupParent string `yaml:"cgroup_parent,omitempty" json:"cgroup_parent,omitempty"`
Cgroup string `yaml:"cgroup,omitempty" json:"cgroup,omitempty"`
CPUCount int64 `yaml:"cpu_count,omitempty" json:"cpu_count,omitempty"`
CPUPercent float32 `yaml:"cpu_percent,omitempty" json:"cpu_percent,omitempty"`
CPUPeriod int64 `yaml:"cpu_period,omitempty" json:"cpu_period,omitempty"`
CPUQuota int64 `yaml:"cpu_quota,omitempty" json:"cpu_quota,omitempty"`
CPURTPeriod int64 `yaml:"cpu_rt_period,omitempty" json:"cpu_rt_period,omitempty"`
CPURTRuntime int64 `yaml:"cpu_rt_runtime,omitempty" json:"cpu_rt_runtime,omitempty"`
CPUS float32 `yaml:"cpus,omitempty" json:"cpus,omitempty"`
CPUSet string `yaml:"cpuset,omitempty" json:"cpuset,omitempty"`
CPUShares int64 `yaml:"cpu_shares,omitempty" json:"cpu_shares,omitempty"`
// Command for the service containers.
// If set, overrides COMMAND from the image.
//
// Set to `[]` or an empty string to clear the command from the image.
Command ShellCommand `yaml:"command,omitempty" json:"command"` // NOTE: we can NOT omitempty for JSON! see ShellCommand type for details.
Configs []ServiceConfigObjConfig `yaml:"configs,omitempty" json:"configs,omitempty"`
ContainerName string `yaml:"container_name,omitempty" json:"container_name,omitempty"`
CredentialSpec *CredentialSpecConfig `yaml:"credential_spec,omitempty" json:"credential_spec,omitempty"`
DependsOn DependsOnConfig `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
Deploy *DeployConfig `yaml:"deploy,omitempty" json:"deploy,omitempty"`
DeviceCgroupRules []string `yaml:"device_cgroup_rules,omitempty" json:"device_cgroup_rules,omitempty"`
Devices []string `yaml:"devices,omitempty" json:"devices,omitempty"`
DNS StringList `yaml:"dns,omitempty" json:"dns,omitempty"`
DNSOpts []string `yaml:"dns_opt,omitempty" json:"dns_opt,omitempty"`
DNSSearch StringList `yaml:"dns_search,omitempty" json:"dns_search,omitempty"`
Dockerfile string `yaml:"dockerfile,omitempty" json:"dockerfile,omitempty"`
DomainName string `yaml:"domainname,omitempty" json:"domainname,omitempty"`
// Entrypoint for the service containers.
// If set, overrides ENTRYPOINT from the image.
//
// Set to `[]` or an empty string to clear the entrypoint from the image.
Entrypoint ShellCommand `yaml:"entrypoint,omitempty" json:"entrypoint"` // NOTE: we can NOT omitempty for JSON! see ShellCommand type for details.
Environment MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"`
EnvFiles []EnvFile `yaml:"env_file,omitempty" json:"env_file,omitempty"`
Expose StringOrNumberList `yaml:"expose,omitempty" json:"expose,omitempty"`
Extends *ExtendsConfig `yaml:"extends,omitempty" json:"extends,omitempty"`
ExternalLinks []string `yaml:"external_links,omitempty" json:"external_links,omitempty"`
ExtraHosts HostsList `yaml:"extra_hosts,omitempty" json:"extra_hosts,omitempty"`
GroupAdd []string `yaml:"group_add,omitempty" json:"group_add,omitempty"`
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
Image string `yaml:"image,omitempty" json:"image,omitempty"`
Init *bool `yaml:"init,omitempty" json:"init,omitempty"`
Ipc string `yaml:"ipc,omitempty" json:"ipc,omitempty"`
Isolation string `yaml:"isolation,omitempty" json:"isolation,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
CustomLabels Labels `yaml:"-" json:"-"`
Links []string `yaml:"links,omitempty" json:"links,omitempty"`
Logging *LoggingConfig `yaml:"logging,omitempty" json:"logging,omitempty"`
LogDriver string `yaml:"log_driver,omitempty" json:"log_driver,omitempty"`
LogOpt map[string]string `yaml:"log_opt,omitempty" json:"log_opt,omitempty"`
MemLimit UnitBytes `yaml:"mem_limit,omitempty" json:"mem_limit,omitempty"`
MemReservation UnitBytes `yaml:"mem_reservation,omitempty" json:"mem_reservation,omitempty"`
MemSwapLimit UnitBytes `yaml:"memswap_limit,omitempty" json:"memswap_limit,omitempty"`
MemSwappiness UnitBytes `yaml:"mem_swappiness,omitempty" json:"mem_swappiness,omitempty"`
MacAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"`
Net string `yaml:"net,omitempty" json:"net,omitempty"`
NetworkMode string `yaml:"network_mode,omitempty" json:"network_mode,omitempty"`
Networks map[string]*ServiceNetworkConfig `yaml:"networks,omitempty" json:"networks,omitempty"`
OomKillDisable bool `yaml:"oom_kill_disable,omitempty" json:"oom_kill_disable,omitempty"`
OomScoreAdj int64 `yaml:"oom_score_adj,omitempty" json:"oom_score_adj,omitempty"`
Pid string `yaml:"pid,omitempty" json:"pid,omitempty"`
PidsLimit int64 `yaml:"pids_limit,omitempty" json:"pids_limit,omitempty"`
Platform string `yaml:"platform,omitempty" json:"platform,omitempty"`
Ports []ServicePortConfig `yaml:"ports,omitempty" json:"ports,omitempty"`
Privileged bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
PullPolicy string `yaml:"pull_policy,omitempty" json:"pull_policy,omitempty"`
ReadOnly bool `yaml:"read_only,omitempty" json:"read_only,omitempty"`
Restart string `yaml:"restart,omitempty" json:"restart,omitempty"`
Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"`
Scale *int `yaml:"scale,omitempty" json:"scale,omitempty"`
Secrets []ServiceSecretConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"`
SecurityOpt []string `yaml:"security_opt,omitempty" json:"security_opt,omitempty"`
ShmSize UnitBytes `yaml:"shm_size,omitempty" json:"shm_size,omitempty"`
StdinOpen bool `yaml:"stdin_open,omitempty" json:"stdin_open,omitempty"`
StopGracePeriod *Duration `yaml:"stop_grace_period,omitempty" json:"stop_grace_period,omitempty"`
StopSignal string `yaml:"stop_signal,omitempty" json:"stop_signal,omitempty"`
StorageOpt map[string]string `yaml:"storage_opt,omitempty" json:"storage_opt,omitempty"`
Sysctls Mapping `yaml:"sysctls,omitempty" json:"sysctls,omitempty"`
Tmpfs StringList `yaml:"tmpfs,omitempty" json:"tmpfs,omitempty"`
Tty bool `yaml:"tty,omitempty" json:"tty,omitempty"`
Ulimits map[string]*UlimitsConfig `yaml:"ulimits,omitempty" json:"ulimits,omitempty"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
UserNSMode string `yaml:"userns_mode,omitempty" json:"userns_mode,omitempty"`
Uts string `yaml:"uts,omitempty" json:"uts,omitempty"`
VolumeDriver string `yaml:"volume_driver,omitempty" json:"volume_driver,omitempty"`
Volumes []ServiceVolumeConfig `yaml:"volumes,omitempty" json:"volumes,omitempty"`
VolumesFrom []string `yaml:"volumes_from,omitempty" json:"volumes_from,omitempty"`
WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// MarshalYAML makes ServiceConfig implement yaml.Marshaller
func (s ServiceConfig) MarshalYAML() (interface{}, error) {
type t ServiceConfig
value := t(s)
value.Name = "" // set during map to slice conversion, not part of the yaml representation
return value, nil
}
// NetworksByPriority return the service networks IDs sorted according to Priority
func (s *ServiceConfig) NetworksByPriority() []string {
type key struct {
name string
priority int
}
var keys []key
for k, v := range s.Networks {
priority := 0
if v != nil {
priority = v.Priority
}
keys = append(keys, key{
name: k,
priority: priority,
})
}
sort.Slice(keys, func(i, j int) bool {
return keys[i].priority > keys[j].priority
})
var sorted []string
for _, k := range keys {
sorted = append(sorted, k.name)
}
return sorted
}
func (s *ServiceConfig) GetScale() int {
if s.Scale != nil {
return *s.Scale
}
if s.Deploy != nil && s.Deploy.Replicas != nil {
// this should not be required as compose-go enforce consistency between scale anr replicas
return *s.Deploy.Replicas
}
return 1
}
func (s *ServiceConfig) SetScale(scale int) {
s.Scale = &scale
if s.Deploy != nil {
s.Deploy.Replicas = &scale
}
}
func (s *ServiceConfig) deepCopy() *ServiceConfig {
instance, err := copystructure.Copy(s)
if err != nil {
panic(err)
}
return instance.(*ServiceConfig)
}
const (
// PullPolicyAlways always pull images
PullPolicyAlways = "always"
// PullPolicyNever never pull images
PullPolicyNever = "never"
// PullPolicyIfNotPresent pull missing images
PullPolicyIfNotPresent = "if_not_present"
// PullPolicyMissing pull missing images
PullPolicyMissing = "missing"
// PullPolicyBuild force building images
PullPolicyBuild = "build"
)
const (
// RestartPolicyAlways always restart the container if it stops
RestartPolicyAlways = "always"
// RestartPolicyOnFailure restart the container if it exits due to an error
RestartPolicyOnFailure = "on-failure"
// RestartPolicyNo do not automatically restart the container
RestartPolicyNo = "no"
// RestartPolicyUnlessStopped always restart the container unless the container is stopped (manually or otherwise)
RestartPolicyUnlessStopped = "unless-stopped"
)
const (
// ServicePrefix is the prefix for references pointing to a service
ServicePrefix = "service:"
// ContainerPrefix is the prefix for references pointing to a container
ContainerPrefix = "container:"
// NetworkModeServicePrefix is the prefix for network_mode pointing to a service
// Deprecated prefer ServicePrefix
NetworkModeServicePrefix = ServicePrefix
// NetworkModeContainerPrefix is the prefix for network_mode pointing to a container
// Deprecated prefer ContainerPrefix
NetworkModeContainerPrefix = ContainerPrefix
)
// GetDependencies retrieves all services this service depends on
func (s ServiceConfig) GetDependencies() []string {
var dependencies []string
for service := range s.DependsOn {
dependencies = append(dependencies, service)
}
return dependencies
}
// GetDependents retrieves all services which depend on this service
func (s ServiceConfig) GetDependents(p *Project) []string {
var dependent []string
for _, service := range p.Services {
for name := range service.DependsOn {
if name == s.Name {
dependent = append(dependent, service.Name)
}
}
}
return dependent
}
// BuildConfig is a type for build
type BuildConfig struct {
Context string `yaml:"context,omitempty" json:"context,omitempty"`
Dockerfile string `yaml:"dockerfile,omitempty" json:"dockerfile,omitempty"`
DockerfileInline string `yaml:"dockerfile_inline,omitempty" json:"dockerfile_inline,omitempty"`
Args MappingWithEquals `yaml:"args,omitempty" json:"args,omitempty"`
SSH SSHConfig `yaml:"ssh,omitempty" json:"ssh,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
CacheFrom StringList `yaml:"cache_from,omitempty" json:"cache_from,omitempty"`
CacheTo StringList `yaml:"cache_to,omitempty" json:"cache_to,omitempty"`
NoCache bool `yaml:"no_cache,omitempty" json:"no_cache,omitempty"`
AdditionalContexts Mapping `yaml:"additional_contexts,omitempty" json:"additional_contexts,omitempty"`
Pull bool `yaml:"pull,omitempty" json:"pull,omitempty"`
ExtraHosts HostsList `yaml:"extra_hosts,omitempty" json:"extra_hosts,omitempty"`
Isolation string `yaml:"isolation,omitempty" json:"isolation,omitempty"`
Network string `yaml:"network,omitempty" json:"network,omitempty"`
Target string `yaml:"target,omitempty" json:"target,omitempty"`
Secrets []ServiceSecretConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"`
ShmSize UnitBytes `yaml:"shm_size,omitempty" json:"shm_size,omitempty"`
Tags StringList `yaml:"tags,omitempty" json:"tags,omitempty"`
Ulimits map[string]*UlimitsConfig `yaml:"ulimits,omitempty" json:"ulimits,omitempty"`
Platforms StringList `yaml:"platforms,omitempty" json:"platforms,omitempty"`
Privileged bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// BlkioConfig define blkio config
type BlkioConfig struct {
Weight uint16 `yaml:"weight,omitempty" json:"weight,omitempty"`
WeightDevice []WeightDevice `yaml:"weight_device,omitempty" json:"weight_device,omitempty"`
DeviceReadBps []ThrottleDevice `yaml:"device_read_bps,omitempty" json:"device_read_bps,omitempty"`
DeviceReadIOps []ThrottleDevice `yaml:"device_read_iops,omitempty" json:"device_read_iops,omitempty"`
DeviceWriteBps []ThrottleDevice `yaml:"device_write_bps,omitempty" json:"device_write_bps,omitempty"`
DeviceWriteIOps []ThrottleDevice `yaml:"device_write_iops,omitempty" json:"device_write_iops,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// WeightDevice is a structure that holds device:weight pair
type WeightDevice struct {
Path string
Weight uint16
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ThrottleDevice is a structure that holds device:rate_per_second pair
type ThrottleDevice struct {
Path string
Rate UnitBytes
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// MappingWithColon is a mapping type that can be converted from a list of
// 'key: value' strings
type MappingWithColon map[string]string
// LoggingConfig the logging configuration for a service
type LoggingConfig struct {
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
Options Options `yaml:"options,omitempty" json:"options,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// DeployConfig the deployment configuration for a service
type DeployConfig struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
Replicas *int `yaml:"replicas,omitempty" json:"replicas,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
UpdateConfig *UpdateConfig `yaml:"update_config,omitempty" json:"update_config,omitempty"`
RollbackConfig *UpdateConfig `yaml:"rollback_config,omitempty" json:"rollback_config,omitempty"`
Resources Resources `yaml:"resources,omitempty" json:"resources,omitempty"`
RestartPolicy *RestartPolicy `yaml:"restart_policy,omitempty" json:"restart_policy,omitempty"`
Placement Placement `yaml:"placement,omitempty" json:"placement,omitempty"`
EndpointMode string `yaml:"endpoint_mode,omitempty" json:"endpoint_mode,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// UpdateConfig the service update configuration
type UpdateConfig struct {
Parallelism *uint64 `yaml:"parallelism,omitempty" json:"parallelism,omitempty"`
Delay Duration `yaml:"delay,omitempty" json:"delay,omitempty"`
FailureAction string `yaml:"failure_action,omitempty" json:"failure_action,omitempty"`
Monitor Duration `yaml:"monitor,omitempty" json:"monitor,omitempty"`
MaxFailureRatio float32 `yaml:"max_failure_ratio,omitempty" json:"max_failure_ratio,omitempty"`
Order string `yaml:"order,omitempty" json:"order,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// Resources the resource limits and reservations
type Resources struct {
Limits *Resource `yaml:"limits,omitempty" json:"limits,omitempty"`
Reservations *Resource `yaml:"reservations,omitempty" json:"reservations,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// Resource is a resource to be limited or reserved
type Resource struct {
// TODO: types to convert from units and ratios
NanoCPUs string `yaml:"cpus,omitempty" json:"cpus,omitempty"`
MemoryBytes UnitBytes `yaml:"memory,omitempty" json:"memory,omitempty"`
Pids int64 `yaml:"pids,omitempty" json:"pids,omitempty"`
Devices []DeviceRequest `yaml:"devices,omitempty" json:"devices,omitempty"`
GenericResources []GenericResource `yaml:"generic_resources,omitempty" json:"generic_resources,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// GenericResource represents a "user defined" resource which can
// only be an integer (e.g: SSD=3) for a service
type GenericResource struct {
DiscreteResourceSpec *DiscreteGenericResource `yaml:"discrete_resource_spec,omitempty" json:"discrete_resource_spec,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// DiscreteGenericResource represents a "user defined" resource which is defined
// as an integer
// "Kind" is used to describe the Kind of a resource (e.g: "GPU", "FPGA", "SSD", ...)
// Value is used to count the resource (SSD=5, HDD=3, ...)
type DiscreteGenericResource struct {
Kind string `json:"kind"`
Value int64 `json:"value"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// RestartPolicy the service restart policy
type RestartPolicy struct {
Condition string `yaml:"condition,omitempty" json:"condition,omitempty"`
Delay *Duration `yaml:"delay,omitempty" json:"delay,omitempty"`
MaxAttempts *uint64 `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
Window *Duration `yaml:"window,omitempty" json:"window,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// Placement constraints for the service
type Placement struct {
Constraints []string `yaml:"constraints,omitempty" json:"constraints,omitempty"`
Preferences []PlacementPreferences `yaml:"preferences,omitempty" json:"preferences,omitempty"`
MaxReplicas uint64 `yaml:"max_replicas_per_node,omitempty" json:"max_replicas_per_node,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// PlacementPreferences is the preferences for a service placement
type PlacementPreferences struct {
Spread string `yaml:"spread,omitempty" json:"spread,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ServiceNetworkConfig is the network configuration for a service
type ServiceNetworkConfig struct {
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Aliases []string `yaml:"aliases,omitempty" json:"aliases,omitempty"`
Ipv4Address string `yaml:"ipv4_address,omitempty" json:"ipv4_address,omitempty"`
Ipv6Address string `yaml:"ipv6_address,omitempty" json:"ipv6_address,omitempty"`
LinkLocalIPs []string `yaml:"link_local_ips,omitempty" json:"link_local_ips,omitempty"`
MacAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ServicePortConfig is the port configuration for a service
type ServicePortConfig struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
HostIP string `yaml:"host_ip,omitempty" json:"host_ip,omitempty"`
Target uint32 `yaml:"target,omitempty" json:"target,omitempty"`
Published string `yaml:"published,omitempty" json:"published,omitempty"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ParsePortConfig parse short syntax for service port configuration
func ParsePortConfig(value string) ([]ServicePortConfig, error) {
var portConfigs []ServicePortConfig
ports, portBindings, err := nat.ParsePortSpecs([]string{value})
if err != nil {
return nil, err
}
// We need to sort the key of the ports to make sure it is consistent
keys := []string{}
for port := range ports {
keys = append(keys, string(port))
}
sort.Strings(keys)
for _, key := range keys {
port := nat.Port(key)
converted, err := convertPortToPortConfig(port, portBindings)
if err != nil {
return nil, err
}
portConfigs = append(portConfigs, converted...)
}
return portConfigs, nil
}
func convertPortToPortConfig(port nat.Port, portBindings map[nat.Port][]nat.PortBinding) ([]ServicePortConfig, error) {
var portConfigs []ServicePortConfig
for _, binding := range portBindings[port] {
portConfigs = append(portConfigs, ServicePortConfig{
HostIP: binding.HostIP,
Protocol: strings.ToLower(port.Proto()),
Target: uint32(port.Int()),
Published: binding.HostPort,
Mode: "ingress",
})
}
return portConfigs, nil
}
// ServiceVolumeConfig are references to a volume used by a service
type ServiceVolumeConfig struct {
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Source string `yaml:"source,omitempty" json:"source,omitempty"`
Target string `yaml:"target,omitempty" json:"target,omitempty"`
ReadOnly bool `yaml:"read_only,omitempty" json:"read_only,omitempty"`
Consistency string `yaml:"consistency,omitempty" json:"consistency,omitempty"`
Bind *ServiceVolumeBind `yaml:"bind,omitempty" json:"bind,omitempty"`
Volume *ServiceVolumeVolume `yaml:"volume,omitempty" json:"volume,omitempty"`
Tmpfs *ServiceVolumeTmpfs `yaml:"tmpfs,omitempty" json:"tmpfs,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// String render ServiceVolumeConfig as a volume string, one can parse back using loader.ParseVolume
func (s ServiceVolumeConfig) String() string {
access := "rw"
if s.ReadOnly {
access = "ro"
}
options := []string{access}
if s.Bind != nil && s.Bind.SELinux != "" {
options = append(options, s.Bind.SELinux)
}
if s.Bind != nil && s.Bind.Propagation != "" {
options = append(options, s.Bind.Propagation)
}
if s.Volume != nil && s.Volume.NoCopy {
options = append(options, "nocopy")
}
return fmt.Sprintf("%s:%s:%s", s.Source, s.Target, strings.Join(options, ","))
}
const (
// VolumeTypeBind is the type for mounting host dir
VolumeTypeBind = "bind"
// VolumeTypeVolume is the type for remote storage volumes
VolumeTypeVolume = "volume"
// VolumeTypeTmpfs is the type for mounting tmpfs
VolumeTypeTmpfs = "tmpfs"
// VolumeTypeNamedPipe is the type for mounting Windows named pipes
VolumeTypeNamedPipe = "npipe"
// VolumeTypeCluster is the type for mounting container storage interface (CSI) volumes
VolumeTypeCluster = "cluster"
// SElinuxShared share the volume content
SElinuxShared = "z"
// SElinuxUnshared label content as private unshared
SElinuxUnshared = "Z"
)
// ServiceVolumeBind are options for a service volume of type bind
type ServiceVolumeBind struct {
SELinux string `yaml:"selinux,omitempty" json:"selinux,omitempty"`
Propagation string `yaml:"propagation,omitempty" json:"propagation,omitempty"`
CreateHostPath bool `yaml:"create_host_path,omitempty" json:"create_host_path,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// SELinux represents the SELinux re-labeling options.
const (
// SELinuxShared option indicates that the bind mount content is shared among multiple containers
SELinuxShared string = "z"
// SELinuxPrivate option indicates that the bind mount content is private and unshared
SELinuxPrivate string = "Z"
)
// Propagation represents the propagation of a mount.
const (
// PropagationRPrivate RPRIVATE
PropagationRPrivate string = "rprivate"
// PropagationPrivate PRIVATE
PropagationPrivate string = "private"
// PropagationRShared RSHARED
PropagationRShared string = "rshared"
// PropagationShared SHARED
PropagationShared string = "shared"
// PropagationRSlave RSLAVE
PropagationRSlave string = "rslave"
// PropagationSlave SLAVE
PropagationSlave string = "slave"
)
// ServiceVolumeVolume are options for a service volume of type volume
type ServiceVolumeVolume struct {
NoCopy bool `yaml:"nocopy,omitempty" json:"nocopy,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ServiceVolumeTmpfs are options for a service volume of type tmpfs
type ServiceVolumeTmpfs struct {
Size UnitBytes `yaml:"size,omitempty" json:"size,omitempty"`
Mode uint32 `yaml:"mode,omitempty" json:"mode,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// FileReferenceConfig for a reference to a swarm file object
type FileReferenceConfig struct {
Source string `yaml:"source,omitempty" json:"source,omitempty"`
Target string `yaml:"target,omitempty" json:"target,omitempty"`
UID string `yaml:"uid,omitempty" json:"uid,omitempty"`
GID string `yaml:"gid,omitempty" json:"gid,omitempty"`
Mode *uint32 `yaml:"mode,omitempty" json:"mode,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// ServiceConfigObjConfig is the config obj configuration for a service
type ServiceConfigObjConfig FileReferenceConfig
// ServiceSecretConfig is the secret configuration for a service
type ServiceSecretConfig FileReferenceConfig
// UlimitsConfig the ulimit configuration
type UlimitsConfig struct {
Single int `yaml:"single,omitempty" json:"single,omitempty"`
Soft int `yaml:"soft,omitempty" json:"soft,omitempty"`
Hard int `yaml:"hard,omitempty" json:"hard,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
func (u *UlimitsConfig) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case *UlimitsConfig:
// this call to DecodeMapstructure is triggered after initial value conversion as we use a map[string]*UlimitsConfig
return nil
case int:
u.Single = v
u.Soft = 0
u.Hard = 0
case map[string]any:
u.Single = 0
soft, ok := v["soft"]
if ok {
u.Soft = soft.(int)
}
hard, ok := v["hard"]
if ok {
u.Hard = hard.(int)
}
default:
return fmt.Errorf("unexpected value type %T for ulimit", value)
}
return nil
}
// MarshalYAML makes UlimitsConfig implement yaml.Marshaller
func (u *UlimitsConfig) MarshalYAML() (interface{}, error) {
if u.Single != 0 {
return u.Single, nil
}
return struct {
Soft int
Hard int
}{
Soft: u.Soft,
Hard: u.Hard,
}, nil
}
// MarshalJSON makes UlimitsConfig implement json.Marshaller
func (u *UlimitsConfig) MarshalJSON() ([]byte, error) {
if u.Single != 0 {
return json.Marshal(u.Single)
}
// Pass as a value to avoid re-entering this method and use the default implementation
return json.Marshal(*u)
}
// NetworkConfig for a network
type NetworkConfig struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
DriverOpts Options `yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
Ipam IPAMConfig `yaml:"ipam,omitempty" json:"ipam,omitempty"`
External External `yaml:"external,omitempty" json:"external,omitempty"`
Internal bool `yaml:"internal,omitempty" json:"internal,omitempty"`
Attachable bool `yaml:"attachable,omitempty" json:"attachable,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
EnableIPv6 bool `yaml:"enable_ipv6,omitempty" json:"enable_ipv6,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// IPAMConfig for a network
type IPAMConfig struct {
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
Config []*IPAMPool `yaml:"config,omitempty" json:"config,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// IPAMPool for a network
type IPAMPool struct {
Subnet string `yaml:"subnet,omitempty" json:"subnet,omitempty"`
Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"`
IPRange string `yaml:"ip_range,omitempty" json:"ip_range,omitempty"`
AuxiliaryAddresses Mapping `yaml:"aux_addresses,omitempty" json:"aux_addresses,omitempty"`
Extensions map[string]interface{} `yaml:",inline" json:"-"`
}
// VolumeConfig for a volume
type VolumeConfig struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
DriverOpts Options `yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
External External `yaml:"external,omitempty" json:"external,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// External identifies a Volume or Network as a reference to a resource that is
// not managed, and should already exist.
type External bool
// CredentialSpecConfig for credential spec on Windows
type CredentialSpecConfig struct {
Config string `yaml:"config,omitempty" json:"config,omitempty"` // Config was added in API v1.40
File string `yaml:"file,omitempty" json:"file,omitempty"`
Registry string `yaml:"registry,omitempty" json:"registry,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
// FileObjectConfig is a config type for a file used by a service
type FileObjectConfig struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
File string `yaml:"file,omitempty" json:"file,omitempty"`
Environment string `yaml:"environment,omitempty" json:"environment,omitempty"`
Content string `yaml:"content,omitempty" json:"content,omitempty"`
External External `yaml:"external,omitempty" json:"external,omitempty"`
Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"`
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
DriverOpts map[string]string `yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
TemplateDriver string `yaml:"template_driver,omitempty" json:"template_driver,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
const (
// ServiceConditionCompletedSuccessfully is the type for waiting until a service has completed successfully (exit code 0).
ServiceConditionCompletedSuccessfully = "service_completed_successfully"
// ServiceConditionHealthy is the type for waiting until a service is healthy.
ServiceConditionHealthy = "service_healthy"
// ServiceConditionStarted is the type for waiting until a service has started (default).
ServiceConditionStarted = "service_started"
)
type DependsOnConfig map[string]ServiceDependency
type ServiceDependency struct {
Condition string `yaml:"condition,omitempty" json:"condition,omitempty"`
Restart bool `yaml:"restart,omitempty" json:"restart,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
Required bool `yaml:"required" json:"required"`
}
type ExtendsConfig struct {
File string `yaml:"file,omitempty" json:"file,omitempty"`
Service string `yaml:"service,omitempty" json:"service,omitempty"`
}
// SecretConfig for a secret
type SecretConfig FileObjectConfig
// ConfigObjConfig is the config for the swarm "Config" object
type ConfigObjConfig FileObjectConfig
type IncludeConfig struct {
Path StringList `yaml:"path,omitempty" json:"path,omitempty"`
ProjectDirectory string `yaml:"project_directory,omitempty" json:"project_directory,omitempty"`
EnvFile StringList `yaml:"env_file,omitempty" json:"env_file,omitempty"`
}

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils
import (
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
func MapKeys[T constraints.Ordered, U any](theMap map[T]U) []T {
result := maps.Keys(theMap)
slices.Sort(result)
return result
}
func MapsAppend[T comparable, U any](target map[T]U, source map[T]U) map[T]U {
if target == nil {
return source
}
if source == nil {
return target
}
for key, value := range source {
if _, ok := target[key]; !ok {
target[key] = value
}
}
return target
}
func ArrayContains[T comparable](source []T, toCheck []T) bool {
for _, value := range toCheck {
if !slices.Contains(source, value) {
return false
}
}
return true
}

View File

@ -0,0 +1,95 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils
type Set[T comparable] map[T]struct{}
func NewSet[T comparable](v ...T) Set[T] {
if len(v) == 0 {
return make(Set[T])
}
out := make(Set[T], len(v))
for i := range v {
out.Add(v[i])
}
return out
}
func (s Set[T]) Has(v T) bool {
_, ok := s[v]
return ok
}
func (s Set[T]) Add(v T) {
s[v] = struct{}{}
}
func (s Set[T]) AddAll(v ...T) {
for _, e := range v {
s[e] = struct{}{}
}
}
func (s Set[T]) Remove(v T) bool {
_, ok := s[v]
if ok {
delete(s, v)
}
return ok
}
func (s Set[T]) Clear() {
for v := range s {
delete(s, v)
}
}
func (s Set[T]) Elements() []T {
elements := make([]T, 0, len(s))
for v := range s {
elements = append(elements, v)
}
return elements
}
func (s Set[T]) RemoveAll(elements ...T) {
for _, e := range elements {
s.Remove(e)
}
}
func (s Set[T]) Diff(other Set[T]) Set[T] {
out := make(Set[T])
for k := range s {
if _, ok := other[k]; !ok {
out[k] = struct{}{}
}
}
return out
}
func (s Set[T]) Union(other Set[T]) Set[T] {
out := make(Set[T])
for k := range s {
out[k] = struct{}{}
}
for k := range other {
out[k] = struct{}{}
}
return out
}

View File

@ -0,0 +1,48 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils
import (
"fmt"
"strconv"
"strings"
)
// StringToBool converts a string to a boolean ignoring errors
func StringToBool(s string) bool {
b, _ := strconv.ParseBool(strings.ToLower(strings.TrimSpace(s)))
return b
}
// GetAsEqualsMap split key=value formatted strings into a key : value map
func GetAsEqualsMap(em []string) map[string]string {
m := make(map[string]string)
for _, v := range em {
kv := strings.SplitN(v, "=", 2)
m[kv[0]] = kv[1]
}
return m
}
// GetAsEqualsMap format a key : value map into key=value strings
func GetAsStringList(em map[string]string) []string {
m := make([]string, 0, len(em))
for k, v := range em {
m = append(m, fmt.Sprintf("%s=%s", k, v))
}
return m
}

View File

@ -0,0 +1,49 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validation
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/tree"
)
func checkExternal(v map[string]any, p tree.Path) error {
b, ok := v["external"]
if !ok {
return nil
}
if !b.(bool) {
return nil
}
for k := range v {
switch k {
case "name", "external", consts.Extensions:
continue
default:
if strings.HasPrefix(k, "x-") {
// custom extension, ignored
continue
}
return fmt.Errorf("%s: conflicting parameters \"external\" and %q specified", p, k)
}
}
return nil
}

View File

@ -0,0 +1,96 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validation
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/tree"
)
type checkerFunc func(value any, p tree.Path) error
var checks = map[tree.Path]checkerFunc{
"volumes.*": checkVolume,
"configs.*": checkFileObject("file", "environment", "content"),
"secrets.*": checkFileObject("file", "environment"),
"services.*.develop.watch.*.path": checkPath,
}
func Validate(dict map[string]any) error {
return check(dict, tree.NewPath())
}
func check(value any, p tree.Path) error {
for pattern, fn := range checks {
if p.Matches(pattern) {
return fn(value, p)
}
}
switch v := value.(type) {
case map[string]any:
for k, v := range v {
err := check(v, p.Next(k))
if err != nil {
return err
}
}
case []any:
for _, e := range v {
err := check(e, p.Next("[]"))
if err != nil {
return err
}
}
}
return nil
}
func checkFileObject(keys ...string) checkerFunc {
return func(value any, p tree.Path) error {
v := value.(map[string]any)
count := 0
for _, s := range keys {
if _, ok := v[s]; ok {
count++
}
}
if count > 1 {
return fmt.Errorf("%s: %s attributes are mutually exclusive", p, strings.Join(keys, "|"))
}
if count == 0 {
if _, ok := v["driver"]; ok {
// User specified a custom driver, which might have it's own way to set content
return nil
}
if _, ok := v["external"]; !ok {
return fmt.Errorf("%s: one of %s must be set", p, strings.Join(keys, "|"))
}
}
return nil
}
}
func checkPath(value any, p tree.Path) error {
v := value.(string)
if v == "" {
return fmt.Errorf("%s: value can't be blank", p)
}
return nil
}

View File

@ -0,0 +1,39 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validation
import (
"fmt"
"github.com/compose-spec/compose-go/v2/tree"
)
func checkVolume(value any, p tree.Path) error {
if value == nil {
return nil
}
v, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("expected volume, got %s", value)
}
err := checkExternal(v, p)
if err != nil {
return err
}
return nil
}