diff --git a/README.md b/README.md index 02a1cac..7f45521 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ The following action deploys the app whenever a new commit is pushed to the main In this case, a secret of the repository named `SOME_SECRET_FROM_REPOSITORY` will also be passed into the app via its environment variables as `SOME_SECRET`. It is passed to the action's environment via the `${{ secrets.KEY }}` notation and then substituted into the spec itself via the environment variable reference in `value`. Make sure to define the respective env var's type as `SECRET` in the spec to ensure the value is stored in an encrypted way. +**Note:** `APP_DOMAIN`, `APP_URL` and `APP_NAME` are predefined [App-wide variables](https://docs.digitalocean.com/products/app-platform/how-to/use-environment-variables/#app-wide-variables). Avoid overriding them in the action's environment to avoid the env-var-expansion process of the Github Action to interfere with that of the platform itself. + ```yaml name: Update App diff --git a/deploy/main.go b/deploy/main.go index a7f99d8..ccc91dc 100644 --- a/deploy/main.go +++ b/deploy/main.go @@ -93,7 +93,7 @@ func (d *deployer) createSpec(ctx context.Context) (*godo.AppSpec, error) { if err != nil { return nil, fmt.Errorf("failed to get app spec content: %w", err) } - appSpecExpanded := os.ExpandEnv(string(appSpec)) + appSpecExpanded := utils.ExpandEnvRetainingBindables(string(appSpec)) if err := yaml.Unmarshal([]byte(appSpecExpanded), &spec); err != nil { return nil, fmt.Errorf("failed to parse app spec: %w", err) } diff --git a/deploy/main_test.go b/deploy/main_test.go index 46c61ea..50df721 100644 --- a/deploy/main_test.go +++ b/deploy/main_test.go @@ -20,6 +20,10 @@ import ( func TestCreateSpecFromFile(t *testing.T) { spec := &godo.AppSpec{ Name: "foo", + Envs: []*godo.AppVariableDefinition{{ + Key: "GLOBAL_ENV_VAR", + Value: "${APP_DOMAIN}", + }}, Services: []*godo.AppServiceSpec{{ Name: "web", Image: &godo.ImageSourceSpec{ @@ -28,6 +32,10 @@ func TestCreateSpecFromFile(t *testing.T) { Repository: "bar", Tag: "${ENV_VAR}", }, + Envs: []*godo.AppVariableDefinition{{ + Key: "SERVICE_ENV_VAR", + Value: "${web2.HOSTNAME}", + }}, }, { Name: "web2", Image: &godo.ImageSourceSpec{ @@ -61,6 +69,10 @@ func TestCreateSpecFromFile(t *testing.T) { expected := &godo.AppSpec{ Name: "foo", + Envs: []*godo.AppVariableDefinition{{ + Key: "GLOBAL_ENV_VAR", + Value: "${APP_DOMAIN}", // Bindable reference stayed intact. + }}, Services: []*godo.AppServiceSpec{{ Name: "web", Image: &godo.ImageSourceSpec{ @@ -69,6 +81,10 @@ func TestCreateSpecFromFile(t *testing.T) { Repository: "bar", Tag: "v1", // Tag was updated. }, + Envs: []*godo.AppVariableDefinition{{ + Key: "SERVICE_ENV_VAR", + Value: "${web2.HOSTNAME}", // Bindable reference stayed intact. + }}, }, { Name: "web2", Image: &godo.ImageSourceSpec{ diff --git a/utils/env.go b/utils/env.go new file mode 100644 index 0000000..f97a13d --- /dev/null +++ b/utils/env.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "os" + "strings" +) + +// appWideVariables are the environment variables that are shared across all components. +// See https://docs.digitalocean.com/products/app-platform/how-to/use-environment-variables/#app-wide-variables. +var appWideVariables = map[string]struct{}{ + "APP_DOMAIN": {}, + "APP_URL": {}, + "APP_NAME": {}, +} + +// ExpandEnvRetainingBindables expands the environment variables in s, but it +// keeps bindable variables intact. +// Since bindable variables look like env vars, notation-wise, we just don't +// expand them at all. +func ExpandEnvRetainingBindables(s string) string { + return os.Expand(s, func(name string) string { + value := os.Getenv(name) + if value == "" { + if _, ok := appWideVariables[name]; ok || looksLikeBindable(name) { + // If the environment variable is not set, keep the respective + // reference intact. + return fmt.Sprintf("${%s}", name) + } + } + return value + }) +} + +// looksLikeBindable returns true if the key looks like a bindable variable. +// Environment variables can't usually contain dots, so if they do, we're +// fairly confident that it's a bindable variable. +func looksLikeBindable(key string) bool { + return strings.Contains(key, ".") +} diff --git a/utils/env_test.go b/utils/env_test.go new file mode 100644 index 0000000..b1904d6 --- /dev/null +++ b/utils/env_test.go @@ -0,0 +1,41 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExpandEnvRetainingBindables(t *testing.T) { + t.Setenv("FOO", "bar") + t.Setenv("APP_URL", "baz") + + tests := []struct { + name string + in string + out string + }{{ + name: "simple", + in: "hello $FOO", + out: "hello bar", + }, { + name: "bindable", + in: "hello ${FOO.bar}", + out: "hello ${FOO.bar}", + }, { + name: "global bindable, unset", + in: "hello ${APP_DOMAIN}", + out: "hello ${APP_DOMAIN}", + }, { + name: "global bindable, overridden in env", + in: "hello ${APP_URL}", + out: "hello baz", + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ExpandEnvRetainingBindables(test.in) + require.Equal(t, test.out, got) + }) + } +}