diff --git a/DOCKER.md b/DOCKER.md index 4f7bdb06f..18641716a 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -38,6 +38,7 @@ docker run avenga/couper run -watch -p 8081 |:-------------------------------------| :------ | :---------- | | COUPER_FILE | `couper.hcl` | Path to the configuration file. | | COUPER_FILE_DIRECTORY | `""` | Path to the configuration files directory. | +| COUPER_ENVIRONMENT | `""` | Name of environment in which Couper is currently running. | | COUPER_ACCEPT_FORWARDED_URL | `""` | Which `X-Forwarded-*` request headers should be accepted to change the [request variables](https://github.com/avenga/couper/blob/master/docs/REFERENCE.md#request) `url`, `origin`, `protocol`, `host`, `port`. Comma-separated list of values. Valid values: `proto`, `host`, `port`. | | COUPER_DEFAULT_PORT | `8080` | Sets the default port to the given value and does not override explicit `[host:port]` configurations from file. | | COUPER_HEALTH_PATH | `/healthz` | Path for health-check requests for all servers and ports. | diff --git a/command/run_test.go b/command/run_test.go index 4c3abfd0e..fc66d241a 100644 --- a/command/run_test.go +++ b/command/run_test.go @@ -105,7 +105,7 @@ func TestNewRun(t *testing.T) { return } - couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file)) + couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file), "") if err != nil { subT.Error(err) } @@ -192,7 +192,7 @@ func TestAcceptForwarded(t *testing.T) { return } - couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file)) + couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file), "") if err != nil { subT.Error(err) } diff --git a/command/verify.go b/command/verify.go index 584ccc798..bf8783fc1 100644 --- a/command/verify.go +++ b/command/verify.go @@ -17,8 +17,8 @@ func NewVerify() *Verify { return &Verify{} } -func (v Verify) Execute(args Args, _ *config.Couper, logger *logrus.Entry) error { - cf, err := configload.LoadFiles(args) +func (v Verify) Execute(args Args, conf *config.Couper, logger *logrus.Entry) error { + cf, err := configload.LoadFiles(args, conf.Environment) if diags, ok := err.(hcl.Diagnostics); ok { for _, diag := range diags { logger.WithError(diag).Error() diff --git a/config/configload/environment.go b/config/configload/environment.go new file mode 100644 index 000000000..f06d2a227 --- /dev/null +++ b/config/configload/environment.go @@ -0,0 +1,57 @@ +package configload + +import ( + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func preprocessEnvironmentBlocks(bodies []*hclsyntax.Body, env string) error { + for _, body := range bodies { + if err := preprocessBody(body, env); err != nil { + return err + } + } + + return nil +} + +func preprocessBody(parent *hclsyntax.Body, env string) error { + var blocks []*hclsyntax.Block + + for _, block := range parent.Blocks { + if block.Type != environment { + blocks = append(blocks, block) + + continue + } + + if len(block.Labels) == 0 { + defRange := block.DefRange() + + return newDiagErr(&defRange, "Missing label(s) for 'environment' block") + } + + for _, label := range block.Labels { + if err := validLabel(label, getRange(block.Body)); err != nil { + return err + } + + if label == env { + blocks = append(blocks, block.Body.Blocks...) + + for name, attr := range block.Body.Attributes { + parent.Attributes[name] = attr + } + } + } + } + + for _, block := range blocks { + if err := preprocessBody(block.Body, env); err != nil { + return err + } + } + + parent.Blocks = blocks + + return nil +} diff --git a/config/configload/load.go b/config/configload/load.go index 978bb118b..5dd5aadc8 100644 --- a/config/configload/load.go +++ b/config/configload/load.go @@ -30,6 +30,7 @@ const ( defaults = "defaults" definitions = "definitions" endpoint = "endpoint" + environment = "environment" errorHandler = "error_handler" files = "files" nameLabel = "name" @@ -125,7 +126,7 @@ func parseFiles(files configfile.Files) ([]*hclsyntax.Body, [][]byte, error) { return parsedBodies, srcBytes, nil } -func LoadFiles(filesList []string) (*config.Couper, error) { +func LoadFiles(filesList []string, env string) (*config.Couper, error) { configFiles, err := configfile.NewFiles(filesList) if err != nil { return nil, err @@ -140,6 +141,10 @@ func LoadFiles(filesList []string) (*config.Couper, error) { return nil, fmt.Errorf("missing configuration files") } + if err := preprocessEnvironmentBlocks(parsedBodies, env); err != nil { + return nil, err + } + defaultsBlock, err := mergeDefaults(parsedBodies) if err != nil { return nil, err @@ -190,8 +195,8 @@ func LoadFiles(filesList []string) (*config.Couper, error) { return conf, nil } -func LoadFile(file string) (*config.Couper, error) { - return LoadFiles([]string{file}) +func LoadFile(file, env string) (*config.Couper, error) { + return LoadFiles([]string{file}, env) } func LoadBytes(src []byte, filename string) (*config.Couper, error) { @@ -449,9 +454,8 @@ func absolutizePaths(fileBody *hclsyntax.Body) error { if strings.HasPrefix(filePath, "http://") || strings.HasPrefix(filePath, "https://") { return nil } - if strings.HasPrefix(filePath, "file:") { - filePath = filePath[5:] - } + + filePath = strings.TrimPrefix(filePath, "file:") if path.IsAbs(filePath) { return nil } diff --git a/config/couper.go b/config/couper.go index c1b76c198..6919f2ae0 100644 --- a/config/couper.go +++ b/config/couper.go @@ -2,6 +2,7 @@ package config import ( "context" + "github.com/avenga/couper/config/configload/file" ) @@ -11,6 +12,7 @@ const DefaultFilename = "couper.hcl" // Couper represents the config object. type Couper struct { Context context.Context + Environment string Files file.Files Definitions *Definitions `hcl:"definitions,block"` Servers Servers `hcl:"server,block"` diff --git a/docs/CLI.md b/docs/CLI.md index b074e683c..fa2d4644a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -19,7 +19,8 @@ Couper is build as binary called `couper` with the following commands: | Argument | Default | Environment | Description | |:---------------------|:-------------|:---------------------------|:-----------------------------------------------------------------------------------------------------------------------------| | `-f` | `couper.hcl` | `COUPER_FILE` | Path to a Couper configuration file. | -| `-d` | - | `COUPER_FILE_DIRECTORY` | Path to a directory containing Couper configuration files. | +| `-d` | `""` | `COUPER_FILE_DIRECTORY` | Path to a directory containing Couper configuration files. | +| `-e` | `""` | `COUPER_ENVIRONMENT` | Name of environment in which Couper is currently running. | | `-watch` | `false` | `COUPER_WATCH` | Watch for configuration file changes and reload on modifications. | | `-watch-retries` | `5` | `COUPER_WATCH_RETRIES` | Maximum retry count for configuration reloads which could not bind the configured port. | | `-watch-retry-delay` | `500ms` | `COUPER_WATCH_RETRY_DELAY` | Delay duration before next attempt if an error occurs. | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ab399acab..ad9d939c0 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -28,6 +28,7 @@ - [Defaults Block](#defaults-block) - [Health Block (Beta)](#health-block) - [Error Handler Block](#error-handler-block) + - [Environment Block](#environment-block) - [Access Control](#access-control) - [Health-Check](#health-check) - [Variables](#variables) @@ -624,6 +625,64 @@ Examples: - [Error Handling for Access Controls](https://github.com/avenga/couper-examples/blob/master/error-handling-ba/README.md). +### Environment Block + +The `environment` block lets you refine the Couper configuration based on the set +[environment](./CLI.md#global-options). + +| Block name | Context | Label | Nested block(s) | +| :------------ | :------- | :----------------------------------------------- | :---------------------------------- | +| `environment` | Overall. | ⚠ required, multiple labels are supported. | All configuration blocks of Couper. | + +The `environment` block works like a preprocessor. If the label of an `environment` +block do not match the set [environment](./CLI.md#global-options) value, the preprocessor +removes this block and their content. Otherwise, the content of the block is applied +to the configuration. + + + +If the [environment](./CLI.md#global-options) value set to `prod`, the following +configuration: + +```hcl +server { + api "protected" { + endpoint "/secure" { + environment "prod" { + access_control = ["jwt"] + } + + proxy { + environment "prod" { + url = "https://protected-resource.org" + } + environment "stage" { + url = "https://test-resource.org" + } + } + } + } +} +``` + +produces after the preprocessing the following configuration: + +```hcl +server { + api "protected" { + endpoint "/secure" { + access_control = ["jwt"] + + proxy { + url = "https://protected-resource.org" + } + } + } +} +``` + +**Note:** The value of the environment set via [Defaults Block](#defaults-block) is ignored. + ## Access Control The configuration of access control is twofold in Couper: You define the particular diff --git a/main.go b/main.go index 611c373c4..02dd3694a 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,7 @@ func realmain(ctx context.Context, arguments []string) int { DebugEndpoint bool `env:"debug"` FilePath string `env:"file"` DirPath string `env:"file_directory"` + Environment string `env:"environment"` FileWatch bool `env:"watch"` FileWatchRetryDelay time.Duration `env:"watch_retry_delay"` FileWatchRetries int `env:"watch_retries"` @@ -69,6 +70,7 @@ func realmain(ctx context.Context, arguments []string) int { set.BoolVar(&flags.DebugEndpoint, "debug", false, "-debug") set.Var(&filesList, "f", "-f /path/to/couper.hcl ...") set.Var(&filesList, "d", "-d /path/to/couper.d/ ...") + set.StringVar(&flags.Environment, "e", "", "-e stage") set.BoolVar(&flags.FileWatch, "watch", false, "-watch") set.DurationVar(&flags.FileWatchRetryDelay, "watch-retry-delay", time.Millisecond*500, "-watch-retry-delay 1s") set.IntVar(&flags.FileWatchRetries, "watch-retries", 5, "-watch-retries 10") @@ -117,19 +119,23 @@ func realmain(ctx context.Context, arguments []string) int { } } - if cmd == "verify" { - log := newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty) + log := newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty) + + if flags.Environment != "" { + log.Info(`couper uses "` + flags.Environment + `" environment`) + } - err = command.NewCommand(ctx, cmd).Execute(filesList.paths, nil, log) + if cmd == "verify" { + err = command.NewCommand(ctx, cmd).Execute(filesList.paths, &config.Couper{Environment: flags.Environment}, log) if err != nil { return 1 } return 0 } - confFile, err := configload.LoadFiles(filesList.paths) + confFile, err := configload.LoadFiles(filesList.paths, flags.Environment) if err != nil { - newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty).WithError(err).Error() + log.WithError(err).Error() return 1 } @@ -210,7 +216,7 @@ func realmain(ctx context.Context, arguments []string) int { errRetries = 0 // reset logger.Info("reloading couper configuration") - cf, reloadErr := configload.LoadFiles(filesList.paths) + cf, reloadErr := configload.LoadFiles(filesList.paths, flags.Environment) if reloadErr != nil { logger.WithError(reloadErr).Error("reload failed") time.Sleep(flags.FileWatchRetryDelay) diff --git a/server/http_endpoints_test.go b/server/http_endpoints_test.go index f08f06cf2..b3041c998 100644 --- a/server/http_endpoints_test.go +++ b/server/http_endpoints_test.go @@ -806,7 +806,7 @@ func TestEndpointCyclicSequence(t *testing.T) { defer cleanup(func() {}, test.New(t)) path := filepath.Join(testdataPath, testcase.file) - _, err := configload.LoadFile(path) + _, err := configload.LoadFile(path, "") diags, ok := err.(*hcl.Diagnostic) if !ok { diff --git a/server/http_error_handler_test.go b/server/http_error_handler_test.go index 4077365c7..c43fc1382 100644 --- a/server/http_error_handler_test.go +++ b/server/http_error_handler_test.go @@ -115,7 +115,7 @@ func TestAccessControl_ErrorHandler_BasicAuth_Wildcard(t *testing.T) { } func TestAccessControl_ErrorHandler_Configuration_Error(t *testing.T) { - _, err := configload.LoadFile("testdata/integration/error_handler/03_couper.hcl") + _, err := configload.LoadFile("testdata/integration/error_handler/03_couper.hcl", "") expectedMsg := "03_couper.hcl:24,12-12: Missing required argument; The argument \"grant_type\" is required, but no definition was found." @@ -334,7 +334,7 @@ func TestAccessControl_ErrorHandler_Permissions(t *testing.T) { } func Test_Panic_Multi_EH(t *testing.T) { - _, err := configload.LoadFile("testdata/settings/16_couper.hcl") + _, err := configload.LoadFile("testdata/settings/16_couper.hcl", "") expectedMsg := `: duplicate error type registration: "*"; ` diff --git a/server/http_integration_test.go b/server/http_integration_test.go index 21850a377..cd95f7fdb 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -85,14 +85,14 @@ func teardown() { } func newCouper(file string, helper *test.Helper) (func(), *logrustest.Hook) { - couperConfig, err := configload.LoadFile(filepath.Join(testWorkingDir, file)) + couperConfig, err := configload.LoadFile(filepath.Join(testWorkingDir, file), "test") helper.Must(err) return newCouperWithConfig(couperConfig, helper) } func newCouperMultiFiles(file, dir string, helper *test.Helper) (func(), *logrustest.Hook) { - couperConfig, err := configload.LoadFiles([]string{file, dir}) + couperConfig, err := configload.LoadFiles([]string{file, dir}, "test") helper.Must(err) return newCouperWithConfig(couperConfig, helper) @@ -3506,7 +3506,7 @@ func TestJWKsMaxStale(t *testing.T) { func TestJWTAccessControlSourceConfig(t *testing.T) { helper := test.New(t) - couperConfig, err := configload.LoadFile("testdata/integration/config/05_couper.hcl") + couperConfig, err := configload.LoadFile("testdata/integration/config/05_couper.hcl", "") helper.Must(err) log, _ := logrustest.NewNullLogger() diff --git a/server/http_test.go b/server/http_test.go index 3d9d8d6ab..44e285e9f 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -710,3 +710,25 @@ func TestHTTPServer_parseDuration(t *testing.T) { t.Errorf("%#v", logs[0].Message) } } + +func TestHTTPServer_EnvironmentBlocks(t *testing.T) { + helper := test.New(t) + client := newClient() + + shutdown, _ := newCouper("testdata/integration/environment/01_couper.hcl", test.New(t)) + defer shutdown() + + req, err := http.NewRequest(http.MethodGet, "http://anyserver:8080/test", nil) + helper.Must(err) + + res, err := client.Do(req) + helper.Must(err) + + if h := res.Header.Get("X-Test-Env"); h != "test" { + t.Errorf("Unexpected header given: %q", h) + } + + if res.StatusCode != http.StatusOK { + t.Errorf("Unexpected status code: %d", res.StatusCode) + } +} diff --git a/server/multi_files_test.go b/server/multi_files_test.go index 38efd8ac5..859231540 100644 --- a/server/multi_files_test.go +++ b/server/multi_files_test.go @@ -190,7 +190,7 @@ func TestMultiFiles_MultipleBackends(t *testing.T) { {"testdata/multi/backends/errors/api_ep.hcl"}, } { t.Run(tc.config, func(st *testing.T) { - _, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.config)) + _, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.config), "") if !strings.Contains(err.Error(), "Multiple definitions of backend are not allowed.") { st.Errorf("Unexpected error: %s", err.Error()) @@ -234,7 +234,7 @@ func Test_MultipleLabels(t *testing.T) { }, } { t.Run(tc.name, func(st *testing.T) { - _, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.configPath)) + _, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.configPath), "") if (err != nil && tc.expError == "") || (tc.expError != "" && (err == nil || !strings.Contains(err.Error(), tc.expError))) { diff --git a/server/testdata/integration/environment/01_couper.hcl b/server/testdata/integration/environment/01_couper.hcl new file mode 100644 index 000000000..16f5ea2e0 --- /dev/null +++ b/server/testdata/integration/environment/01_couper.hcl @@ -0,0 +1,24 @@ +environment "test" { + server { + endpoint "/test" { + environment "test" "foo" "bar" { + set_response_headers = { + X-Test-Env = "test" + } + } + proxy { + environment "test" { + url = "${env.COUPER_TEST_BACKEND_ADDR}/anything" + } + environment "prod" { + access_control = ["auth"] + url = "${env.COUPER_TEST_BACKEND_ADDR}" + } + } + } + } +} + +environment "prod" { + server {} +}