Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -1192,27 +1192,29 @@ var (
ErrGitPullRequestNotSupported = errors.New("git pull request publishing is not supported by the cli provider")

// Backend provisioning errors.
ErrBucketRequired = errors.New("backend.bucket is required")
ErrRegionRequired = errors.New("backend.region is required")
ErrBackendNotFound = errors.New("backend configuration not found")
ErrProvisioningNotConfigured = errors.New("provisioning not configured")
ErrCreateNotImplemented = errors.New("create not implemented for backend type")
ErrDeleteNotImplemented = errors.New("delete not implemented for backend type")
ErrProvisionerFailed = errors.New("provisioner failed")
ErrLoadAWSConfig = errors.New("failed to load AWS config")
ErrCheckBucketExist = errors.New("failed to check bucket existence")
ErrCreateBucket = errors.New("failed to create bucket")
ErrApplyBucketDefaults = errors.New("failed to apply bucket defaults")
ErrEnableVersioning = errors.New("failed to enable versioning")
ErrEnableEncryption = errors.New("failed to enable encryption")
ErrBlockPublicAccess = errors.New("failed to block public access")
ErrApplyTags = errors.New("failed to apply tags")
ErrForceRequired = errors.New("--force flag required for backend deletion")
ErrBucketNotEmpty = errors.New("bucket contains objects and cannot be deleted")
ErrStateFilesExist = errors.New("bucket contains terraform state files")
ErrDeleteObjects = errors.New("failed to delete objects from bucket")
ErrDeleteBucket = errors.New("failed to delete bucket")
ErrListObjects = errors.New("failed to list bucket objects")
ErrBucketRequired = errors.New("backend.bucket is required")
ErrRegionRequired = errors.New("backend.region is required")
ErrBackendNotFound = errors.New("backend configuration not found")
ErrProvisioningNotConfigured = errors.New("provisioning not configured")
ErrCreateNotImplemented = errors.New("create not implemented for backend type")
ErrInvalidBucketNamespace = errors.New("provision.backend.bucket_namespace must be a string")
ErrUnsupportedBucketNamespace = errors.New("unsupported provision.backend.bucket_namespace value")
ErrDeleteNotImplemented = errors.New("delete not implemented for backend type")
ErrProvisionerFailed = errors.New("provisioner failed")
ErrLoadAWSConfig = errors.New("failed to load AWS config")
ErrCheckBucketExist = errors.New("failed to check bucket existence")
ErrCreateBucket = errors.New("failed to create bucket")
ErrApplyBucketDefaults = errors.New("failed to apply bucket defaults")
ErrEnableVersioning = errors.New("failed to enable versioning")
ErrEnableEncryption = errors.New("failed to enable encryption")
ErrBlockPublicAccess = errors.New("failed to block public access")
ErrApplyTags = errors.New("failed to apply tags")
ErrForceRequired = errors.New("--force flag required for backend deletion")
ErrBucketNotEmpty = errors.New("bucket contains objects and cannot be deleted")
ErrStateFilesExist = errors.New("bucket contains terraform state files")
ErrDeleteObjects = errors.New("failed to delete objects from bucket")
ErrDeleteBucket = errors.New("failed to delete bucket")
ErrListObjects = errors.New("failed to list bucket objects")

// Azure (azurerm) backend provisioning errors.
ErrResourceGroupRequired = errors.New("backend.resource_group_name is required for azurerm backend provisioning")
Expand Down
83 changes: 81 additions & 2 deletions pkg/datafetcher/schema/atmos/manifest/1.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@
"x-atmos-replacement": "dependencies.components"
},
"provision": {
"$ref": "#/definitions/provision"
"$ref": "#/definitions/terraform_provision"
},
"required_version": {
"$ref": "#/definitions/required_version"
Expand All @@ -459,6 +459,11 @@
"$ref": "#/definitions/terraform_flags"
}
},
"allOf": [
{
"$ref": "#/definitions/terraform_provision_backend_type_scope"
}
],
"required": []
}
]
Expand Down Expand Up @@ -566,7 +571,7 @@
"x-atmos-replacement": "dependencies.components"
},
"provision": {
"$ref": "#/definitions/provision"
"$ref": "#/definitions/terraform_provision"
},
"source": {
"$ref": "#/definitions/source"
Expand All @@ -584,6 +589,11 @@
"$ref": "#/definitions/terraform_flags"
}
},
"allOf": [
{
"$ref": "#/definitions/terraform_provision_backend_type_scope"
}
],
"required": []
}
]
Expand Down Expand Up @@ -1841,6 +1851,75 @@
}
]
},
"terraform_provision_backend_type_scope": {
"title": "terraform_provision_backend_type_scope",
"description": "Rejects provision.backend.bucket_namespace when backend_type explicitly selects a backend other than s3. A backend_type that is absent or an !include is not checked, because the effective backend cannot be determined from this manifest alone.",
"if": {
"properties": {
"backend_type": {
"type": "string",
"not": {
"anyOf": [
{
"const": "s3"
},
{
"pattern": "^!include"
}
]
}
}
},
"required": [
"backend_type"
]
},
"then": {
"properties": {
"provision": {
"properties": {
"backend": {
"properties": {
"bucket_namespace": false
}
}
}
}
}
}
},
"terraform_provision": {
"title": "terraform_provision",
"description": "Provision section for Terraform components: the shared provision settings plus state backend provisioning settings",
"allOf": [
{
"$ref": "#/definitions/provision"
},
{
"properties": {
"backend": {
"description": "Terraform state backend provisioning settings",
"oneOf": [
{
"type": "string",
"pattern": "^!include"
},
{
"type": "object",
"properties": {
"bucket_namespace": {
"type": "string",
"description": "S3 only. Sent to the S3 CreateBucket call as BucketNamespace when Atmos creates the state bucket. Valid values are defined by Amazon S3 (global or account-regional); other values are rejected before any AWS call. Not written to the generated Terraform backend configuration. Rejected when backend_type is set to a different backend type."
}
},
"additionalProperties": true
}
]
}
}
}
]
},
"provision": {
"title": "provision",
"description": "Provision section for component provisioning configuration, including workdir and delivery targets",
Expand Down
93 changes: 93 additions & 0 deletions pkg/datafetcher/schema_terraform_provision_backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package datafetcher

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"
)

// validateManifestAgainstEmbeddedSchema validates a manifest map against the embedded manifest schema.
func validateManifestAgainstEmbeddedSchema(t *testing.T, manifest map[string]any) *gojsonschema.Result {
t.Helper()

docJSON, err := json.Marshal(manifest)
require.NoError(t, err)

schemaJSON, err := json.Marshal(loadEmbeddedSchema(t))
require.NoError(t, err)

result, err := gojsonschema.Validate(
gojsonschema.NewBytesLoader(schemaJSON),
gojsonschema.NewBytesLoader(docJSON),
)
require.NoError(t, err, "schema validation should not error")

return result
}

// terraformComponentSection builds the settings shared by the component-level and
// section-level terraform fixtures below.
func terraformComponentSection(backendType any, provisionBackend map[string]any) map[string]any {
section := map[string]any{
"provision": map[string]any{"backend": provisionBackend},
}
if backendType != nil {
section["backend_type"] = backendType
}
return section
}

func TestManifestSchema_ProvisionBackendBucketNamespaceScopedToS3(t *testing.T) {
withNamespace := map[string]any{"enabled": true, "bucket_namespace": "any-value"}
withoutNamespace := map[string]any{"enabled": true}

tests := []struct {
name string
backendType any
backend map[string]any
wantValid bool
}{
{name: "s3 accepts bucket_namespace", backendType: "s3", backend: withNamespace, wantValid: true},
{name: "unset backend_type is not checked", backendType: nil, backend: withNamespace, wantValid: true},
{name: "included backend_type is not checked", backendType: "!include backend-type.yaml", backend: withNamespace, wantValid: true},
{name: "azurerm rejects bucket_namespace", backendType: "azurerm", backend: withNamespace, wantValid: false},
{name: "gcs rejects bucket_namespace", backendType: "gcs", backend: withNamespace, wantValid: false},
{name: "local rejects bucket_namespace", backendType: "local", backend: withNamespace, wantValid: false},
// Negative path: the restriction applies only to bucket_namespace, not to other provisioning settings.
{name: "azurerm without bucket_namespace stays valid", backendType: "azurerm", backend: withoutNamespace, wantValid: true},
{name: "s3 rejects non-string bucket_namespace", backendType: "s3", backend: map[string]any{"bucket_namespace": 42}, wantValid: false},
}

for _, tt := range tests {
t.Run("component: "+tt.name, func(t *testing.T) {
manifest := map[string]any{
"components": map[string]any{
"terraform": map[string]any{
"vpc": terraformComponentSection(tt.backendType, tt.backend),
},
},
}

result := validateManifestAgainstEmbeddedSchema(t, manifest)
for _, desc := range result.Errors() {
t.Logf("validation error: %s", desc)
}
assert.Equal(t, tt.wantValid, result.Valid())
})

t.Run("terraform section: "+tt.name, func(t *testing.T) {
manifest := map[string]any{
"terraform": terraformComponentSection(tt.backendType, tt.backend),
}

result := validateManifestAgainstEmbeddedSchema(t, manifest)
for _, desc := range result.Errors() {
t.Logf("validation error: %s", desc)
}
assert.Equal(t, tt.wantValid, result.Valid())
})
}
}
1 change: 1 addition & 0 deletions pkg/provisioner/backend/azurerm.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ func CreateAzurermBackend(
atmosConfig *schema.AtmosConfiguration,
backendConfig map[string]any,
authContext *schema.AuthContext,
_ ...CreateOption,
) (*ProvisionResult, error) {
defer perf.Track(atmosConfig, "backend.CreateAzurermBackend")()

Expand Down
47 changes: 46 additions & 1 deletion pkg/provisioner/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ type ProvisionResult struct {
}

// BackendCreateFunc is a function that creates a Terraform backend.
// Optional settings are supplied through CreateOption values; backend types ignore options that do not apply.
type BackendCreateFunc func(
ctx context.Context,
atmosConfig *schema.AtmosConfiguration,
backendConfig map[string]any,
authContext *schema.AuthContext,
opts ...CreateOption,
) (*ProvisionResult, error)

// BackendDeleteFunc is a function that deletes a Terraform backend.
Expand Down Expand Up @@ -246,6 +248,49 @@ func ProvisionBackend(
return nil, fmt.Errorf("%w: %s", errUtils.ErrCreateNotImplemented, backendType)
}

// Read optional create settings from provision.backend (not from the generated backend config).
opts, err := CreateOptionsFromComponent(componentConfig, backendType)
if err != nil {
return nil, err
}

// Execute create function.
return createFunc(ctx, atmosConfig, backendConfig, authContext)
return createFunc(ctx, atmosConfig, backendConfig, authContext, opts...)
}

// CreateOptionsFromComponent builds the create options for a component's backend from its
// provision.backend section. Every caller that invokes a BackendCreateFunc, including the
// automatic provisioning that runs on terraform init, uses it so the options stay consistent.
//
// Settings that apply to a single backend type are read only for that type, so an unrelated
// backend ignores them even when the value is malformed. The values are checked here, before
// any existence check or AWS call, so a bad value fails fast.
func CreateOptionsFromComponent(componentConfig map[string]any, backendType string) ([]CreateOption, error) {
defer perf.Track(nil, "backend.CreateOptionsFromComponent")()

if backendType != backendTypeS3 {
return nil, nil
}

provision, _ := componentConfig["provision"].(map[string]any)
provisionBackend, _ := provision["backend"].(map[string]any)

raw, present := provisionBackend["bucket_namespace"]
if !present || raw == nil {
return nil, nil
}

namespace, ok := raw.(string)
if !ok {
return nil, errUtils.Build(errUtils.ErrInvalidBucketNamespace).
WithExplanationf("Got value of type %T", raw).
WithHint("Set 'provision.backend.bucket_namespace' to a string").
Err()
}

if err := validateBucketNamespace(namespace); err != nil {
return nil, err
}

return []CreateOption{WithBucketNamespace(namespace)}, nil
}
Loading
Loading