Skip to content
Open
Show file tree
Hide file tree
Changes from 48 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
415 changes: 415 additions & 0 deletions azurehelper/azure_auth_test.go

Large diffs are not rendered by default.

368 changes: 368 additions & 0 deletions azurehelper/azure_blob.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
// Package azurehelper provides Azure-specific helper functions
package azurehelper

import (
"context"
"fmt"
"io"
"net/http"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/pkg/log"
)

// BlobServiceClient wraps Azure's azblob client to provide a simpler interface for our needs.
type BlobServiceClient struct {
client *azblob.Client
config map[string]interface{}
}

// GetObjectInput represents input parameters for getting a blob.
type GetObjectInput struct {
Container *string
Key *string
}

// GetObjectOutput represents the output from getting a blob.
type GetObjectOutput struct {
Body io.ReadCloser
}

// AzureResponseError represents an Azure API error response with detailed information.
// It contains the following fields:
// - StatusCode: HTTP status code from the Azure API response
// - ErrorCode: Azure-specific error code that identifies the error type
// - Message: Human-readable error message describing what went wrong
type AzureResponseError struct {
Message string // Human-readable error message (larger field first)
ErrorCode string // Azure-specific error code
StatusCode int // HTTP status code from the Azure API response
}

// ConvertAzureError converts an azcore.ResponseError to AzureResponseError
func ConvertAzureError(err error) *AzureResponseError {
var respErr *azcore.ResponseError
if errors.As(err, &respErr) {
// Extract the error message from the error object
// since respErr.Message is not directly accessible
message := respErr.Error()

return &AzureResponseError{
StatusCode: respErr.StatusCode,
ErrorCode: respErr.ErrorCode,
Message: message,
}
}

return nil
}

// Error implements the error interface for AzureResponseError
func (e *AzureResponseError) Error() string {
return fmt.Sprintf("Azure API error (StatusCode=%d, ErrorCode=%s): %s", e.StatusCode, e.ErrorCode, e.Message)
}

// CreateBlobServiceClient creates a new Azure Blob Service client using the configuration from the backend.
func CreateBlobServiceClient(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, config map[string]interface{}) (*BlobServiceClient, error) {
storageAccountName, okStorageAccountName := config["storage_account_name"].(string)
if !okStorageAccountName || storageAccountName == "" {
return nil, errors.Errorf("storage_account_name is required")
}

// Extract resource group and subscription ID if provided
resourceGroupName, _ := config["resource_group_name"].(string)
subscriptionID, _ := config["subscription_id"].(string)

var err error

// If we have subscription ID and resource group, verify storage account exists using Management API
if subscriptionID != "" && resourceGroupName != "" {
// Create storage account client to verify the storage account exists
var saClient *StorageAccountClient
saClient, err = CreateStorageAccountClient(ctx, l, config)

if err != nil {
return nil, errors.Errorf("error creating storage account client: %w", err)
}

// Check if the storage account exists
var exists bool
exists, _, err = saClient.StorageAccountExists(ctx)

if err != nil {
return nil, errors.Errorf("error checking if storage account exists: %w", err)
}

if !exists {
return nil, errors.Errorf("storage account %s does not exist in resource group %s",
storageAccountName, resourceGroupName)
}

l.Infof("Verified storage account %s exists", storageAccountName)
}

url := fmt.Sprintf("https://%s.blob.core.windows.net", storageAccountName)

cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{})
if err != nil {
return nil, errors.Errorf("error getting default azure credential: %v", err)
}

client, err := azblob.NewClient(url, cred, nil)
if err != nil {
// Check if error is due to storage account not existing
if strings.Contains(err.Error(), "not exist") ||
strings.Contains(err.Error(), "no such host") ||
strings.Contains(err.Error(), "dial tcp") {
return nil, errors.Errorf("storage account %s does not exist or is not accessible: %w",
storageAccountName, err)
}

return nil, errors.Errorf("error creating blob client with default credential: %w", err)
}

// Check if we can access the service endpoint to verify the storage account exists and is accessible
// Try to get properties of a non-existent container to test connectivity
testContainerName := "terragrunt-connectivity-test"
testContainer := client.ServiceClient().NewContainerClient(testContainerName)
_, err = testContainer.GetProperties(ctx, nil)

if err != nil {
var respErr *azcore.ResponseError

switch {
case errors.As(err, &respErr) && respErr.ErrorCode == "ContainerNotFound":
// This is actually good - it means we reached the storage account but the container doesn't exist
l.Infof("Successfully verified storage account %s exists and is accessible", storageAccountName)
case errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound:
// 404 can mean either the storage account doesn't exist or the container doesn't exist
// Check the error code to differentiate
if respErr.ErrorCode == "StorageAccountNotFound" || respErr.ErrorCode == "AccountNotFound" {
return nil, errors.Errorf("storage account %s does not exist (HTTP %d: %s)",
storageAccountName, respErr.StatusCode, respErr.ErrorCode)
}
// If it's just ContainerNotFound, that's actually expected and good
l.Infof("Successfully verified storage account %s exists (container not found as expected)", storageAccountName)
case errors.As(err, &respErr) && respErr.StatusCode == http.StatusForbidden:
return nil, errors.Errorf("access denied to storage account %s: insufficient permissions (HTTP %d: %s). "+
"Ensure you have 'Storage Blob Data Reader' or higher role assigned",
storageAccountName, respErr.StatusCode, respErr.ErrorCode)
case errors.As(err, &respErr) && respErr.StatusCode == http.StatusUnauthorized:
return nil, errors.Errorf("authentication failed for storage account %s (HTTP %d: %s). "+
"Check your Azure credentials and ensure they are valid",
storageAccountName, respErr.StatusCode, respErr.ErrorCode)
case errors.As(err, &respErr) && respErr.StatusCode >= 500:
return nil, errors.Errorf("Azure service error when accessing storage account %s (HTTP %d: %s). "+
"This may be a temporary issue, please try again",
storageAccountName, respErr.StatusCode, respErr.ErrorCode)
case errors.As(err, &respErr):
// Other Azure response errors
return nil, errors.Errorf("unexpected Azure API error when verifying storage account %s "+
"(HTTP %d: %s): %w", storageAccountName, respErr.StatusCode, respErr.ErrorCode, err)
default:
// For non-Azure errors, check if it's a connectivity issue which suggests the storage account doesn't exist
errMsg := err.Error()
if strings.Contains(errMsg, "no such host") ||
strings.Contains(errMsg, "dial tcp") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "connection timeout") {
return nil, errors.Errorf("storage account %s does not exist or is not accessible "+
"(network error): %w", storageAccountName, err)
}
// For other errors, return a specific error message with context
return nil, errors.Errorf("unexpected error verifying access to storage account %s: %w",
storageAccountName, err)
}
}

return &BlobServiceClient{
client: client,
config: config,
}, nil
}

// GetObject downloads a blob from Azure Storage.
func (c *BlobServiceClient) GetObject(ctx context.Context, input *GetObjectInput) (*GetObjectOutput, error) {
if input.Container == nil || *input.Container == "" {
return nil, errors.Errorf("container name is required")
}

if input.Key == nil || *input.Key == "" {
return nil, errors.Errorf("blob key is required")
}

downloaded, err := c.client.DownloadStream(ctx, *input.Container, *input.Key, nil)
if err != nil {
var respErr *azcore.ResponseError
if errors.As(err, &respErr) && respErr.ErrorCode == "BlobNotFound" {
return nil, errors.Errorf("blob not found: %w", err)
}

return nil, errors.Errorf("error downloading blob: %w", err)
}

return &GetObjectOutput{
Body: downloaded.Body,
}, nil
}

// ContainerExists checks if a container exists.
func (c *BlobServiceClient) ContainerExists(ctx context.Context, containerName string) (bool, error) {
if containerName == "" {
return false, errors.Errorf("container name is required")
}

container := c.client.ServiceClient().NewContainerClient(containerName)
_, err := container.GetProperties(ctx, nil)

if err != nil {
var respErr *azcore.ResponseError
if errors.As(err, &respErr) {
if respErr.ErrorCode == "ContainerNotFound" {
return false, nil
}

if respErr.StatusCode == http.StatusUnauthorized || respErr.StatusCode == http.StatusForbidden {
return false, errors.Errorf("authentication failed: %w", err)
}
}

return false, errors.Errorf("error checking container existence: %w", err)
}

return true, nil
}

// CreateContainerIfNecessary creates a container if it doesn't exist.
func (c *BlobServiceClient) CreateContainerIfNecessary(ctx context.Context, l log.Logger, containerName string) error {
exists, err := c.ContainerExists(ctx, containerName)
if err != nil {
return err
}

if !exists {
l.Infof("Creating Azure Storage container %s", containerName)
_, err = c.client.CreateContainer(ctx, containerName, nil)

if err != nil {
return ContainerCreationError{
Underlying: err,
ContainerName: containerName,
}
}
}

return nil
}

// DeleteBlobIfNecessary deletes a blob if it exists.
func (c *BlobServiceClient) DeleteBlobIfNecessary(ctx context.Context, l log.Logger, containerName string, blobName string) error {
_, err := c.client.DeleteBlob(ctx, containerName, blobName, nil)
if err != nil {
var respErr *azcore.ResponseError
if errors.As(err, &respErr) && respErr.ErrorCode == "BlobNotFound" {
return nil
}

return errors.Errorf("error deleting blob: %w", err)
}

return nil
}

// DeleteContainer deletes a container and all its contents.
func (c *BlobServiceClient) DeleteContainer(ctx context.Context, l log.Logger, containerName string) error {
if containerName == "" {
return errors.Errorf("container name is required")
}

container := c.client.ServiceClient().NewContainerClient(containerName)
_, err := container.Delete(ctx, nil)

if err != nil {
var respErr *azcore.ResponseError
if errors.As(err, &respErr) && respErr.ErrorCode == "ContainerNotFound" {
return nil
}

return errors.Errorf("failed to delete Azure container %s: %w", containerName, err)
}

return nil
}

// UploadBlob uploads a blob with the given data.
func (c *BlobServiceClient) UploadBlob(ctx context.Context, l log.Logger, containerName, blobName string, data []byte) error {
if containerName == "" || blobName == "" {
return errors.Errorf("container name and blob key are required")
}

container := c.client.ServiceClient().NewContainerClient(containerName)
blockBlob := container.NewBlockBlobClient(blobName)

_, err := blockBlob.UploadBuffer(ctx, data, nil)
if err != nil {
return errors.Errorf("error uploading blob: %w", err)
}

return nil
}

// CopyBlobToContainer copies a blob from one container to another, potentially across storage accounts.
func (c *BlobServiceClient) CopyBlobToContainer(ctx context.Context, srcContainer, srcKey string, dstClient *BlobServiceClient,
dstContainer, dstKey string) error {
if srcContainer == "" || srcKey == "" || dstContainer == "" || dstKey == "" {
return errors.Errorf("container names and blob keys are required")
}

// Get source blob data
input := &GetObjectInput{
Container: &srcContainer,
Key: &srcKey,
}

srcBlobOutput, err := c.GetObject(ctx, input)
if err != nil {
return fmt.Errorf("error reading source blob: %w", err)
}

defer func() {
if closeErr := srcBlobOutput.Body.Close(); closeErr != nil {
err = fmt.Errorf("failed to close blob: %w (original error: %w)", closeErr, err)
}
}()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix error handling in defer block.

The defer block attempts to modify the error return value, but this doesn't work without named return values.

Either add named return values or handle the close error differently:

-func (c *BlobServiceClient) CopyBlobToContainer(ctx context.Context, srcContainer, srcKey string, dstClient *BlobServiceClient,
-	dstContainer, dstKey string) error {
+func (c *BlobServiceClient) CopyBlobToContainer(ctx context.Context, srcContainer, srcKey string, dstClient *BlobServiceClient,
+	dstContainer, dstKey string) (err error) {

Or log the close error instead:

 defer func() {
 	if closeErr := srcBlobOutput.Body.Close(); closeErr != nil {
-		err = fmt.Errorf("failed to close blob: %w (original error: %w)", closeErr, err)
+		// Log the error since we can't return it
+		log.Warnf("Failed to close blob body: %v", closeErr)
 	}
 }()

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In azurehelper/azure_blob.go around lines 334 to 338, the defer block tries to
assign a new error to the named return variable 'err', but the function does not
have named return values, so this assignment has no effect. To fix this, either
add named return values to the function signature so the defer can modify 'err',
or change the defer block to handle the close error differently, such as logging
the close error instead of assigning it to 'err'.


// Read the blob content
blobData, err := io.ReadAll(srcBlobOutput.Body)
if err != nil {
return fmt.Errorf("error reading blob data: %w", err)
}

// Upload to the destination
if err := dstClient.UploadBlob(ctx, nil, dstContainer, dstKey, blobData); err != nil {
return fmt.Errorf("error copying blob to destination: %w", err)
}

return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Two concerns with the CopyBlobToContainer implementation.

  1. Line 365 passes nil as the logger parameter to UploadBlob, which expects a log.Logger. This could cause a panic.
  2. Reading the entire blob into memory (line 359) could cause issues with large files.
-	if err := dstClient.UploadBlob(ctx, nil, dstContainer, dstKey, blobData); err != nil {
+	// Get logger from context or create a default one
+	l := log.Default()
+	if err := dstClient.UploadBlob(ctx, l, dstContainer, dstKey, blobData); err != nil {

Consider implementing streaming copy for large files to avoid memory issues.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// CopyBlobToContainer copies a blob from one container to another, potentially across storage accounts.
func (c *BlobServiceClient) CopyBlobToContainer(ctx context.Context, srcContainer, srcKey string, dstClient *BlobServiceClient,
dstContainer, dstKey string) error {
if srcContainer == "" || srcKey == "" || dstContainer == "" || dstKey == "" {
return errors.Errorf("container names and blob keys are required")
}
// Get source blob data
input := &GetObjectInput{
Container: &srcContainer,
Key: &srcKey,
}
srcBlobOutput, err := c.GetObject(ctx, input)
if err != nil {
return fmt.Errorf("error reading source blob: %w", err)
}
defer func() {
if closeErr := srcBlobOutput.Body.Close(); closeErr != nil {
err = fmt.Errorf("failed to close blob: %w (original error: %w)", closeErr, err)
}
}()
// Read the blob content
blobData, err := io.ReadAll(srcBlobOutput.Body)
if err != nil {
return fmt.Errorf("error reading blob data: %w", err)
}
// Upload to the destination
if err := dstClient.UploadBlob(ctx, nil, dstContainer, dstKey, blobData); err != nil {
return fmt.Errorf("error copying blob to destination: %w", err)
}
return nil
}
func (c *BlobServiceClient) CopyBlobToContainer(ctx context.Context, srcContainer, srcKey string, dstClient *BlobServiceClient,
dstContainer, dstKey string) error {
if srcContainer == "" || srcKey == "" || dstContainer == "" || dstKey == "" {
return errors.Errorf("container names and blob keys are required")
}
// Get source blob data
input := &GetObjectInput{
Container: &srcContainer,
Key: &srcKey,
}
srcBlobOutput, err := c.GetObject(ctx, input)
if err != nil {
return fmt.Errorf("error reading source blob: %w", err)
}
defer func() {
if closeErr := srcBlobOutput.Body.Close(); closeErr != nil {
err = fmt.Errorf("failed to close blob: %w (original error: %w)", closeErr, err)
}
}()
// Read the blob content
blobData, err := io.ReadAll(srcBlobOutput.Body)
if err != nil {
return fmt.Errorf("error reading blob data: %w", err)
}
// Upload to the destination
// Get logger from context or create a default one
l := log.Default()
if err := dstClient.UploadBlob(ctx, l, dstContainer, dstKey, blobData); err != nil {
return fmt.Errorf("error copying blob to destination: %w", err)
}
return nil
}
🤖 Prompt for AI Agents
In azurehelper/azure_blob.go between lines 334 and 370, the CopyBlobToContainer
function passes nil as the logger argument to UploadBlob, which expects a valid
log.Logger and may cause a panic. Also, it reads the entire blob into memory,
which is problematic for large files. To fix this, pass a proper logger instance
instead of nil to UploadBlob, and refactor the code to stream the blob data
directly from the source to the destination without fully loading it into
memory, using io.Pipe or similar streaming techniques.


// ContainerCreationError wraps errors that occur during Azure container operations.
type ContainerCreationError struct {
Underlying error // 8 bytes (interface)
ContainerName string // 16 bytes (string)
}

// Error returns a string indicating that container operation failed.
func (err ContainerCreationError) Error() string {
return fmt.Sprintf("error with container %s: %v", err.ContainerName, err.Underlying)
}

// Unwrap returns the underlying error that caused the container operation to fail.
func (err ContainerCreationError) Unwrap() error {
return err.Underlying
}
Loading