Skip to content

Commit

Permalink
Merge pull request #198 from articulate/feature/env-vars
Browse files Browse the repository at this point in the history
feat: validate environment variables
  • Loading branch information
mloberg authored Mar 21, 2024
2 parents 1500584 + 2b78117 commit 7ea05cf
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 9 deletions.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,40 @@ You can authenticate with Vault in one of the following ways:

</details>

### Environment Variables

If you want to ensure some environment variables exist before running your command,
you can include a JSON file called `service.json` in the working directory. The
entrypoint will parse this file and check that the configured environment variables
exist and are not empty.

```json
{
"dependencies": {
"env_vars": {
"required": [
"FOO",
"BAR"
],
"optional": [
"BAZ"
]
}
}
}
```

If any optional environment variables are missing, it will log that, but continue
to run.

If any required environment variables are missing, it will log that and then exit
with an exit code of 4.

## Development

You'll need to install the following:

* Go 1.20
* Go
* [golangci-lint](https://golangci-lint.run/) (`brew install golangci-lint`)
* [pre-commit](https://pre-commit.com/) (`brew install pre-commit`)
* [GoReleaser](https://goreleaser.com/) (_optional_)
Expand Down
25 changes: 19 additions & 6 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@ import (
"fmt"
"log/slog"
"os"
"strconv"
)

type Config struct {
Service string
Environment string
Region string
Service string
Environment string
Region string
ServiceDefinition string
SkipValidation bool
}

// NewFromEnv creates a new Config from environment variables and defaults
func NewFromEnv() *Config {
cfg := &Config{
Service: os.Getenv("SERVICE_NAME"),
Environment: os.Getenv("SERVICE_ENV"),
Region: os.Getenv("AWS_REGION"),
Service: os.Getenv("SERVICE_NAME"),
Environment: os.Getenv("SERVICE_ENV"),
Region: os.Getenv("AWS_REGION"),
ServiceDefinition: os.Getenv("SERVICE_DEFINITION"),
SkipValidation: false,
}

if s, err := strconv.ParseBool(os.Getenv("BOOTSTRAP_SKIP_VALIDATION")); err == nil {
cfg.SkipValidation = s
}

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

if cfg.ServiceDefinition == "" {
cfg.ServiceDefinition = "service.json"
}

return cfg
}

Expand Down
6 changes: 6 additions & 0 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ func (e *EnvMap) Environ() []string {
return fmt.Sprintf("%s=%s", k, v)
})
}

// Has returns true if the given key is set and not empty
func (e *EnvMap) Has(key string) bool {
v, ok := e.env[key]
return ok && v != ""
}
9 changes: 7 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func main() {

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

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

if err := validate(ctx, cfg, env, logger); err != nil {
logger.ErrorContext(ctx, "Missing dependencies", "error", err)
os.Exit(4)
}

os.Exit(run(ctx, os.Args[1], os.Args[2:], env.Environ(), logger))
}

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

return 0
Expand Down
102 changes: 102 additions & 0 deletions validate.go
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
}
110 changes: 110 additions & 0 deletions validate_test.go
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")
}

0 comments on commit 7ea05cf

Please sign in to comment.