-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #198 from articulate/feature/env-vars
feat: validate environment variables
- Loading branch information
Showing
6 changed files
with
274 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"log/slog" | ||
"os" | ||
|
||
"github.com/samber/lo" | ||
) | ||
|
||
type ( | ||
serviceConfig struct { | ||
Dependencies struct { | ||
EnvVars struct { | ||
Required []dependency `json:"required"` | ||
Optional []dependency `json:"optional"` | ||
} `json:"env_vars"` | ||
} `json:"dependencies"` | ||
} | ||
dependency struct { | ||
dependencyInner | ||
Partial bool `json:"-"` | ||
} | ||
dependencyInner struct { | ||
Key string `json:"key"` | ||
Regions []string `json:"regions"` | ||
} | ||
) | ||
|
||
var ErrMissingEnvVars = errors.New("missing required environment variables") | ||
|
||
// Required returns true if the dependency is required for the given region | ||
func (d *dependency) Required(region string) bool { | ||
return d.Regions == nil || lo.Contains(d.Regions, region) | ||
} | ||
|
||
// UnmarshalJSON handles the dependency being a string or an object | ||
func (d *dependency) UnmarshalJSON(data []byte) error { | ||
var str string | ||
if err := json.Unmarshal(data, &str); err == nil { | ||
d.Key = str | ||
d.Partial = true | ||
return nil | ||
} | ||
|
||
var dep dependencyInner | ||
if err := json.Unmarshal(data, &dep); err != nil { | ||
return fmt.Errorf("could not decode dependency: %w", err) | ||
} | ||
|
||
d.dependencyInner = dep | ||
return nil | ||
} | ||
|
||
func validate(ctx context.Context, c *Config, e *EnvMap, l *slog.Logger) error { | ||
if c.SkipValidation || c.Environment == "test" { | ||
return nil | ||
} | ||
|
||
f, err := os.ReadFile(c.ServiceDefinition) | ||
if os.IsNotExist(err) { | ||
return nil | ||
} else if err != nil { | ||
return fmt.Errorf("could not read service definition: %w", err) | ||
} | ||
|
||
var cfg serviceConfig | ||
if err := json.Unmarshal(f, &cfg); err != nil { | ||
return fmt.Errorf("could not decode service definition: %w", err) | ||
} | ||
|
||
req := missing(cfg.Dependencies.EnvVars.Required, c, e) | ||
opt := missing(cfg.Dependencies.EnvVars.Optional, c, e) | ||
|
||
if len(opt) != 0 { | ||
l.WarnContext(ctx, "Missing optional environment variables", "env_vars", opt) | ||
} | ||
|
||
if len(req) != 0 { | ||
l.ErrorContext(ctx, "Missing required environment variables", "env_vars", req) | ||
return ErrMissingEnvVars | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func missing(deps []dependency, c *Config, e *EnvMap) []string { | ||
res := []string{} | ||
for _, d := range deps { | ||
if !d.Required(c.Region) { | ||
continue | ||
} | ||
|
||
if v := os.Getenv(d.Key); v == "" && !e.Has(d.Key) { | ||
res = append(res, d.Key) | ||
} | ||
} | ||
return res | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"os" | ||
"path/filepath" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestDependency_Required(t *testing.T) { | ||
d := dependency{ | ||
dependencyInner: dependencyInner{ | ||
Regions: []string{"us-east-1"}, | ||
}, | ||
} | ||
assert.True(t, d.Required("us-east-1")) | ||
assert.False(t, d.Required("us-west-2")) | ||
|
||
d = dependency{} | ||
assert.True(t, d.Required("us-east-1")) | ||
assert.True(t, d.Required("us-west-2")) | ||
} | ||
|
||
func TestValidate(t *testing.T) { //nolint:funlen | ||
s := filepath.Join(t.TempDir(), "service.json") | ||
require.NoError(t, os.WriteFile(s, []byte(`{ | ||
"dependencies": { | ||
"env_vars": { | ||
"required": [ | ||
"FOO", | ||
{ | ||
"key": "BAR", | ||
"regions": ["us-east-1"] | ||
}, | ||
{ | ||
"key":"BAZ" | ||
} | ||
], | ||
"optional": [ | ||
"QUX", | ||
{ | ||
"key": "FOOBAR", | ||
"regions": ["eu-central-1"] | ||
}, | ||
{ | ||
"key":"FOOBAZ" | ||
} | ||
] | ||
} | ||
} | ||
}`), 0o600)) | ||
|
||
l, log := testLogger() | ||
c := &Config{ServiceDefinition: s, Region: "us-east-1"} | ||
|
||
e := NewEnvMap() | ||
|
||
err := validate(context.TODO(), c, e, l) | ||
require.ErrorIs(t, err, ErrMissingEnvVars) | ||
assert.Contains( | ||
t, | ||
log.String(), | ||
`"ERROR","msg":"Missing required environment variables","env_vars":["FOO","BAR","BAZ"]`, | ||
) | ||
assert.Contains(t, log.String(), `"WARN","msg":"Missing optional environment variables","env_vars":["QUX","FOOBAZ"]`) | ||
|
||
// Skips validation | ||
c.SkipValidation = true | ||
require.NoError(t, validate(context.TODO(), c, e, l)) | ||
c.SkipValidation = false | ||
|
||
// Skips validation in test environment | ||
c.Environment = "test" | ||
require.NoError(t, validate(context.TODO(), c, e, l)) | ||
c.Environment = "dev" | ||
|
||
// Empty env vars should be considered missing | ||
e.Add("FOO", "") | ||
t.Setenv("BAR", "") | ||
|
||
log.Reset() | ||
err = validate(context.TODO(), c, e, l) | ||
require.ErrorIs(t, err, ErrMissingEnvVars) | ||
assert.Contains(t, log.String(), `Missing required environment variables","env_vars":["FOO","BAR"`) | ||
|
||
// Set all required env vars | ||
c.Region = "eu-central-1" | ||
e.Add("FOO", "foo") | ||
t.Setenv("BAZ", "baz") | ||
|
||
log.Reset() | ||
err = validate(context.TODO(), c, e, l) | ||
require.NoError(t, err) | ||
assert.NotContains(t, log.String(), "Missing required environment variables") | ||
assert.Contains(t, log.String(), "Missing optional environment variables") | ||
|
||
// Set all optional env vars | ||
e.Add("QUX", "qux") | ||
e.Add("FOOBAR", "foobar") | ||
t.Setenv("FOOBAZ", "foobaz") | ||
|
||
log.Reset() | ||
err = validate(context.TODO(), c, e, l) | ||
require.NoError(t, err) | ||
assert.NotContains(t, log.String(), "Missing required environment variables") | ||
assert.NotContains(t, log.String(), "Missing optional environment variables") | ||
} |