Skip to content

Azurerm storage for remote state backend #4487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 52 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
4022280
Starting to test with azurerm storage backend. Mostly done to speed u…
omattsson Jun 1, 2025
12c7ef4
Add azure_helper functions
olofmattsson-inriver Jun 10, 2025
228b6fe
Merge branch 'gruntwork-io:main' into azurerm_storage
omattsson Jun 10, 2025
ded4b00
Add azurerm as remote backend
olofmattsson-inriver Jun 10, 2025
543870e
Merge branch 'azurerm_storage' of https://github.com/omattsson/terrag…
olofmattsson-inriver Jun 10, 2025
561dd63
Add integration test for backed booststrap and delete
olofmattsson-inriver Jun 11, 2025
307bf1c
Add test helpers for Azure storage
olofmattsson-inriver Jun 11, 2025
2d7ba15
Fix lint errors in azurerm backend
olofmattsson-inriver Jun 12, 2025
41aaa32
Fix lint issues in azurehelper
olofmattsson-inriver Jun 12, 2025
23f6d26
Add getTerragruntOutputJSONFromRemoteStateAzurerm and fix linters
olofmattsson-inriver Jun 12, 2025
5d4e128
Fix syntax error
olofmattsson-inriver Jun 12, 2025
9518a41
Misc cleanups
olofmattsson-inriver Jun 12, 2025
42604b4
Fix linting
olofmattsson-inriver Jun 12, 2025
9e3da17
Running godot
olofmattsson-inriver Jun 13, 2025
9d1c686
Update docs
olofmattsson-inriver Jun 13, 2025
9b1b33c
Update go mod
olofmattsson-inriver Jun 13, 2025
00ed2c8
Merge branch 'gruntwork-io:main' into azurerm_storage
omattsson Jun 13, 2025
a39ccf3
Merge branch 'gruntwork-io:main' into azurerm_storage
omattsson Jun 15, 2025
281b772
Adding resource group and storage account creating and tests
olofmattsson-inriver Jun 19, 2025
03dba9a
Updated docs
olofmattsson-inriver Jun 19, 2025
47234dc
UPdate go.mod
olofmattsson-inriver Jun 19, 2025
032f64e
Tests and moving to use a experiment flag
olofmattsson-inriver Jun 23, 2025
59d9bdf
Fix azure integration test
olofmattsson-inriver Jun 23, 2025
a35f4d7
Linting away
olofmattsson-inriver Jun 23, 2025
af84a93
Adding tests and linting
olofmattsson-inriver Jun 24, 2025
5a3e579
Adding and moving tests
olofmattsson-inriver Jun 24, 2025
a5b3f59
Update error handling and add retry logic on Azure operations
olofmattsson-inriver Jun 25, 2025
c4897b4
Lint after changes
olofmattsson-inriver Jun 25, 2025
7ef35a6
Cleanups
olofmattsson-inriver Jun 26, 2025
a876d75
Increase testing of azurehelper
olofmattsson-inriver Jun 26, 2025
5aba15b
Add test for backend migrate
olofmattsson-inriver Jun 26, 2025
18e30cd
Added more unit and integration tests
olofmattsson-inriver Jun 27, 2025
53f3407
Removed Hierarchical namespace support
olofmattsson-inriver Jun 27, 2025
4c5c181
Test cleanups
olofmattsson-inriver Jun 30, 2025
be48a41
Update docs
olofmattsson-inriver Jun 30, 2025
9b1c317
Update integration tests
olofmattsson-inriver Jun 30, 2025
6c5b83a
Update logging
olofmattsson-inriver Jun 30, 2025
f69414e
Fixing issues found by Coderabbit
olofmattsson-inriver Jun 30, 2025
cb78df2
Fix integration test to work with experiment azure-backend
olofmattsson-inriver Jul 1, 2025
999c7b5
Merge branch 'main' into azurerm_storage
olofmattsson-inriver Jul 1, 2025
c177476
Minor test fixes and cleanup
olofmattsson-inriver Jul 1, 2025
2da6c9d
Updating after Coderabbits advice
olofmattsson-inriver Jul 1, 2025
4c8de94
Make test and code more robust by waiting for storage account to be c…
olofmattsson-inriver Jul 1, 2025
baf374e
Removed binary test file
olofmattsson-inriver Jul 1, 2025
bee06de
Add retry logic when adding RBAC permission
omattsson Jul 2, 2025
a3a89b3
Reorganize rbac constants tests
omattsson Jul 2, 2025
5ba33de
Add RBAC assignment retry
olofmattsson-inriver Jul 3, 2025
82f9a7b
Merge pull request #1 from omattsson/add_rbac_retry
omattsson Jul 3, 2025
e792234
Make integration tests run more stable and remove need for pre-existi…
olofmattsson-inriver Jul 3, 2025
78d1a2c
Redesign to use interfaces instead of direct access to azurehelper
olofmattsson-inriver Jul 10, 2025
517dd6c
Better integration test isolation
omattsson Jul 16, 2025
c3fee1f
Cleanup
omattsson Jul 16, 2025
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
29 changes: 28 additions & 1 deletion cli/commands/backend/migrate/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package migrate

import (
"context"
"fmt"

"github.com/gruntwork-io/terragrunt/config"
"github.com/gruntwork-io/terragrunt/configstack"
"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/internal/remotestate"
"github.com/gruntwork-io/terragrunt/internal/remotestate/backend"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/pkg/log"
Expand Down Expand Up @@ -45,6 +47,16 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio
return errors.Errorf("dst unit not found at %s", dstPath)
}

// Ensure experiment flags are propagated to module options
srcModule.TerragruntOptions.Experiments = opts.Experiments
dstModule.TerragruntOptions.Experiments = opts.Experiments

l.Debugf("Migration: Source experiments: %v", srcModule.TerragruntOptions.Experiments)
l.Debugf("Migration: Destination experiments: %v", dstModule.TerragruntOptions.Experiments)

// Re-register backends with updated experiment flags
remotestate.RegisterBackends(srcModule.TerragruntOptions)

srcRemoteState, err := config.ParseRemoteState(ctx, l, srcModule.TerragruntOptions)
if err != nil || srcRemoteState == nil {
return err
Expand All @@ -55,6 +67,13 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio
return err
}

// Update backends to use newly registered backends (e.g., Azure backend after experiment flag is set)
srcRemoteState.UpdateBackend()
dstRemoteState.UpdateBackend()

l.Debugf("Migration: Source backend: %s (type: %T)", srcRemoteState.BackendName, srcRemoteState)
l.Debugf("Migration: Destination backend: %s (type: %T)", dstRemoteState.BackendName, dstRemoteState)

if !opts.ForceBackendMigrate {
enabled, err := srcRemoteState.IsVersionControlEnabled(ctx, l, srcModule.TerragruntOptions)
if err != nil && !errors.As(err, new(backend.BucketDoesNotExistError)) {
Expand All @@ -66,5 +85,13 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio
}
}

return srcRemoteState.Migrate(ctx, l, srcModule.TerragruntOptions, dstModule.TerragruntOptions, dstRemoteState)
err = srcRemoteState.Migrate(ctx, l, srcModule.TerragruntOptions, dstModule.TerragruntOptions, dstRemoteState)
if err != nil {
return err
}

l.Infof("Successfully migrated remote state from %s to %s", srcPath, dstPath)
fmt.Printf("Backend migration completed successfully from %s to %s\n", srcPath, dstPath)

return nil
}
42 changes: 42 additions & 0 deletions cli/commands/backend/migrate/migrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package migrate_test

import (
"testing"

"github.com/gruntwork-io/terragrunt/cli/commands/backend/migrate"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
"github.com/stretchr/testify/require"
)

func TestMigrateOutputMessage(t *testing.T) {
t.Parallel()

// Create a simple test to verify the migration function includes output messages
// This test doesn't require actual Azure resources

// Create test options
opts, err := options.NewTerragruntOptionsForTest("")
require.NoError(t, err)

// Create a logger
testLogger := logger.CreateLogger()

ctx := t.Context()

// Note: This test would normally fail because we don't have valid paths,
// but we're mainly testing that the message structure is correct
// In a real scenario, we'd mock the dependencies

srcPath := "/tmp/test-src"
dstPath := "/tmp/test-dst"

// This will fail early due to missing files, but we can at least verify
// that the function structure and imports are correct
err = migrate.Run(ctx, testLogger, srcPath, dstPath, opts)

// We expect an error due to missing files, but the function should be callable
require.Error(t, err)

t.Logf("Migration function callable and returns expected error for invalid paths")
}
5 changes: 5 additions & 0 deletions cli/flags/global/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix
Action: func(_ *cli.Context, val []string) error {
opts.Experiments.NotifyCompletedExperiments(l)

// Re-register backends after experiments are enabled
// This is needed because RegisterBackends is called during options initialization
// before CLI flags are processed, so experimental backends need to be registered again
options.RunHooks(opts)

return nil
},
},
Expand Down
6 changes: 3 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,15 +360,15 @@ func (cfg *TerragruntConfig) WriteTo(w io.Writer) (int64, error) {

remoteStateBody.SetAttributeValue("backend", remoteStateAsCty.GetAttr("backend"))

if cfg.RemoteState.Config.DisableInit {
if cfg.RemoteState.DisableInit {
remoteStateBody.SetAttributeValue("disable_init", remoteStateAsCty.GetAttr("disable_init"))
}

if cfg.RemoteState.Config.DisableDependencyOptimization {
if cfg.RemoteState.DisableDependencyOptimization {
remoteStateBody.SetAttributeValue("disable_dependency_optimization", remoteStateAsCty.GetAttr("disable_dependency_optimization"))
}

if cfg.RemoteState.Config.BackendConfig != nil {
if cfg.RemoteState.BackendConfig != nil {
remoteStateBody.SetAttributeValue("config", remoteStateAsCty.GetAttr("config"))
}

Expand Down
16 changes: 8 additions & 8 deletions config/config_as_cty.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,32 +600,32 @@ func terraformConfigAsCty(config *TerraformConfig) (cty.Value, error) {
// serialize the struct because `config` and `encryption` are arbitrary
// interfaces whose type we do not know, so we have to do a hack to go through json.
func RemoteStateAsCty(remote *remotestate.RemoteState) (cty.Value, error) {
if remote == nil || remote.Config == nil {
if remote == nil {
return cty.NilVal, nil
}

config := remote.Config
// Use remote directly since the struct is now flattened

output := map[string]cty.Value{}
output["backend"] = gostringToCty(config.BackendName)
output["disable_init"] = goboolToCty(config.DisableInit)
output["disable_dependency_optimization"] = goboolToCty(config.DisableDependencyOptimization)
output["backend"] = gostringToCty(remote.BackendName)
output["disable_init"] = goboolToCty(remote.DisableInit)
output["disable_dependency_optimization"] = goboolToCty(remote.DisableDependencyOptimization)

generateCty, err := goTypeToCty(config.Generate)
generateCty, err := goTypeToCty(remote.Generate)
if err != nil {
return cty.NilVal, err
}

output["generate"] = generateCty

ctyJSONVal, err := convertToCtyWithJSON(config.BackendConfig)
ctyJSONVal, err := convertToCtyWithJSON(remote.BackendConfig)
if err != nil {
return cty.NilVal, err
}

output["config"] = ctyJSONVal

ctyJSONVal, err = convertToCtyWithJSON(config.Encryption)
ctyJSONVal, err = convertToCtyWithJSON(remote.Encryption)
if err != nil {
return cty.NilVal, err
}
Expand Down
6 changes: 3 additions & 3 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1857,9 +1857,9 @@ inputs = {
// assert.Equal(t, terragruntConfig.Catalog.URLs, rereadConfig.Catalog.URLs)

assert.Equal(t, terragruntConfig.RemoteState.BackendName, rereadConfig.RemoteState.BackendName)
assert.Equal(t, terragruntConfig.RemoteState.Config.DisableInit, rereadConfig.RemoteState.Config.DisableInit)
assert.Equal(t, terragruntConfig.RemoteState.Config.DisableDependencyOptimization, rereadConfig.RemoteState.Config.DisableDependencyOptimization)
assert.Equal(t, terragruntConfig.RemoteState.Config.BackendConfig, rereadConfig.RemoteState.Config.BackendConfig)
assert.Equal(t, terragruntConfig.RemoteState.DisableInit, rereadConfig.RemoteState.DisableInit)
assert.Equal(t, terragruntConfig.RemoteState.DisableDependencyOptimization, rereadConfig.RemoteState.DisableDependencyOptimization)
assert.Equal(t, terragruntConfig.RemoteState.BackendConfig, rereadConfig.RemoteState.BackendConfig)

// We don't test dependencies here because they require filesystem operations.
// assert.Equal(t, terragruntConfig.Dependencies.Paths, rereadConfig.Dependencies.Paths)
Expand Down
89 changes: 88 additions & 1 deletion config/dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -14,12 +15,14 @@ import (
"github.com/aws/aws-sdk-go/aws/awserr"

"github.com/gruntwork-io/terragrunt/awshelper"
"github.com/gruntwork-io/terragrunt/internal/azure/azurehelper"
"github.com/gruntwork-io/terragrunt/internal/cache"
"github.com/gruntwork-io/terragrunt/internal/experiment"
"github.com/gruntwork-io/terragrunt/internal/remotestate"
"github.com/gruntwork-io/terragrunt/internal/report"
"github.com/gruntwork-io/terragrunt/pkg/log"

"github.com/gruntwork-io/terragrunt/internal/remotestate/backend/azurerm"
s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3"

"github.com/aws/aws-sdk-go/aws"
Expand Down Expand Up @@ -950,18 +953,38 @@ func getTerragruntOutputJSONFromRemoteState(
targetTGOptions,
remoteState,
)

if err != nil {
return nil, err
}

l.Debugf("Retrieved output from %s as json: %s using s3 bucket", targetTGOptions.TerragruntConfigPath, jsonBytes)

return jsonBytes, nil

case azurerm.BackendName:
l.Debugf("Fetching dependency outputs directly from Azure Storage backend for %s", targetTGOptions.TerragruntConfigPath)
jsonBytes, err := getTerragruntOutputJSONFromRemoteStateAzurerm(
l,
targetTGOptions,
remoteState,
)

if err != nil {
return nil, err
}

l.Debugf("Successfully retrieved outputs from Azure Storage state for %s", targetTGOptions.TerragruntConfigPath)

return jsonBytes, nil

default:
l.Errorf("FetchDependencyOutputFromState is not supported for backend %s, falling back to normal method", backend)
// For unsupported backends, we want to continue with the regular output path
l.Debugf("Backend %s does not support direct state output fetching, falling back to terragrunt output", backend)
}
}

// If direct fetching is not supported or disabled, fallback to the standard output path
// Generate the backend configuration in the working dir. If no generate config is set on the remote state block,
// set a temporary generate config so we can generate the backend code.
if remoteState.Generate == nil {
Expand Down Expand Up @@ -1056,6 +1079,70 @@ func getTerragruntOutputJSONFromRemoteStateS3(l log.Logger, opts *options.Terrag
return jsonOutputs, nil
}

// getTerragruntOutputJSONFromRemoteStateAzurerm pulls the output directly from an Azure storage without calling Terraform
func getTerragruntOutputJSONFromRemoteStateAzurerm(l log.Logger, opts *options.TerragruntOptions, remoteState *remotestate.RemoteState) (outputsJSON []byte, err error) {
ctx := context.Background()
// Validation should be done immediately after each type assertion
storageAccount, okStorage := remoteState.BackendConfig["storage_account_name"].(string)
if !okStorage {
return
}

containerName, okContainer := remoteState.BackendConfig["container_name"].(string)
if !okContainer {
return
}

key, okKey := remoteState.BackendConfig["key"].(string)
if !okKey {
return
}

l.Debugf("Attempting to fetch outputs directly from Azure Storage account %s, container %s, blob %s", storageAccount, containerName, key)

client, err := azurehelper.CreateBlobServiceClient(ctx, l, opts, remoteState.BackendConfig)
if err != nil {
return
}

resp, err := client.GetObject(ctx, &azurehelper.GetObjectInput{
Container: &containerName,
Key: &key,
})
if err != nil {
return nil, fmt.Errorf("error downloading state file: %w", err)
}

// Ensure response body is closed after we're done
defer func() {
if cerr := resp.Body.Close(); cerr != nil && err == nil {
err = fmt.Errorf("error closing response body: %w", cerr)
}
}()

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading state file: %w", err)
}

var state struct {
Outputs map[string]interface{} `json:"outputs"`
}

if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("error parsing state file JSON: %w", err)
}

outputsJSON, err = json.Marshal(state.Outputs)
if err != nil {
return nil, fmt.Errorf("error encoding outputs as JSON: %w", err)
}

l.Debugf("Successfully parsed outputs from Azure Storage state file")

return
}

// setupTerragruntOptionsForBareTerraform sets up a new TerragruntOptions struct that can be used to run terraform
// without going through the full RunTerragrunt operation.
func setupTerragruntOptionsForBareTerraform(ctx *ParsingContext, l log.Logger, workingDir string, configPath string, iamRoleOpts options.IAMRoleOptions) (*options.TerragruntOptions, error) {
Expand Down
Loading