Przejdź do treści

☁️ Shell in Terraform

Integrating shell scripts with Terraform for enhanced automation capabilities while maintaining Infrastructure as Code benefits.


🎯 Terraform Shell Integration Methods

Local-Exec Provisioner

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# main.tf - Local execution on Terraform host
resource "aws_instance" "web" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"

  tags = {
    Name = "WebServer"
  }

  # Execute shell script locally after resource creation
  provisioner "local-exec" {
    command = <<EOT
      #!/bin/bash
      set -e

      echo "Configuring local environment for new instance..."

      # Wait for SSH to be available
      until ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ubuntu@${self.public_ip} 'echo ready'; do
        echo "Waiting for SSH..."
        sleep 10
      done

      # Upload configuration files
      scp -o StrictHostKeyChecking=no config/web.conf ubuntu@${self.public_ip}:/tmp/

      # Run remote configuration
      ssh -o StrictHostKeyChecking=no ubuntu@${self.public_ip} << 'REMOTE_EOF'
        sudo mv /tmp/web.conf /etc/nginx/sites-available/default
        sudo systemctl restart nginx
        sudo systemctl status nginx
      REMOTE_EOF

      echo "Instance configuration completed"
    EOT
  }
}

Remote-Exec Provisioner

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# main.tf - Remote execution on created resources
resource "aws_instance" "app" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.medium"

  # Remote execution on the instance
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y docker.io",
      "sudo systemctl start docker",
      "sudo systemctl enable docker",
      "sudo usermod -aG docker ubuntu"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }

  # File provisioner to upload shell scripts
  provisioner "file" {
    source      = "scripts/setup-app.sh"
    destination = "/tmp/setup-app.sh"

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }

  # Execute uploaded script
  provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/setup-app.sh",
      "sudo /tmp/setup-app.sh"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }
}

🛠️ Advanced Shell Integration Patterns

Dynamic Configuration with External Data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# data.tf - External data sources with shell scripts
data "external" "system_info" {
  program = ["bash", "-c", <<EOT
    #!/bin/bash
    set -e

    # Gather system information
    echo "{"
    echo "  \"hostname\": \"$(hostname)\","
    echo "  \"os\": \"$(uname -s)\","
    echo "  \"arch\": \"$(uname -m)\","
    echo "  \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
    echo "  \"user\": \"$(whoami)\""
    echo "}"
  EOT
  ]
}

# Use external data in resources
resource "aws_instance" "dynamic" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"

  tags = {
    Name      = "DynamicInstance-${data.external.system_info.result.hostname}"
    CreatedBy = data.external.system_info.result.user
    CreatedAt = data.external.system_info.result.timestamp
  }
}

Conditional Provisioning

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# conditional.tf - Conditional shell execution
variable "environment" {
  type    = string
  default = "development"
}

resource "aws_instance" "conditional" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"

  # Conditional provisioning based on environment
  provisioner "local-exec" {
    when    = create
    command = <<EOT
      #!/bin/bash
      set -e

      case "${var.environment}" in
        production)
          echo "Running production setup..."
          # Production-specific configuration
          ./scripts/production-setup.sh
          ;;
        staging)
          echo "Running staging setup..."
          # Staging-specific configuration
          ./scripts/staging-setup.sh
          ;;
        development)
          echo "Running development setup..."
          # Development-specific configuration
          ./scripts/development-setup.sh
          ;;
        *)
          echo "Unknown environment: ${var.environment}" >&2
          exit 1
          ;;
      esac
    EOT
  }
}

Error Handling and Retry Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# resilient.tf - Robust shell execution with error handling
resource "null_resource" "resilient_provision" {
  triggers = {
    instance_id = aws_instance.web.id
  }

  provisioner "local-exec" {
    command = <<EOT
      #!/bin/bash
      set -e

      # Retry logic with exponential backoff
      retry_command() {
        local cmd="$1"
        local max_attempts=${2:-5}
        local attempt=1

        while [ $attempt -le $max_attempts ]; do
          echo "Attempt $attempt/$max_attempts: $cmd"

          if eval "$cmd"; then
            echo "Command succeeded"
            return 0
          else
            local exit_code=$?
            echo "Command failed with exit code $exit_code"

            if [ $attempt -eq $max_attempts ]; then
              echo "Max attempts reached, giving up"
              return $exit_code
            fi

            # Exponential backoff
            local delay=$((2 ** attempt))
            echo "Waiting $delay seconds before retry..."
            sleep $delay

            attempt=$((attempt + 1))
          fi
        done
      }

      # Retry critical operations
      retry_command "aws ec2 wait instance-status-ok --instance-ids ${aws_instance.web.id}"
      retry_command "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no ubuntu@${aws_instance.web.public_ip} 'echo ready'"

      # Main provisioning logic
      echo "Starting provisioning..."
      ssh -o StrictHostKeyChecking=no ubuntu@${aws_instance.web.public_ip} << 'REMOTE_EOF'
        # Install and configure application
        sudo apt-get update
        sudo apt-get install -y nginx
        sudo systemctl start nginx
        sudo systemctl enable nginx

        # Verify installation
        if systemctl is-active --quiet nginx; then
          echo "Nginx installed and running successfully"
        else
          echo "Nginx installation failed" >&2
          exit 1
        fi
      REMOTE_EOF

      echo "Provisioning completed successfully"
    EOT
  }
}

📋 Shell Script Best Practices in Terraform

Secure Script Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# secure-scripts.tf - Secure shell script management
resource "local_file" "secure_script" {
  content = <<EOT
#!/bin/bash
# secure-deployment.sh - Secure deployment script

set -euo pipefail

# Use secure temporary files
TEMP_DIR=$(mktemp -d) || {
  echo "Failed to create temporary directory" >&2
  exit 1
}

# Clean up on exit
trap 'rm -rf "$TEMP_DIR"' EXIT

# Validate inputs
validate_required() {
  local var_name="$1"
  local var_value="${!var_name:-}"

  if [ -z "$var_value" ]; then
    echo "Error: Required variable $var_name is not set" >&2
    return 1
  fi
}

# Secure credential handling
if [ -f "/run/secrets/deployment-key" ]; then
  DEPLOYMENT_KEY=$(cat /run/secrets/deployment-key)
else
  echo "Error: Deployment key not found" >&2
  exit 1
fi

# Main deployment logic
deploy_application() {
  echo "Deploying application with secure credentials..."

  # Use credentials securely
  export DEPLOYMENT_KEY
  ./deploy-app --key-file=/dev/stdin <<< "$DEPLOYMENT_KEY"

  # Verify deployment
  if ./verify-deployment; then
    echo "Deployment successful"
  else
    echo "Deployment verification failed" >&2
    return 1
  fi
}

# Execute deployment
deploy_application
EOT

  filename = "${path.module}/scripts/secure-deployment.sh"
  file_permission = "0755"
}

resource "null_resource" "secure_deployment" {
  depends_on = [local_file.secure_script]

  provisioner "local-exec" {
    command = local_file.secure_script.filename
  }
}

Environment-Aware Scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# environment-scripts.tf - Environment-specific shell integration
locals {
  environment_configs = {
    development = {
      script = "scripts/dev-setup.sh"
      vars = {
        LOG_LEVEL = "debug"
        DB_HOST = "localhost"
      }
    }
    production = {
      script = "scripts/prod-setup.sh"
      vars = {
        LOG_LEVEL = "info"
        DB_HOST = "prod-db.cluster.amazonaws.com"
      }
    }
  }

  current_config = local.environment_configs[var.environment]
}

resource "null_resource" "environment_setup" {
  provisioner "local-exec" {
    command = local.current_config.script

    environment = merge(
      local.current_config.vars,
      {
        ENVIRONMENT = var.environment
        TIMESTAMP = timestamp()
      }
    )
  }
}

🎯 Real-World Examples

Example 1: CI/CD Integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# cicd-integration.tf - CI/CD pipeline with shell scripts
resource "null_resource" "ci_cd_pipeline" {
  triggers = {
    commit_sha = var.commit_sha
  }

  provisioner "local-exec" {
    command = <<EOT
      #!/bin/bash
      set -e

      # CI/CD pipeline with shell scripts
      echo "Starting CI/CD pipeline for commit ${var.commit_sha}"

      # 1. Code checkout
      git clone ${var.repo_url} /tmp/build-${var.commit_sha}
      cd /tmp/build-${var.commit_sha}
      git checkout ${var.commit_sha}

      # 2. Build application
      ./scripts/build.sh

      # 3. Run tests
      ./scripts/test.sh

      # 4. Security scanning
      ./scripts/security-scan.sh

      # 5. Deploy to staging
      if [ "${var.environment}" = "staging" ]; then
        ./scripts/deploy-staging.sh
      fi

      # 6. Deploy to production (manual approval)
      if [ "${var.environment}" = "production" ]; then
        echo "Ready for production deployment"
        # This would typically be handled by a separate approval process
      fi

      echo "CI/CD pipeline completed successfully"
    EOT
  }
}

Example 2: Multi-Stage Provisioning

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# multi-stage.tf - Multi-stage provisioning with shell
resource "aws_instance" "multi_stage" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.medium"

  # Stage 1: Base OS setup
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y curl wget git",
      "./scripts/base-setup.sh"
    ]
  }

  # Stage 2: Application installation
  provisioner "remote-exec" {
    inline = [
      "./scripts/install-app.sh",
      "sudo systemctl enable myapp"
    ]
  }

  # Stage 3: Configuration and validation
  provisioner "remote-exec" {
    inline = [
      "./scripts/configure-app.sh",
      "./scripts/validate-app.sh"
    ]
  }

  # Stage 4: Health check and reporting
  provisioner "local-exec" {
    command = <<EOT
      #!/bin/bash
      set -e

      # Wait for application to be ready
      timeout 300 bash -c 'until curl -sf http://${self.public_ip}:8080/health; do sleep 5; done'

      # Send notification
      curl -X POST ${var.slack_webhook} \
        -H 'Content-Type: application/json' \
        -d '{
          "text": "Instance ${self.id} deployed successfully",
          "attachments": [
            {
              "color": "good",
              "fields": [
                {"title": "Instance ID", "value": "${self.id}", "short": true},
                {"title": "Public IP", "value": "${self.public_ip}", "short": true}
              ]
            }
          ]
        }'
    EOT
  }
}

🧪 Testing and Validation

Unit Testing Shell Scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#!/bin/bash
# test-terraform-scripts.sh - Test framework for Terraform shell scripts

# Mock Terraform environment
setup_test_environment() {
  TEST_DIR=$(mktemp -d)
  cd "$TEST_DIR"

  # Create mock scripts directory
  mkdir -p scripts

  # Mock Terraform variables
  export TF_VAR_region="us-west-2"
  export TF_VAR_environment="test"
}

# Test script execution
test_script_execution() {
  local script_path="$1"
  local expected_exit_code="${2:-0}"

  echo "Testing script: $script_path"

  # Execute script
  if [ -f "$script_path" ]; then
    local exit_code=0
    "$script_path" || exit_code=$?

    if [ "$exit_code" -eq "$expected_exit_code" ]; then
      echo "✅ Script execution test passed"
    else
      echo "❌ Script execution failed with exit code $exit_code (expected $expected_exit_code)" >&2
      return 1
    fi
  else
    echo "❌ Script not found: $script_path" >&2
    return 1
  fi
}

# Test environment variable handling
test_environment_variables() {
  local script_path="$1"
  local required_vars=("$@")

  echo "Testing environment variable handling for: $script_path"

  # Test with missing variables
  for var in "${required_vars[@]}"; do
    # Temporarily unset variable
    local original_value="${!var}"
    unset "$var"

    # Test script behavior
    if "$script_path" >/dev/null 2>&1; then
      echo "❌ Script should fail when $var is missing" >&2
      return 1
    fi

    # Restore variable
    if [ -n "$original_value" ]; then
      export "$var"="$original_value"
    fi
  done

  echo "✅ Environment variable handling test passed"
}

# Run all tests
run_all_tests() {
  setup_test_environment

  # Test individual scripts
  test_script_execution "scripts/base-setup.sh"
  test_script_execution "scripts/install-app.sh"
  test_script_execution "scripts/configure-app.sh"

  # Test environment variable handling
  test_environment_variables "scripts/configure-app.sh" "DB_HOST" "DB_PASSWORD"

  echo "All tests completed"
}

# Execute tests
run_all_tests

🧾 Summary

Local-exec: Execute scripts on Terraform host ✅ Remote-exec: Execute scripts on remote resources ✅ External data: Dynamic configuration with shell scripts ✅ Conditional logic: Environment-specific provisioning ✅ Error handling: Robust retry and failure management ✅ Security: Secure credential and temporary file handling ✅ Testing: Comprehensive test frameworks for shell scripts


🧾 See Also