Skip to content

Commit 7ea05cf

Browse files
authored
Merge pull request #198 from articulate/feature/env-vars
feat: validate environment variables
2 parents 1500584 + 2b78117 commit 7ea05cf

File tree

6 files changed

+274
-9
lines changed

6 files changed

+274
-9
lines changed

README.md

+30-1
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,40 @@ You can authenticate with Vault in one of the following ways:
6161

6262
</details>
6363

64+
### Environment Variables
65+
66+
If you want to ensure some environment variables exist before running your command,
67+
you can include a JSON file called `service.json` in the working directory. The
68+
entrypoint will parse this file and check that the configured environment variables
69+
exist and are not empty.
70+
71+
```json
72+
{
73+
"dependencies": {
74+
"env_vars": {
75+
"required": [
76+
"FOO",
77+
"BAR"
78+
],
79+
"optional": [
80+
"BAZ"
81+
]
82+
}
83+
}
84+
}
85+
```
86+
87+
If any optional environment variables are missing, it will log that, but continue
88+
to run.
89+
90+
If any required environment variables are missing, it will log that and then exit
91+
with an exit code of 4.
92+
6493
## Development
6594

6695
You'll need to install the following:
6796

68-
* Go 1.20
97+
* Go
6998
* [golangci-lint](https://golangci-lint.run/) (`brew install golangci-lint`)
7099
* [pre-commit](https://pre-commit.com/) (`brew install pre-commit`)
71100
* [GoReleaser](https://goreleaser.com/) (_optional_)

config.go

+19-6
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@ import (
44
"fmt"
55
"log/slog"
66
"os"
7+
"strconv"
78
)
89

910
type Config struct {
10-
Service string
11-
Environment string
12-
Region string
11+
Service string
12+
Environment string
13+
Region string
14+
ServiceDefinition string
15+
SkipValidation bool
1316
}
1417

1518
// NewFromEnv creates a new Config from environment variables and defaults
1619
func NewFromEnv() *Config {
1720
cfg := &Config{
18-
Service: os.Getenv("SERVICE_NAME"),
19-
Environment: os.Getenv("SERVICE_ENV"),
20-
Region: os.Getenv("AWS_REGION"),
21+
Service: os.Getenv("SERVICE_NAME"),
22+
Environment: os.Getenv("SERVICE_ENV"),
23+
Region: os.Getenv("AWS_REGION"),
24+
ServiceDefinition: os.Getenv("SERVICE_DEFINITION"),
25+
SkipValidation: false,
26+
}
27+
28+
if s, err := strconv.ParseBool(os.Getenv("BOOTSTRAP_SKIP_VALIDATION")); err == nil {
29+
cfg.SkipValidation = s
2130
}
2231

2332
if cfg.Service == "" {
@@ -33,6 +42,10 @@ func NewFromEnv() *Config {
3342
cfg.Region = "us-east-1"
3443
}
3544

45+
if cfg.ServiceDefinition == "" {
46+
cfg.ServiceDefinition = "service.json"
47+
}
48+
3649
return cfg
3750
}
3851

env.go

+6
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,9 @@ func (e *EnvMap) Environ() []string {
6060
return fmt.Sprintf("%s=%s", k, v)
6161
})
6262
}
63+
64+
// Has returns true if the given key is set and not empty
65+
func (e *EnvMap) Has(key string) bool {
66+
v, ok := e.env[key]
67+
return ok && v != ""
68+
}

main.go

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func main() {
1919

2020
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
2121
slog.SetDefault(logger)
22-
if v, ok := os.LookupEnv("DEBUG_BOOTSTRAP"); ok && v != "false" {
22+
if v, err := strconv.ParseBool(os.Getenv("DEBUG_BOOTSTRAP")); err == nil && v {
2323
logLevel.Set(slog.LevelDebug)
2424
}
2525

@@ -64,6 +64,11 @@ func main() {
6464
env.Add("SERVICE_ENV", cfg.Environment)
6565
env.Add("PROCESSOR_COUNT", strconv.Itoa(runtime.NumCPU()))
6666

67+
if err := validate(ctx, cfg, env, logger); err != nil {
68+
logger.ErrorContext(ctx, "Missing dependencies", "error", err)
69+
os.Exit(4)
70+
}
71+
6772
os.Exit(run(ctx, os.Args[1], os.Args[2:], env.Environ(), logger))
6873
}
6974

@@ -139,7 +144,7 @@ func run(ctx context.Context, name string, args, env []string, l *slog.Logger) i
139144
return exit.ExitCode()
140145
}
141146
l.ErrorContext(ctx, "Unknown error while running command", "error", err, "cmd", cmd.String())
142-
return 1
147+
return 3
143148
}
144149

145150
return 0

validate.go

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"log/slog"
9+
"os"
10+
11+
"github.com/samber/lo"
12+
)
13+
14+
type (
15+
serviceConfig struct {
16+
Dependencies struct {
17+
EnvVars struct {
18+
Required []dependency `json:"required"`
19+
Optional []dependency `json:"optional"`
20+
} `json:"env_vars"`
21+
} `json:"dependencies"`
22+
}
23+
dependency struct {
24+
dependencyInner
25+
Partial bool `json:"-"`
26+
}
27+
dependencyInner struct {
28+
Key string `json:"key"`
29+
Regions []string `json:"regions"`
30+
}
31+
)
32+
33+
var ErrMissingEnvVars = errors.New("missing required environment variables")
34+
35+
// Required returns true if the dependency is required for the given region
36+
func (d *dependency) Required(region string) bool {
37+
return d.Regions == nil || lo.Contains(d.Regions, region)
38+
}
39+
40+
// UnmarshalJSON handles the dependency being a string or an object
41+
func (d *dependency) UnmarshalJSON(data []byte) error {
42+
var str string
43+
if err := json.Unmarshal(data, &str); err == nil {
44+
d.Key = str
45+
d.Partial = true
46+
return nil
47+
}
48+
49+
var dep dependencyInner
50+
if err := json.Unmarshal(data, &dep); err != nil {
51+
return fmt.Errorf("could not decode dependency: %w", err)
52+
}
53+
54+
d.dependencyInner = dep
55+
return nil
56+
}
57+
58+
func validate(ctx context.Context, c *Config, e *EnvMap, l *slog.Logger) error {
59+
if c.SkipValidation || c.Environment == "test" {
60+
return nil
61+
}
62+
63+
f, err := os.ReadFile(c.ServiceDefinition)
64+
if os.IsNotExist(err) {
65+
return nil
66+
} else if err != nil {
67+
return fmt.Errorf("could not read service definition: %w", err)
68+
}
69+
70+
var cfg serviceConfig
71+
if err := json.Unmarshal(f, &cfg); err != nil {
72+
return fmt.Errorf("could not decode service definition: %w", err)
73+
}
74+
75+
req := missing(cfg.Dependencies.EnvVars.Required, c, e)
76+
opt := missing(cfg.Dependencies.EnvVars.Optional, c, e)
77+
78+
if len(opt) != 0 {
79+
l.WarnContext(ctx, "Missing optional environment variables", "env_vars", opt)
80+
}
81+
82+
if len(req) != 0 {
83+
l.ErrorContext(ctx, "Missing required environment variables", "env_vars", req)
84+
return ErrMissingEnvVars
85+
}
86+
87+
return nil
88+
}
89+
90+
func missing(deps []dependency, c *Config, e *EnvMap) []string {
91+
res := []string{}
92+
for _, d := range deps {
93+
if !d.Required(c.Region) {
94+
continue
95+
}
96+
97+
if v := os.Getenv(d.Key); v == "" && !e.Has(d.Key) {
98+
res = append(res, d.Key)
99+
}
100+
}
101+
return res
102+
}

validate_test.go

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestDependency_Required(t *testing.T) {
14+
d := dependency{
15+
dependencyInner: dependencyInner{
16+
Regions: []string{"us-east-1"},
17+
},
18+
}
19+
assert.True(t, d.Required("us-east-1"))
20+
assert.False(t, d.Required("us-west-2"))
21+
22+
d = dependency{}
23+
assert.True(t, d.Required("us-east-1"))
24+
assert.True(t, d.Required("us-west-2"))
25+
}
26+
27+
func TestValidate(t *testing.T) { //nolint:funlen
28+
s := filepath.Join(t.TempDir(), "service.json")
29+
require.NoError(t, os.WriteFile(s, []byte(`{
30+
"dependencies": {
31+
"env_vars": {
32+
"required": [
33+
"FOO",
34+
{
35+
"key": "BAR",
36+
"regions": ["us-east-1"]
37+
},
38+
{
39+
"key":"BAZ"
40+
}
41+
],
42+
"optional": [
43+
"QUX",
44+
{
45+
"key": "FOOBAR",
46+
"regions": ["eu-central-1"]
47+
},
48+
{
49+
"key":"FOOBAZ"
50+
}
51+
]
52+
}
53+
}
54+
}`), 0o600))
55+
56+
l, log := testLogger()
57+
c := &Config{ServiceDefinition: s, Region: "us-east-1"}
58+
59+
e := NewEnvMap()
60+
61+
err := validate(context.TODO(), c, e, l)
62+
require.ErrorIs(t, err, ErrMissingEnvVars)
63+
assert.Contains(
64+
t,
65+
log.String(),
66+
`"ERROR","msg":"Missing required environment variables","env_vars":["FOO","BAR","BAZ"]`,
67+
)
68+
assert.Contains(t, log.String(), `"WARN","msg":"Missing optional environment variables","env_vars":["QUX","FOOBAZ"]`)
69+
70+
// Skips validation
71+
c.SkipValidation = true
72+
require.NoError(t, validate(context.TODO(), c, e, l))
73+
c.SkipValidation = false
74+
75+
// Skips validation in test environment
76+
c.Environment = "test"
77+
require.NoError(t, validate(context.TODO(), c, e, l))
78+
c.Environment = "dev"
79+
80+
// Empty env vars should be considered missing
81+
e.Add("FOO", "")
82+
t.Setenv("BAR", "")
83+
84+
log.Reset()
85+
err = validate(context.TODO(), c, e, l)
86+
require.ErrorIs(t, err, ErrMissingEnvVars)
87+
assert.Contains(t, log.String(), `Missing required environment variables","env_vars":["FOO","BAR"`)
88+
89+
// Set all required env vars
90+
c.Region = "eu-central-1"
91+
e.Add("FOO", "foo")
92+
t.Setenv("BAZ", "baz")
93+
94+
log.Reset()
95+
err = validate(context.TODO(), c, e, l)
96+
require.NoError(t, err)
97+
assert.NotContains(t, log.String(), "Missing required environment variables")
98+
assert.Contains(t, log.String(), "Missing optional environment variables")
99+
100+
// Set all optional env vars
101+
e.Add("QUX", "qux")
102+
e.Add("FOOBAR", "foobar")
103+
t.Setenv("FOOBAZ", "foobaz")
104+
105+
log.Reset()
106+
err = validate(context.TODO(), c, e, l)
107+
require.NoError(t, err)
108+
assert.NotContains(t, log.String(), "Missing required environment variables")
109+
assert.NotContains(t, log.String(), "Missing optional environment variables")
110+
}

0 commit comments

Comments
 (0)