From 4ddcaf785a2560b3f574b24bc63e46881b7923c8 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 18 May 2022 09:47:29 +0200 Subject: [PATCH 1/5] add support for secret set by environment variable Signed-off-by: Nicolas De Loof --- loader/full-example.yml | 2 ++ loader/full-struct_test.go | 20 ++++++++--------- loader/loader.go | 2 +- loader/loader_test.go | 6 ++++- loader/validate.go | 10 +++++++++ loader/validate_test.go | 45 ++++++++++++++++++++++++++++++++++++++ schema/compose-spec.json | 1 + types/types.go | 1 + 8 files changed, 74 insertions(+), 13 deletions(-) diff --git a/loader/full-example.yml b/loader/full-example.yml index 8cd83310..1969527b 100644 --- a/loader/full-example.yml +++ b/loader/full-example.yml @@ -405,6 +405,7 @@ configs: external: true config4: name: foo + file: ~/config_data x-bar: baz x-foo: bar @@ -420,6 +421,7 @@ secrets: external: true secret4: name: bar + environment: BAR x-bar: baz x-foo: bar x-bar: baz diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go index 6438b363..d6616570 100644 --- a/loader/full-struct_test.go +++ b/loader/full-struct_test.go @@ -31,7 +31,7 @@ func fullExampleConfig(workingDir, homeDir string) *types.Config { Services: services(workingDir, homeDir), Networks: networks(), Volumes: volumes(), - Configs: configs(workingDir), + Configs: configs(workingDir, homeDir), Secrets: secrets(workingDir), Extensions: map[string]interface{}{ "x-foo": "bar", @@ -520,7 +520,7 @@ func volumes() map[string]types.VolumeConfig { } } -func configs(workingDir string) map[string]types.ConfigObjConfig { +func configs(workingDir string, homeDir string) map[string]types.ConfigObjConfig { return map[string]types.ConfigObjConfig{ "config1": { File: filepath.Join(workingDir, "config_data"), @@ -538,7 +538,7 @@ func configs(workingDir string) map[string]types.ConfigObjConfig { }, "config4": { Name: "foo", - File: workingDir, + File: filepath.Join(homeDir, "config_data"), Extensions: map[string]interface{}{ "x-bar": "baz", "x-foo": "bar", @@ -564,8 +564,8 @@ func secrets(workingDir string) map[string]types.SecretConfig { External: types.External{External: true}, }, "secret4": { - Name: "bar", - File: workingDir, + Name: "bar", + Environment: "BAR", Extensions: map[string]interface{}{ "x-bar": "baz", "x-foo": "bar", @@ -973,7 +973,7 @@ secrets: external: true secret4: name: bar - file: %s + environment: BAR x-bar: baz x-foo: bar configs: @@ -1003,9 +1003,8 @@ x-nested: filepath.Join(homeDir, "configs"), filepath.Join(workingDir, "opt"), filepath.Join(workingDir, "secret_data"), - filepath.Join(workingDir), filepath.Join(workingDir, "config_data"), - filepath.Join(workingDir)) + filepath.Join(homeDir, "config_data")) } func fullExampleJSON(workingDir, homeDir string) string { @@ -1096,7 +1095,7 @@ func fullExampleJSON(workingDir, homeDir string) string { }, "secret4": { "name": "bar", - "file": "%s", + "environment": "BAR", "external": false } }, @@ -1613,10 +1612,9 @@ func fullExampleJSON(workingDir, homeDir string) string { } }`, toPath(workingDir, "config_data"), - toPath(workingDir), + toPath(homeDir, "config_data"), toPath(workingDir, "secret_data"), toPath(workingDir), - toPath(workingDir), toPath(workingDir, "static"), toPath(homeDir, "configs"), toPath(workingDir, "opt")) diff --git a/loader/loader.go b/loader/loader.go index b008099a..01c0c5d5 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -804,7 +804,7 @@ func loadFileObjectConfig(name string, objType string, obj types.FileObjectConfi return obj, errors.Errorf("%[1]s %[2]s: %[1]s.driver and %[1]s.file conflict; only use %[1]s.driver", objType, name) } default: - if resolvePaths { + if obj.File != "" && resolvePaths { obj.File = absPath(details.WorkingDir, obj.File) } } diff --git a/loader/loader_test.go b/loader/loader_test.go index c3baec54..8a9cc1da 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -917,7 +917,11 @@ func TestFullExample(t *testing.T) { homeDir, err := os.UserHomeDir() assert.NilError(t, err) - env := map[string]string{"HOME": homeDir, "QUX": "qux_from_environment"} + env := map[string]string{ + "HOME": homeDir, + "BAR": "this is a secret", + "QUX": "qux_from_environment", + } config, err := loadYAMLWithEnv(string(b), env) assert.NilError(t, err) diff --git a/loader/validate.go b/loader/validate.go index 727e0b26..d8ab6197 100644 --- a/loader/validate.go +++ b/loader/validate.go @@ -68,5 +68,15 @@ func checkConsistency(project *types.Project) error { } } } + + for name, secret := range project.Secrets { + if secret.External.External { + continue + } + if secret.File == "" && secret.Environment == "" { + return errors.Wrap(errdefs.ErrInvalid, fmt.Sprintf("secret %q must declare either `file` or `environment`", name)) + } + } + return nil } diff --git a/loader/validate_test.go b/loader/validate_test.go index 15a6f340..6d423d21 100644 --- a/loader/validate_test.go +++ b/loader/validate_test.go @@ -139,3 +139,48 @@ func TestValidateNetworkMode(t *testing.T) { assert.NilError(t, err) }) } + +func TestValidateSecret(t *testing.T) { + t.Run("secret set by file", func(t *testing.T) { + project := &types.Project{ + Secrets: types.Secrets{ + "foo": types.SecretConfig{ + File: ".secret", + }, + }, + } + err := checkConsistency(project) + assert.NilError(t, err) + }) + t.Run("secret set by environment", func(t *testing.T) { + project := &types.Project{ + Secrets: types.Secrets{ + "foo": types.SecretConfig{ + Environment: "TOKEN", + }, + }, + } + err := checkConsistency(project) + assert.NilError(t, err) + }) + t.Run("external secret", func(t *testing.T) { + project := &types.Project{ + Secrets: types.Secrets{ + "foo": types.SecretConfig{ + External: types.External{External: true}, + }, + }, + } + err := checkConsistency(project) + assert.NilError(t, err) + }) + t.Run("uset secret", func(t *testing.T) { + project := &types.Project{ + Secrets: types.Secrets{ + "foo": types.SecretConfig{}, + }, + } + err := checkConsistency(project) + assert.Error(t, err, "secret \"foo\" must declare either `file` or `environment`: invalid compose project") + }) +} diff --git a/schema/compose-spec.json b/schema/compose-spec.json index a41e056f..2500ce75 100644 --- a/schema/compose-spec.json +++ b/schema/compose-spec.json @@ -685,6 +685,7 @@ "type": "object", "properties": { "name": {"type": "string"}, + "environment": {"type": "string"}, "file": {"type": "string"}, "external": { "type": ["boolean", "object"], diff --git a/types/types.go b/types/types.go index 7051bf02..b54679e3 100644 --- a/types/types.go +++ b/types/types.go @@ -891,6 +891,7 @@ type CredentialSpecConfig struct { type FileObjectConfig struct { Name string `yaml:",omitempty" json:"name,omitempty"` File string `yaml:",omitempty" json:"file,omitempty"` + Environment string `yaml:",omitempty" json:"environment,omitempty"` External External `yaml:",omitempty" json:"external,omitempty"` Labels Labels `yaml:",omitempty" json:"labels,omitempty"` Driver string `yaml:",omitempty" json:"driver,omitempty"` From 9456f4b283d43328e7e336fbc59f0e8c107621fd Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 26 May 2022 13:33:50 +0200 Subject: [PATCH 2/5] always use values from lookup function if available Signed-off-by: Guillaume Lours --- cli/options.go | 8 ++------ dotenv/godotenv_test.go | 31 +++++++++++++++++++++++++++++++ dotenv/parser.go | 9 ++++++--- loader/full-struct_test.go | 6 +++--- loader/loader_test.go | 6 ++++-- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/cli/options.go b/cli/options.go index 94f6f982..7365bacb 100644 --- a/cli/options.go +++ b/cli/options.go @@ -226,12 +226,11 @@ func WithDotEnv(o *ProjectOptions) error { } defer file.Close() - notInEnvSet := make(map[string]interface{}) env, err := dotenv.ParseWithLookup(file, func(k string) (string, bool) { v, ok := o.Environment[k] if !ok { - notInEnvSet[k] = nil - return "", true + + return "", false } return v, true }) @@ -239,9 +238,6 @@ func WithDotEnv(o *ProjectOptions) error { return err } for k, v := range env { - if _, ok := notInEnvSet[k]; ok { - continue - } if _, set := o.Environment[k]; set { continue } diff --git a/dotenv/godotenv_test.go b/dotenv/godotenv_test.go index e678df4a..efd6a132 100644 --- a/dotenv/godotenv_test.go +++ b/dotenv/godotenv_test.go @@ -612,3 +612,34 @@ func TestExpendingEnvironmentWithLookup(t *testing.T) { t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, key, expectedValue, key, value) } } + +func TestSubstitutionsWithShellEnvPrecedence(t *testing.T) { + os.Clearenv() + const envKey = "OPTION_A" + const envVal = "5" + os.Setenv(envKey, envVal) + defer os.Unsetenv(envKey) + + envFileName := "fixtures/substitutions.env" + expectedValues := map[string]string{ + "OPTION_A": "5", + "OPTION_B": "5", + "OPTION_C": "5", + "OPTION_D": "55", + "OPTION_E": "", + } + + envMap, err := ReadWithLookup(os.LookupEnv, envFileName) + if err != nil { + t.Error("Error reading file") + } + if len(envMap) != len(expectedValues) { + t.Error("Didn't get the right size map back") + } + + for key, value := range expectedValues { + if envMap[key] != value { + t.Errorf("Read got one of the keys wrong, [%q]->%q", key, envMap[key]) + } + } +} diff --git a/dotenv/parser.go b/dotenv/parser.go index 85ed2c00..fab656bf 100644 --- a/dotenv/parser.go +++ b/dotenv/parser.go @@ -18,6 +18,9 @@ const ( func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error { cutset := src + if lookupFn == nil { + lookupFn = noLookupFn + } for { cutset = getStatementStart(cutset) if cutset == nil { @@ -34,9 +37,6 @@ func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error { } if inherited { - if lookupFn == nil { - lookupFn = noLookupFn - } value, ok := lookupFn(key) if ok { @@ -50,6 +50,9 @@ func parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error { if err != nil { return err } + if lookUpValue, ok := lookupFn(key); ok { + value = lookUpValue + } out[key] = value cutset = left diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go index d6616570..9bdb6370 100644 --- a/loader/full-struct_test.go +++ b/loader/full-struct_test.go @@ -165,7 +165,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig { Entrypoint: []string{"/code/entrypoint.sh", "-p", "3000"}, Environment: map[string]*string{ "FOO": strPtr("foo_from_env_file"), - "BAR": strPtr("bar_from_env_file_2"), + "BAR": strPtr("this is a secret"), "BAZ": strPtr("baz_from_service_def"), "QUX": strPtr("qux_from_environment"), }, @@ -689,7 +689,7 @@ services: - -p - "3000" environment: - BAR: bar_from_env_file_2 + BAR: this is a secret BAZ: baz_from_service_def FOO: foo_from_env_file QUX: qux_from_environment @@ -1259,7 +1259,7 @@ func fullExampleJSON(workingDir, homeDir string) string { "3000" ], "environment": { - "BAR": "bar_from_env_file_2", + "BAR": "this is a secret", "BAZ": "baz_from_service_def", "FOO": "foo_from_env_file", "QUX": "qux_from_environment" diff --git a/loader/loader_test.go b/loader/loader_test.go index 8a9cc1da..60569b2c 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -1833,8 +1833,10 @@ func TestLoadServiceWithEnvFile(t *testing.T) { "env_file": file.Name(), } s, err := LoadService("Test Name", m, ".", func(s string) (string, bool) { - assert.Equal(t, "TEST", s) - return "YES", true + if s == "TEST" { + return "YES", true + } + return "", false }, true, false) assert.NilError(t, err) assert.Equal(t, "YES", *s.Environment["HALLO"]) From 25006d5c162a74b08f69fa2cea56f70d3a61f04d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 May 2022 05:06:23 +0000 Subject: [PATCH 3/5] Bump github.com/imdario/mergo from 0.3.12 to 0.3.13 Bumps [github.com/imdario/mergo](https://github.com/imdario/mergo) from 0.3.12 to 0.3.13. - [Release notes](https://github.com/imdario/mergo/releases) - [Commits](https://github.com/imdario/mergo/compare/0.3.12...v0.3.13) --- updated-dependencies: - dependency-name: github.com/imdario/mergo dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 6ff98538..b4ab4627 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/docker/go-connections v0.4.0 github.com/docker/go-units v0.4.0 github.com/google/go-cmp v0.5.5 - github.com/imdario/mergo v0.3.12 + github.com/imdario/mergo v0.3.13 github.com/mattn/go-shellwords v1.0.12 github.com/mitchellh/mapstructure v1.5.0 github.com/opencontainers/go-digest v1.0.0 diff --git a/go.sum b/go.sum index 0a0c8450..2ff9b9dd 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -165,8 +165,9 @@ gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789 h1:NMiUjDZiD6qDVeBOzpImftxX gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.2.0 h1:I0DwBVMGAx26dttAj1BtJLAkVGncrkkUXfJLC4Flt/I= gotest.tools/v3 v3.2.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= From 2f042b8cfd38ea3e9bcefe797921ce9207524a04 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 30 May 2022 16:33:26 +0200 Subject: [PATCH 4/5] Make dotEnv resolution plublic Signed-off-by: Ulysses Souza --- cli/options.go | 77 +++++++++++++++++++++----------------------- cli/options_test.go | 5 +-- utils/stringutils.go | 20 ++++++++++++ 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/cli/options.go b/cli/options.go index 7365bacb..4bb3c2ae 100644 --- a/cli/options.go +++ b/cli/options.go @@ -145,7 +145,7 @@ func WithDefaultConfigPath(o *ProjectOptions) error { // 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 getAsEqualsMap(env) { + for k, v := range utils.GetAsEqualsMap(env) { o.Environment[k] = v } return nil @@ -169,7 +169,7 @@ func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn { // WithOsEnv imports environment variables from OS func WithOsEnv(o *ProjectOptions) error { - for k, v := range getAsEqualsMap(os.Environ()) { + for k, v := range utils.GetAsEqualsMap(os.Environ()) { if _, set := o.Environment[k]; set { continue } @@ -188,46 +188,59 @@ func WithEnvFile(file string) ProjectOptionsFn { // WithDotEnv imports environment variables from .env file func WithDotEnv(o *ProjectOptions) error { - dotEnvFile := o.EnvFile + wd, err := o.GetWorkingDir() + if err != nil { + return err + } + envMap, err := GetEnvFromFile(o.Environment, wd, o.EnvFile) + if err != nil { + return err + } + for k, v := range envMap { + o.Environment[k] = v + } + return nil +} + +func GetEnvFromFile(currentEnv map[string]string, workingDir string, filename string) (map[string]string, error) { + envMap := make(map[string]string) + + dotEnvFile := filename if dotEnvFile == "" { - wd, err := o.GetWorkingDir() - if err != nil { - return err - } - dotEnvFile = filepath.Join(wd, ".env") + dotEnvFile = filepath.Join(workingDir, ".env") } abs, err := filepath.Abs(dotEnvFile) if err != nil { - return err + return envMap, err } dotEnvFile = abs s, err := os.Stat(dotEnvFile) if os.IsNotExist(err) { - if o.EnvFile != "" { - return errors.Errorf("Couldn't find env file: %s", o.EnvFile) + if filename != "" { + return nil, errors.Errorf("Couldn't find env file: %s", filename) } - return nil + return envMap, nil } if err != nil { - return err + return envMap, err } if s.IsDir() { - if o.EnvFile == "" { - return nil + if filename == "" { + return envMap, nil } - return errors.Errorf("%s is a directory", dotEnvFile) + return envMap, errors.Errorf("%s is a directory", dotEnvFile) } file, err := os.Open(dotEnvFile) if err != nil { - return err + return envMap, err } defer file.Close() env, err := dotenv.ParseWithLookup(file, func(k string) (string, bool) { - v, ok := o.Environment[k] + v, ok := currentEnv[k] if !ok { return "", false @@ -235,15 +248,16 @@ func WithDotEnv(o *ProjectOptions) error { return v, true }) if err != nil { - return err + return envMap, err } for k, v := range env { - if _, set := o.Environment[k]; set { + if _, set := currentEnv[k]; set { continue } - o.Environment[k] = v + envMap[k] = v } - return nil + + return envMap, nil } // WithInterpolation set ProjectOptions to enable/skip interpolation @@ -412,22 +426,3 @@ func absolutePaths(p []string) ([]string, error) { } return paths, nil } - -// 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 -} diff --git a/cli/options_test.go b/cli/options_test.go index 8429c441..9d4c65d9 100644 --- a/cli/options_test.go +++ b/cli/options_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/compose-spec/compose-go/consts" + "github.com/compose-spec/compose-go/utils" "gotest.tools/v3/assert" ) @@ -231,8 +232,8 @@ func TestProjectNameFromWorkingDir(t *testing.T) { func TestEnvMap(t *testing.T) { m := map[string]string{} m["foo"] = "bar" - l := getAsStringList(m) + l := utils.GetAsStringList(m) assert.Equal(t, l[0], "foo=bar") - m = getAsEqualsMap(l) + m = utils.GetAsEqualsMap(l) assert.Equal(t, m["foo"], "bar") } diff --git a/utils/stringutils.go b/utils/stringutils.go index bd95e7bb..182ddf83 100644 --- a/utils/stringutils.go +++ b/utils/stringutils.go @@ -17,6 +17,7 @@ package utils import ( + "fmt" "strconv" "strings" ) @@ -36,3 +37,22 @@ 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 +} From 5e01bb7bf37365e142e44e6b2940d9332e63353a Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 31 May 2022 16:04:47 +0200 Subject: [PATCH 5/5] Adds tilt.dev as a user of `compose-go` Signed-off-by: Ulysses Souza --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e0ccbc8d..97de5a53 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,4 @@ Go reference library for parsing and loading Compose files as specified by the * [compose-ref](https://github.com/compose-spec/compose-ref) * [containerd/nerdctl](https://github.com/containerd/nerdctl) * [compose-cli](https://github.com/docker/compose-cli) +* [tilt.dev](https://github.com/tilt-dev/tilt)