Backend/S3: Custom Service Endpoint Configuration (#794)

Signed-off-by: Marcin Białoń <mbialon@spacelift.io>
This commit is contained in:
Marcin Białoń 2023-10-31 10:02:58 +01:00 committed by GitHub
parent 9c789368dc
commit a1e110c679
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 254 additions and 15 deletions

View File

@ -62,6 +62,7 @@ S3 BACKEND:
* Adds support for the `retry_mode` attribute. ([#698](https://github.com/opentofu/opentofu/issues/698))
* Adds support for the `http_proxy`, `insecure`, `use_dualstack_endpoint`, and `use_fips_endpoint` attributes. ([#694](https://github.com/opentofu/opentofu/issues/694))
* Adds support for the `use_path_style` argument and deprecates the `force_path_style` argument. ([#783](https://github.com/opentofu/opentofu/issues/783))
* Adds support for customizing the AWS API endpoints. ([#775](https://github.com/opentofu/opentofu/issues/775))
## Previous Releases

View File

@ -66,25 +66,56 @@ func (b *Backend) ConfigSchema(context.Context) *configschema.Block {
Optional: true,
Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).",
},
"endpoints": {
NestedType: &configschema.Object{
Nesting: configschema.NestingSingle,
Attributes: map[string]*configschema.Attribute{
"s3": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the S3 API.",
},
"iam": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the IAM API.",
},
"sts": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the STS API.",
},
"dynamodb": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the DynamoDB API.",
},
},
},
},
"dynamodb_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the DynamoDB API",
Description: "A custom endpoint for the DynamoDB API. Use `endpoints.dynamodb` instead.",
Deprecated: true,
},
"endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the S3 API",
Description: "A custom endpoint for the S3 API. Use `endpoints.s3` instead",
Deprecated: true,
},
"iam_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the IAM API",
Description: "A custom endpoint for the IAM API. Use `endpoints.iam` instead",
Deprecated: true,
},
"sts_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the STS API",
Description: "A custom endpoint for the STS API. Use `endpoints.sts` instead",
Deprecated: true,
},
"sts_region": {
Type: cty.String,
@ -568,6 +599,10 @@ func (b *Backend) PrepareConfig(ctx context.Context, obj cty.Value) (cty.Value,
}
}
for _, endpoint := range customEndpoints {
endpoint.Validate(obj, &diags)
}
return obj, diags
}
@ -651,13 +686,13 @@ func (b *Backend) Configure(ctx context.Context, obj cty.Value) tfdiags.Diagnost
CallerDocumentationURL: "https://opentofu.org/docs/language/settings/backends/s3",
CallerName: "S3 Backend",
SuppressDebugLog: logging.IsDebugOrHigher(),
IamEndpoint: stringAttrDefaultEnvVar(obj, "iam_endpoint", "AWS_IAM_ENDPOINT"),
IamEndpoint: customEndpoints["iam"].String(obj),
MaxRetries: intAttrDefault(obj, "max_retries", 5),
Profile: stringAttr(obj, "profile"),
Region: stringAttr(obj, "region"),
SecretKey: stringAttr(obj, "secret_key"),
SkipCredsValidation: boolAttr(obj, "skip_credentials_validation"),
StsEndpoint: stringAttrDefaultEnvVar(obj, "sts_endpoint", "AWS_STS_ENDPOINT"),
StsEndpoint: customEndpoints["sts"].String(obj),
StsRegion: stringAttr(obj, "sts_region"),
Token: stringAttr(obj, "token"),
HTTPProxy: stringAttrDefaultEnvVar(obj, "http_proxy", "HTTP_PROXY", "HTTPS_PROXY"),
@ -779,7 +814,7 @@ func verifyAllowedAccountID(ctx context.Context, awsConfig aws.Config, cfg *awsb
func getDynamoDBConfig(obj cty.Value) func(options *dynamodb.Options) {
return func(options *dynamodb.Options) {
if v, ok := stringAttrDefaultEnvVarOk(obj, "dynamodb_endpoint", "AWS_DYNAMODB_ENDPOINT", "AWS_ENDPOINT_URL_DYNAMODB"); ok {
if v, ok := customEndpoints["dynamodb"].StringOk(obj); ok {
options.BaseEndpoint = aws.String(v)
}
}
@ -787,7 +822,7 @@ func getDynamoDBConfig(obj cty.Value) func(options *dynamodb.Options) {
func getS3Config(obj cty.Value) func(options *s3.Options) {
return func(options *s3.Options) {
if v, ok := stringAttrDefaultEnvVarOk(obj, "endpoint", "AWS_S3_ENDPOINT", "AWS_ENDPOINT_URL_S3"); ok {
if v, ok := customEndpoints["s3"].StringOk(obj); ok {
options.BaseEndpoint = aws.String(v)
}
if v, ok := boolAttrOk(obj, "force_path_style"); ok {
@ -1021,6 +1056,15 @@ func stringMapAttrOk(obj cty.Value, name string) (map[string]string, bool) {
return stringMapValueOk(obj.GetAttr(name))
}
func customEndpointAttrDefaultEnvVarOk(obj cty.Value, endpointsKey, deprecatedKey string, envvars ...string) (string, bool) {
if val := obj.GetAttr("endpoints"); !val.IsNull() {
if v, ok := stringAttrDefaultEnvVarOk(val, endpointsKey, envvars...); ok {
return v, true
}
}
return stringAttrDefaultEnvVarOk(obj, deprecatedKey, envvars...)
}
func pathString(path cty.Path) string {
var buf strings.Builder
for i, step := range path {
@ -1098,3 +1142,78 @@ const encryptionKeyConflictEnvVarError = `Only one of "kms_key_id" and the envir
The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS)
while "AWS_SSE_CUSTOMER_KEY" is used for encryption with customer-managed keys (SSE-C).
Please choose one or the other.`
type customEndpoint struct {
Paths []cty.Path
EnvVars []string
}
func (e customEndpoint) Validate(obj cty.Value, diags *tfdiags.Diagnostics) {
validateAttributesConflict(e.Paths...)(obj, cty.Path{}, diags)
}
func (e customEndpoint) String(obj cty.Value) string {
v, _ := e.StringOk(obj)
return v
}
func (e customEndpoint) StringOk(obj cty.Value) (string, bool) {
for _, path := range e.Paths {
val, err := path.Apply(obj)
if err != nil {
continue
}
if s, ok := stringValueOk(val); ok {
return s, true
}
}
for _, envVar := range e.EnvVars {
if v := os.Getenv(envVar); v != "" {
return v, true
}
}
return "", false
}
var customEndpoints = map[string]customEndpoint{
"s3": {
Paths: []cty.Path{
cty.GetAttrPath("endpoints").GetAttr("s3"),
cty.GetAttrPath("endpoint"),
},
EnvVars: []string{
"AWS_ENDPOINT_URL_S3",
"AWS_S3_ENDPOINT",
},
},
"iam": {
Paths: []cty.Path{
cty.GetAttrPath("endpoints").GetAttr("iam"),
cty.GetAttrPath("iam_endpoint"),
},
EnvVars: []string{
"AWS_ENDPOINT_URL_IAM",
"AWS_IAM_ENDPOINT",
},
},
"sts": {
Paths: []cty.Path{
cty.GetAttrPath("endpoints").GetAttr("sts"),
cty.GetAttrPath("sts_endpoint"),
},
EnvVars: []string{
"AWS_ENDPOINT_URL_STS",
"AWS_STS_ENDPOINT",
},
},
"dynamodb": {
Paths: []cty.Path{
cty.GetAttrPath("endpoints").GetAttr("dynamodb"),
cty.GetAttrPath("dynamodb_endpoint"),
},
EnvVars: []string{
"AWS_ENDPOINT_URL_DYNAMODB",
"AWS_DYNAMODB_ENDPOINT",
},
},
}

View File

@ -738,6 +738,54 @@ func TestBackendConfig_PrepareConfigValidation(t *testing.T) {
}),
expectedErr: `Invalid retry mode: Valid values are "standard" and "adaptive".`,
},
"s3 endpoint conflict": {
config: cty.ObjectVal(map[string]cty.Value{
"bucket": cty.StringVal("test"),
"key": cty.StringVal("test"),
"region": cty.StringVal("us-west-2"),
"endpoint": cty.StringVal("x1"),
"endpoints": cty.ObjectVal(map[string]cty.Value{
"s3": cty.StringVal("x2"),
}),
}),
expectedErr: `Invalid Attribute Combination: Only one of endpoints.s3, endpoint can be set.`,
},
"iam endpoint conflict": {
config: cty.ObjectVal(map[string]cty.Value{
"bucket": cty.StringVal("test"),
"key": cty.StringVal("test"),
"region": cty.StringVal("us-west-2"),
"iam_endpoint": cty.StringVal("x1"),
"endpoints": cty.ObjectVal(map[string]cty.Value{
"iam": cty.StringVal("x2"),
}),
}),
expectedErr: `Invalid Attribute Combination: Only one of endpoints.iam, iam_endpoint can be set.`,
},
"sts endpoint conflict": {
config: cty.ObjectVal(map[string]cty.Value{
"bucket": cty.StringVal("test"),
"key": cty.StringVal("test"),
"region": cty.StringVal("us-west-2"),
"sts_endpoint": cty.StringVal("x1"),
"endpoints": cty.ObjectVal(map[string]cty.Value{
"sts": cty.StringVal("x2"),
}),
}),
expectedErr: `Invalid Attribute Combination: Only one of endpoints.sts, sts_endpoint can be set.`,
},
"dynamodb endpoint conflict": {
config: cty.ObjectVal(map[string]cty.Value{
"bucket": cty.StringVal("test"),
"key": cty.StringVal("test"),
"region": cty.StringVal("us-west-2"),
"dynamodb_endpoint": cty.StringVal("x1"),
"endpoints": cty.ObjectVal(map[string]cty.Value{
"dynamodb": cty.StringVal("x2"),
}),
}),
expectedErr: `Invalid Attribute Combination: Only one of endpoints.dynamodb, dynamodb_endpoint can be set.`,
},
}
for name, tc := range cases {

View File

@ -154,10 +154,27 @@ func aliasIdFromARNResource(s string) string {
type objectValidator func(obj cty.Value, objPath cty.Path, diags *tfdiags.Diagnostics)
func validateAttributesConflict(paths ...cty.Path) objectValidator {
applyPath := func(obj cty.Value, path cty.Path) (cty.Value, error) {
if len(path) == 0 {
return cty.NilVal, nil
}
for _, step := range path {
val, err := step.Apply(obj)
if err != nil {
return cty.NilVal, err
}
if val.IsNull() {
return cty.NilVal, nil
}
obj = val
}
return obj, nil
}
return func(obj cty.Value, objPath cty.Path, diags *tfdiags.Diagnostics) {
found := false
for _, path := range paths {
val, err := path.Apply(obj)
val, err := applyPath(obj, path)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Path for Schema",

View File

@ -153,6 +153,7 @@ func Test_validateAttributesConflict(t *testing.T) {
objValues: map[string]cty.Value{
"attr1": cty.StringVal("value1"),
"attr2": cty.StringVal("value2"),
"attr3": cty.StringVal("value3"),
},
expectErr: true,
},
@ -160,14 +161,43 @@ func Test_validateAttributesConflict(t *testing.T) {
name: "No Conflict",
paths: []cty.Path{
{cty.GetAttrStep{Name: "attr1"}},
{cty.GetAttrStep{Name: "attr3"}},
{cty.GetAttrStep{Name: "attr2"}},
},
objValues: map[string]cty.Value{
"attr1": cty.StringVal("value1"),
"attr2": cty.NilVal,
"attr3": cty.StringVal("value3"),
},
expectErr: false,
},
{
name: "Nested: Conflict Found",
paths: []cty.Path{
(cty.Path{cty.GetAttrStep{Name: "nested"}}).GetAttr("attr1"),
{cty.GetAttrStep{Name: "attr2"}},
},
objValues: map[string]cty.Value{
"nested": cty.ObjectVal(map[string]cty.Value{
"attr1": cty.StringVal("value1"),
}),
"attr2": cty.StringVal("value2"),
"attr3": cty.StringVal("value3"),
},
expectErr: true,
},
{
name: "Nested: No Conflict",
paths: []cty.Path{
(cty.Path{cty.GetAttrStep{Name: "nested"}}).GetAttr("attr1"),
{cty.GetAttrStep{Name: "attr3"}},
},
objValues: map[string]cty.Value{
"nested": cty.NilVal,
"attr1": cty.StringVal("value1"),
"attr3": cty.StringVal("value3"),
},
expectErr: false,
},
}
for _, test := range tests {
@ -182,7 +212,11 @@ func Test_validateAttributesConflict(t *testing.T) {
if test.expectErr {
if !diags.HasErrors() {
t.Errorf("Expected validation errors, but got none.")
t.Error("Expected validation errors, but got none.")
}
} else {
if diags.HasErrors() {
t.Errorf("Expected no errors, but got %s.", diags.Err())
}
}
})

View File

@ -159,7 +159,7 @@ The following configuration is optional:
* `access_key` - (Optional) AWS access key. If configured, must also configure `secret_key`. This can also be sourced from the `AWS_ACCESS_KEY_ID` environment variable, AWS shared credentials file (e.g. `~/.aws/credentials`), or AWS shared configuration file (e.g. `~/.aws/config`).
* `secret_key` - (Optional) AWS access key. If configured, must also configure `access_key`. This can also be sourced from the `AWS_SECRET_ACCESS_KEY` environment variable, AWS shared credentials file (e.g. `~/.aws/credentials`), or AWS shared configuration file (e.g. `~/.aws/config`).
* `iam_endpoint` - (Optional) Custom endpoint for the AWS Identity and Access Management (IAM) API. This can also be sourced from the `AWS_IAM_ENDPOINT` environment variable.
* `iam_endpoint` - (Optional) **Deprecated** Custom endpoint for the AWS Identity and Access Management (IAM) API. This can also be sourced from the `AWS_IAM_ENDPOINT` environment variable.
* `max_retries` - (Optional) The maximum number of times an AWS API request is retried on retryable failure. Defaults to 5.
* `retry_mode` - (Optional) Specifies how retries are attempted. Valid values are `standard` and `adaptive`. This can also be sourced from the `AWS_RETRY_MODE` environment variable.
* `profile` - (Optional) Name of AWS profile in AWS shared credentials file (e.g. `~/.aws/credentials`) or AWS shared configuration file (e.g. `~/.aws/config`) to use for credentials and/or configuration. This can also be sourced from the `AWS_PROFILE` environment variable.
@ -169,7 +169,7 @@ The following configuration is optional:
* `skip_credentials_validation` - (Optional) Skip credentials validation via the STS API.
* `skip_region_validation` - (Optional) Skip validation of provided region name.
* `skip_metadata_api_check` - (Optional) Skip usage of EC2 Metadata API.
* `sts_endpoint` - (Optional) Custom endpoint for the AWS Security Token Service (STS) API. This can also be sourced from the `AWS_STS_ENDPOINT` environment variable.
* `sts_endpoint` - (Optional) **Deprecated** Custom endpoint for the AWS Security Token Service (STS) API. This can also be sourced from the `AWS_STS_ENDPOINT` environment variable.
* `sts_region` - (Optional) AWS region for STS. If unset, AWS will use the same region for STS as other non-STS operations.
* `token` - (Optional) Multi-Factor Authentication (MFA) token. This can also be sourced from the `AWS_SESSION_TOKEN` environment variable.
* `allowed_account_ids` (Optional): A list of permitted AWS account IDs to safeguard against accidental disruption of a live environment. This option conflicts with `forbidden_account_ids`.
@ -183,6 +183,26 @@ The following configuration is optional:
* `use_dualstack_endpoint` - (Optional) Resolve an endpoint with DualStack capability.
* `use_fips_endpoint` - (Optional) Resolve an endpoint with FIPS capability.
#### Customizing AWS API Endpoints
The optional `endpoints` argument contains the following options:
* `s3` - (Optional) Use this to set a custom endpoint URL for the AWS S3 API. This can also be sourced from the `AWS_ENDPOINT_URL_S3` environment variable or the deprecated environment variable `AWS_S3_ENDPOINT`.
* `iam` - (Optional) Use this to set a custom endpoint URL for the AWS IAM API. This can also be sourced from the `AWS_ENDPOINT_URL_IAM` environment variable or the deprecated environment variable `AWS_IAM_ENDPOINT`.
* `sts` - (Optional) Use this to set a custom endpoint URL for the AWS STS API. This can also be sourced from the `AWS_ENDPOINT_URL_STS` environment variable or the deprecated environment variable `AWS_STS_ENDPOINT`.
* `dynamodb` - (Optional) Use this to set a custom endpoint URL for the AWS DynamoDB API. This can also be sourced from the `AWS_ENDPOINT_URL_DYNAMODB` environment variable or the deprecated environment variable `AWS_DYNAMODB_ENDPOINT`.
```hcl
terraform {
backend "s3" {
endpoints {
dynamodb = "http://localhost:4569"
s3 = "http://localhost:4572"
}
}
}
```
#### Assume Role Configuration
Assuming an IAM Role is optional and can be configured in two ways.
@ -314,7 +334,7 @@ The following configuration is optional:
* `acl` - (Optional) [Canned ACL](https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl) to be applied to the state file.
* `encrypt` - (Optional) Enable [server side encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingServerSideEncryption.html) of the state file.
* `endpoint` - (Optional) Custom endpoint for the AWS S3 API. This can also be sourced from the `AWS_S3_ENDPOINT` environment variable.
* `endpoint` - (Optional) **Deprecated** Custom endpoint for the AWS S3 API. This can also be sourced from the `AWS_S3_ENDPOINT` environment variable.
* `force_path_style` - (Optional) **Deprecated** Enable path-style S3 URLs (`https://<HOST>/<BUCKET>` instead of `https://<BUCKET>.<HOST>`). Use `use_path_style` instead.
* `use_path_style` - (Optional) Enable path-style S3 URLs (`https://<HOST>/<BUCKET>` instead of `https://<BUCKET>.<HOST>`).
* `kms_key_id` - (Optional) Amazon Resource Name (ARN) of a Key Management Service (KMS) Key to use for encrypting the state. Note that if this value is specified, OpenTofu will need `kms:Encrypt`, `kms:Decrypt` and `kms:GenerateDataKey` permissions on this KMS key.
@ -325,7 +345,7 @@ The following configuration is optional:
The following configuration is optional:
* `dynamodb_endpoint` - (Optional) Custom endpoint for the AWS DynamoDB API. This can also be sourced from the `AWS_DYNAMODB_ENDPOINT` environment variable.
* `dynamodb_endpoint` - (Optional) **Deprecated** Custom endpoint for the AWS DynamoDB API. This can also be sourced from the `AWS_DYNAMODB_ENDPOINT` environment variable.
* `dynamodb_table` - (Optional) Name of DynamoDB Table to use for state locking and consistency. The table must have a partition key named `LockID` with type of `String`. If not configured, state locking will be disabled.
## Multi-account AWS Architecture