437 lines
13 KiB
Plaintext
437 lines
13 KiB
Plaintext
|
|
#!/usr/bin/env nu
|
|||
|
|
|
|||
|
|
# Multi-Provider Web App Deployment Script
|
|||
|
|
# Orchestrates deployment across DigitalOcean, AWS, and Hetzner
|
|||
|
|
|
|||
|
|
def main [--debug: bool = false] {
|
|||
|
|
print "🚀 Multi-Provider Web App Deployment"
|
|||
|
|
print "────────────────────────────────────────"
|
|||
|
|
|
|||
|
|
# Enable debug output if requested
|
|||
|
|
if $debug {
|
|||
|
|
print "✓ Debug mode enabled"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Step 1: Validate configuration
|
|||
|
|
print "\n📋 Step 1: Validating configuration..."
|
|||
|
|
validate_environment
|
|||
|
|
|
|||
|
|
# Step 2: Create networks first
|
|||
|
|
print "\n🌐 Step 2: Creating private networks..."
|
|||
|
|
create_digitalocean_vpc
|
|||
|
|
create_aws_vpc
|
|||
|
|
create_hetzner_network
|
|||
|
|
|
|||
|
|
# Step 3: Create provider resources
|
|||
|
|
print "\n☁️ Step 3: Creating compute and database resources..."
|
|||
|
|
create_hetzner_resources
|
|||
|
|
create_aws_resources
|
|||
|
|
create_digitalocean_resources
|
|||
|
|
|
|||
|
|
# Step 4: Setup VPN tunnels
|
|||
|
|
print "\n🔐 Step 4: Setting up VPN tunnels for secure communication..."
|
|||
|
|
setup_vpn_do_to_aws
|
|||
|
|
setup_vpn_aws_to_hetzner
|
|||
|
|
|
|||
|
|
# Step 5: Configure security
|
|||
|
|
print "\n🔒 Step 5: Configuring security..."
|
|||
|
|
setup_firewall_rules
|
|||
|
|
setup_security_groups
|
|||
|
|
|
|||
|
|
# Step 6: Deploy application
|
|||
|
|
print "\n📦 Step 6: Deploying application..."
|
|||
|
|
deploy_application
|
|||
|
|
|
|||
|
|
# Step 7: Verify deployment
|
|||
|
|
print "\n✅ Step 7: Verifying deployment..."
|
|||
|
|
verify_deployment
|
|||
|
|
|
|||
|
|
print "\n🎉 Deployment complete!"
|
|||
|
|
print "Your application is now live on three cloud providers."
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def validate_environment [] {
|
|||
|
|
# Check required environment variables
|
|||
|
|
let required = [
|
|||
|
|
"DIGITALOCEAN_TOKEN",
|
|||
|
|
"AWS_ACCESS_KEY_ID",
|
|||
|
|
"AWS_SECRET_ACCESS_KEY",
|
|||
|
|
"HCLOUD_TOKEN"
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
$required | each {|var|
|
|||
|
|
if ($env | has $var) {
|
|||
|
|
print $" ✓ ($var) is set"
|
|||
|
|
} else {
|
|||
|
|
print $" ✗ ($var) is not set"
|
|||
|
|
error make {msg: $"Missing required environment variable: ($var)"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Verify CLI tools
|
|||
|
|
let tools = ["doctl", "aws", "hcloud", "nickel"]
|
|||
|
|
$tools | each {|tool|
|
|||
|
|
if (which $tool | is-not-empty) {
|
|||
|
|
print $" ✓ ($tool) is installed"
|
|||
|
|
} else {
|
|||
|
|
print $" ✗ ($tool) is not installed"
|
|||
|
|
error make {msg: $"Missing required tool: ($tool)"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Validate Nickel configuration
|
|||
|
|
print " Validating Nickel configuration..."
|
|||
|
|
try {
|
|||
|
|
nickel export workspace.ncl | from json | null
|
|||
|
|
print " ✓ Nickel configuration is valid"
|
|||
|
|
} catch {|err|
|
|||
|
|
error make {msg: $"Nickel validation failed: ($err)"}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Validate config.toml
|
|||
|
|
print " Validating config.toml..."
|
|||
|
|
try {
|
|||
|
|
let config = (open config.toml)
|
|||
|
|
print " ✓ config.toml is valid"
|
|||
|
|
} catch {|err|
|
|||
|
|
error make {msg: $"config.toml validation failed: ($err)"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_hetzner_network [] {
|
|||
|
|
print " Creating Hetzner private network..."
|
|||
|
|
|
|||
|
|
let network = (hcloud network create \
|
|||
|
|
--name "backup-network" \
|
|||
|
|
--ip-range "10.2.0.0/16" \
|
|||
|
|
--format "json" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created network: ($network.network.id)"
|
|||
|
|
|
|||
|
|
# Create subnet
|
|||
|
|
hcloud network add-subnet backup-network \
|
|||
|
|
--ip-range "10.2.1.0/24" \
|
|||
|
|
--network-zone "eu-central"
|
|||
|
|
|
|||
|
|
print " ✓ Created subnet: 10.2.1.0/24"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_hetzner_resources [] {
|
|||
|
|
print " Creating Hetzner backup volume..."
|
|||
|
|
|
|||
|
|
# Create volume attached to network
|
|||
|
|
let volume = (hcloud volume create \
|
|||
|
|
--name "webapp-backups" \
|
|||
|
|
--size 500 \
|
|||
|
|
--location "nbg1" \
|
|||
|
|
--format "ext4" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created volume: ($volume.id)"
|
|||
|
|
|
|||
|
|
# Wait for volume to be ready
|
|||
|
|
let max_wait = 60
|
|||
|
|
mut attempts = 0
|
|||
|
|
|
|||
|
|
while $attempts < $max_wait {
|
|||
|
|
let status = (hcloud volume describe "webapp-backups" --output json | from json)
|
|||
|
|
|
|||
|
|
if $status.status == "available" {
|
|||
|
|
print " ✓ Volume is available"
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sleep 1sec
|
|||
|
|
$attempts = ($attempts + 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if $attempts >= $max_wait {
|
|||
|
|
error make {msg: "Hetzner volume failed to become available"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_aws_vpc [] {
|
|||
|
|
print " Creating AWS VPC..."
|
|||
|
|
|
|||
|
|
let vpc = (aws ec2 create-vpc \
|
|||
|
|
--cidr-block "10.1.0.0/16" \
|
|||
|
|
--tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=webapp-vpc}]" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created VPC: ($vpc.Vpc.VpcId)"
|
|||
|
|
|
|||
|
|
# Create private subnet for database
|
|||
|
|
let subnet = (aws ec2 create-subnet \
|
|||
|
|
--vpc-id $vpc.Vpc.VpcId \
|
|||
|
|
--cidr-block "10.1.1.0/24" \
|
|||
|
|
--availability-zone "us-east-1a" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created private subnet: ($subnet.Subnet.SubnetId)"
|
|||
|
|
|
|||
|
|
# Create security group for RDS
|
|||
|
|
let sg = (aws ec2 create-security-group \
|
|||
|
|
--group-name "webapp-db-sg" \
|
|||
|
|
--description "Security group for RDS database" \
|
|||
|
|
--vpc-id $vpc.Vpc.VpcId | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created security group: ($sg.GroupId)"
|
|||
|
|
|
|||
|
|
# Allow PostgreSQL from DO VPC (10.0.0.0/16)
|
|||
|
|
aws ec2 authorize-security-group-ingress \
|
|||
|
|
--group-id $sg.GroupId \
|
|||
|
|
--protocol tcp \
|
|||
|
|
--port 5432 \
|
|||
|
|
--cidr "10.0.0.0/16"
|
|||
|
|
|
|||
|
|
print " ✓ Allowed PostgreSQL from DO network (10.0.0.0/16)"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_aws_resources [] {
|
|||
|
|
print " Creating AWS RDS PostgreSQL database..."
|
|||
|
|
|
|||
|
|
# Create RDS instance in private subnet
|
|||
|
|
let db_response = (aws rds create-db-instance \
|
|||
|
|
--db-instance-identifier "webapp-db" \
|
|||
|
|
--db-instance-class "db.t3.medium" \
|
|||
|
|
--engine "postgres" \
|
|||
|
|
--engine-version "14.6" \
|
|||
|
|
--master-username "admin" \
|
|||
|
|
--master-user-password "YourSecurePassword123!" \
|
|||
|
|
--allocated-storage 100 \
|
|||
|
|
--storage-type "gp3" \
|
|||
|
|
--multi-az \
|
|||
|
|
--db-subnet-group-name "default" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created RDS instance: ($db_response.DBInstance.DBInstanceIdentifier)"
|
|||
|
|
|
|||
|
|
# Wait for database to be available
|
|||
|
|
print " Waiting for database to be available (this may take 10-15 minutes)..."
|
|||
|
|
let max_wait = 900 # 15 minutes
|
|||
|
|
|
|||
|
|
mut attempts = 0
|
|||
|
|
while $attempts < $max_wait {
|
|||
|
|
try {
|
|||
|
|
let db_info = (aws rds describe-db-instances \
|
|||
|
|
--db-instance-identifier "webapp-db" | from json)
|
|||
|
|
|
|||
|
|
if ($db_info.DBInstances | first).DBInstanceStatus == "available" {
|
|||
|
|
print " ✓ Database is available"
|
|||
|
|
let endpoint = ($db_info.DBInstances | first).Endpoint.Address
|
|||
|
|
print $" ✓ Database endpoint: ($endpoint)"
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
# Still creating
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sleep 10sec
|
|||
|
|
$attempts = ($attempts + 10)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if $attempts >= $max_wait {
|
|||
|
|
error make {msg: "AWS RDS failed to become available"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_digitalocean_vpc [] {
|
|||
|
|
print " Creating DigitalOcean VPC..."
|
|||
|
|
|
|||
|
|
let vpc = (doctl compute vpc create \
|
|||
|
|
--name "web-app-vpc" \
|
|||
|
|
--region "nyc3" \
|
|||
|
|
--ip-range "10.0.0.0/16" \
|
|||
|
|
--format ID \
|
|||
|
|
--no-header)
|
|||
|
|
|
|||
|
|
print $" ✓ Created VPC: ($vpc)"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def create_digitalocean_resources [] {
|
|||
|
|
print " Creating DigitalOcean droplets and load balancer..."
|
|||
|
|
|
|||
|
|
# Create droplets
|
|||
|
|
let ssh_keys = (doctl compute ssh-key list --no-header --format ID)
|
|||
|
|
|
|||
|
|
if ($ssh_keys | is-empty) {
|
|||
|
|
error make {msg: "No SSH keys found in DigitalOcean. Please upload one first."}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let ssh_key_id = ($ssh_keys | first)
|
|||
|
|
|
|||
|
|
# Create 3 web server droplets
|
|||
|
|
let droplet_ids = (
|
|||
|
|
1..3 | each {|i|
|
|||
|
|
let response = (doctl compute droplet create \
|
|||
|
|
$"web-server-($i)" \
|
|||
|
|
--region "nyc3" \
|
|||
|
|
--size "s-2vcpu-4gb" \
|
|||
|
|
--image "ubuntu-22-04-x64" \
|
|||
|
|
--ssh-keys $ssh_key_id \
|
|||
|
|
--enable-monitoring \
|
|||
|
|
--enable-backups \
|
|||
|
|
--format ID \
|
|||
|
|
--no-header | into string)
|
|||
|
|
|
|||
|
|
print $" ✓ Created droplet: web-server-($i)"
|
|||
|
|
$response
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Wait for droplets to be ready
|
|||
|
|
print " Waiting for droplets to be ready..."
|
|||
|
|
sleep 30sec
|
|||
|
|
|
|||
|
|
# Verify droplets are running
|
|||
|
|
$droplet_ids | each {|id|
|
|||
|
|
let droplet = (doctl compute droplet get $id --format Status --no-header)
|
|||
|
|
if $droplet != "active" {
|
|||
|
|
error make {msg: $"Droplet ($id) failed to start"}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
print " ✓ All droplets are active"
|
|||
|
|
|
|||
|
|
# Create load balancer
|
|||
|
|
print " Creating DigitalOcean load balancer..."
|
|||
|
|
let lb = (doctl compute load-balancer create \
|
|||
|
|
--name "web-lb" \
|
|||
|
|
--region "nyc3" \
|
|||
|
|
--forwarding-rules "entry_protocol:http,entry_port:80,target_protocol:http,target_port:80" \
|
|||
|
|
--format ID \
|
|||
|
|
--no-header | into string)
|
|||
|
|
|
|||
|
|
print $" ✓ Created load balancer: ($lb)"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def setup_vpn_do_to_aws [] {
|
|||
|
|
print " Setting up VPN tunnel: DigitalOcean → AWS..."
|
|||
|
|
|
|||
|
|
# Create VPN Gateway in AWS
|
|||
|
|
let vgw = (aws ec2 create-vpn-gateway \
|
|||
|
|
--type "ipsec.1" \
|
|||
|
|
--tag-specifications "ResourceType=vpn-gateway,Tags=[{Key=Name,Value=webapp-vpn-gw}]" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created VPN Gateway: ($vgw.VpnGateway.VpnGatewayId)"
|
|||
|
|
|
|||
|
|
# Create Customer Gateway (DigitalOcean endpoint - use placeholder)
|
|||
|
|
let cgw = (aws ec2 create-customer-gateway \
|
|||
|
|
--type "ipsec.1" \
|
|||
|
|
--public-ip "203.0.113.12" \
|
|||
|
|
--tag-specifications "ResourceType=customer-gateway,Tags=[{Key=Name,Value=do-vpn-endpoint}]" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created Customer Gateway: ($cgw.CustomerGateway.CustomerGatewayId)"
|
|||
|
|
|
|||
|
|
# Create VPN Connection
|
|||
|
|
let vpn = (aws ec2 create-vpn-connection \
|
|||
|
|
--type "ipsec.1" \
|
|||
|
|
--customer-gateway-id $cgw.CustomerGateway.CustomerGatewayId \
|
|||
|
|
--vpn-gateway-id $vgw.VpnGateway.VpnGatewayId | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created VPN Connection: ($vpn.VpnConnection.VpnConnectionId)"
|
|||
|
|
|
|||
|
|
print " Note: Download VPN configuration and apply to DigitalOcean endpoint manually"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def setup_vpn_aws_to_hetzner [] {
|
|||
|
|
print " Setting up VPN tunnel: AWS → Hetzner..."
|
|||
|
|
|
|||
|
|
# For Hetzner, VPN is typically set up via StrongSwan or Wireguard on their servers
|
|||
|
|
print " VPN tunnel to Hetzner requires manual configuration on Hetzner server"
|
|||
|
|
print " Use SSH to configure StrongSwan or Wireguard based on network requirements"
|
|||
|
|
|
|||
|
|
print " Example configuration steps:"
|
|||
|
|
print " 1. SSH into Hetzner server"
|
|||
|
|
print " 2. Install StrongSwan: apt-get install strongswan"
|
|||
|
|
print " 3. Configure IPSec with AWS VPN gateway"
|
|||
|
|
print " 4. Enable IP forwarding: sysctl -w net.ipv4.ip_forward=1"
|
|||
|
|
print " 5. Verify connectivity: ping 10.1.1.10 (AWS RDS)"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def setup_firewall_rules [] {
|
|||
|
|
print " Configuring firewall rules..."
|
|||
|
|
|
|||
|
|
# Allow SSH, HTTP, HTTPS on DigitalOcean droplets
|
|||
|
|
print " Configuring DigitalOcean firewall..."
|
|||
|
|
let fw = (doctl compute firewall create \
|
|||
|
|
--name "web-firewall" \
|
|||
|
|
--inbound-rules "protocol:tcp,ports:22,sources:addresses:0.0.0.0/0;sources:addresses::/0" \
|
|||
|
|
--inbound-rules "protocol:tcp,ports:80,sources:addresses:0.0.0.0/0;sources:addresses::/0" \
|
|||
|
|
--inbound-rules "protocol:tcp,ports:443,sources:addresses:0.0.0.0/0;sources:addresses::/0" \
|
|||
|
|
--format ID \
|
|||
|
|
--no-header | into string)
|
|||
|
|
|
|||
|
|
print $" ✓ Created firewall: ($fw)"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def setup_security_groups [] {
|
|||
|
|
print " Configuring AWS security groups..."
|
|||
|
|
|
|||
|
|
# Create security group for RDS
|
|||
|
|
let sg = (aws ec2 create-security-group \
|
|||
|
|
--group-name "webapp-db-sg" \
|
|||
|
|
--description "Security group for webapp RDS database" | from json)
|
|||
|
|
|
|||
|
|
print $" ✓ Created security group: ($sg.GroupId)"
|
|||
|
|
|
|||
|
|
# Allow PostgreSQL from web tier CIDR
|
|||
|
|
aws ec2 authorize-security-group-ingress \
|
|||
|
|
--group-id $sg.GroupId \
|
|||
|
|
--protocol tcp \
|
|||
|
|
--port 5432 \
|
|||
|
|
--cidr 10.0.0.0/8
|
|||
|
|
|
|||
|
|
print " ✓ Configured PostgreSQL access"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def deploy_application [] {
|
|||
|
|
# Get web server IPs
|
|||
|
|
let droplets = (doctl compute droplet list --format Name,PublicIPv4 --no-header)
|
|||
|
|
|
|||
|
|
$droplets | split row "\n" | each {|line|
|
|||
|
|
if ($line | is-not-empty) {
|
|||
|
|
let parts = ($line | split column --max-columns 2 " " | get column1)
|
|||
|
|
let ip = ($parts | last)
|
|||
|
|
let name = ($parts | first)
|
|||
|
|
|
|||
|
|
print $" Deploying to ($name) at ($ip)..."
|
|||
|
|
|
|||
|
|
# Note: In production, you would:
|
|||
|
|
# 1. SSH into the server
|
|||
|
|
# 2. Clone application repository
|
|||
|
|
# 3. Install dependencies
|
|||
|
|
# 4. Configure environment variables
|
|||
|
|
# 5. Start application
|
|||
|
|
|
|||
|
|
print $" ✓ Ready for application deployment at ssh://root@($ip)"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def verify_deployment [] {
|
|||
|
|
print " Verifying DigitalOcean droplets..."
|
|||
|
|
|
|||
|
|
let droplets = (doctl compute droplet list --no-header --format ID,Name,Status)
|
|||
|
|
print $" Found ($droplets | split row "\n" | length) droplets"
|
|||
|
|
|
|||
|
|
print " Verifying AWS RDS database..."
|
|||
|
|
try {
|
|||
|
|
let db = (aws rds describe-db-instances \
|
|||
|
|
--db-instance-identifier "webapp-db" | from json)
|
|||
|
|
|
|||
|
|
let endpoint = ($db.DBInstances | first).Endpoint.Address
|
|||
|
|
print $" Database endpoint: ($endpoint)"
|
|||
|
|
} catch {|err|
|
|||
|
|
print $" Note: Database may still be initializing"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
print " Verifying Hetzner volumes..."
|
|||
|
|
let volumes = (hcloud volume list --format "ID Name Status" --no-header)
|
|||
|
|
print $" Found ($volumes | split row "\n" | length) volumes"
|
|||
|
|
|
|||
|
|
print "\n Summary:"
|
|||
|
|
print " ✓ DigitalOcean: 3 web servers + load balancer"
|
|||
|
|
print " ✓ AWS: PostgreSQL database (Multi-AZ)"
|
|||
|
|
print " ✓ Hetzner: Backup storage volume"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Run main function
|
|||
|
|
main --debug=$nu.env.DEBUG?
|