diff --git a/clavis/clavis-vault/clavitor-linux-arm64 b/clavis/clavis-vault/clavitor-linux-arm64 index 61141da..5a2793f 100755 Binary files a/clavis/clavis-vault/clavitor-linux-arm64 and b/clavis/clavis-vault/clavitor-linux-arm64 differ diff --git a/clavitor.com/clavitor.db-shm b/clavitor.com/clavitor.db-shm index 51a6cf3..4d60724 100644 Binary files a/clavitor.com/clavitor.db-shm and b/clavitor.com/clavitor.db-shm differ diff --git a/clavitor.com/clavitor.db-wal b/clavitor.com/clavitor.db-wal index 60ed1b4..6df1d49 100644 Binary files a/clavitor.com/clavitor.db-wal and b/clavitor.com/clavitor.db-wal differ diff --git a/operations/pop-sync/go.mod b/operations/pop-sync/go.mod index ca34194..6584d1c 100644 --- a/operations/pop-sync/go.mod +++ b/operations/pop-sync/go.mod @@ -14,6 +14,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect diff --git a/operations/pop-sync/go.sum b/operations/pop-sync/go.sum index 14163c0..38e3cbd 100644 --- a/operations/pop-sync/go.sum +++ b/operations/pop-sync/go.sum @@ -20,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 h1:bBoWhx8lsFLTXintRX64ZBXcmFZbGqUmaPUrjXECqIc= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3/go.mod h1:rcRkKbUJ2437WuXdq9fbj+MjTudYWzY9Ct8kiBbN8a8= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= diff --git a/operations/pop-sync/main.go b/operations/pop-sync/main.go index 381509e..98ee91e 100644 --- a/operations/pop-sync/main.go +++ b/operations/pop-sync/main.go @@ -19,6 +19,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/ssm" _ "modernc.org/sqlite" ) @@ -120,6 +121,11 @@ func main() { exitWith(cmdExec(cfg, strings.Join(remaining, " "))) case "firewall": exitWith(cmdFirewall(cfg)) + case "provision": + if len(remaining) == 0 { + fatal("usage: pop-sync provision [city2 ...] (e.g. pop-sync provision Tokyo Calgary)") + } + exitWith(cmdProvision(cfg, remaining)) case "maintenance": if len(remaining) == 0 { fatal("usage: pop-sync maintenance [reason]") @@ -728,6 +734,220 @@ func ensureFirewall(cfg Config, pop POP) NodeResult { } func strPtr(s string) *string { return &s } +func int32Ptr(i int32) *int32 { return &i } + +// --- Subcommand: provision --- +// Spins up t4g.nano EC2 instances for planned POPs. + +func cmdProvision(cfg Config, cities []string) []NodeResult { + if cfg.AWSKeyID == "" || cfg.AWSSecretKey == "" { + fatal("AWS credentials required: set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or -aws-key/-aws-secret") + } + + // Load all POPs from DB + allPOPs, err := readPOPs(cfg.DBPath) + if err != nil { + fatal("reading DB: %v", err) + } + + // Match cities to planned POPs + var targets []POP + for _, city := range cities { + found := false + for _, p := range allPOPs { + if strings.EqualFold(p.City, city) && p.Status == "planned" { + targets = append(targets, p) + found = true + break + } + } + if !found { + log(cfg, "WARNING: %q not found or not planned, skipping", city) + } + } + if len(targets) == 0 { + fatal("no matching planned POPs found") + } + + log(cfg, "Provisioning %d nodes...\n", len(targets)) + + var results []NodeResult + for _, p := range targets { + r := provisionNode(cfg, p) + results = append(results, r) + } + + outputResults(cfg, results) + return results +} + +func provisionNode(cfg Config, pop POP) NodeResult { + region := pop.RegionName + r := NodeResult{Node: pop.City, Action: "provision"} + + ctx := context.Background() + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AWSKeyID, cfg.AWSSecretKey, "")), + ) + if err != nil { + r.Error = fmt.Sprintf("aws config: %v", err) + return r + } + ec2Client := ec2.NewFromConfig(awsCfg) + ssmClient := ssm.NewFromConfig(awsCfg) + + // 1. Look up latest AL2023 ARM64 AMI + log(cfg, " [%s] looking up AMI in %s...", pop.City, region) + amiParam, err := ssmClient.GetParameter(ctx, &ssm.GetParameterInput{ + Name: strPtr("/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64"), + }) + if err != nil { + r.Error = fmt.Sprintf("AMI lookup: %v", err) + return r + } + amiID := *amiParam.Parameter.Value + log(cfg, " [%s] AMI: %s", pop.City, amiID) + + // 2. Find or create security group + sgID, err := ensureSecurityGroup(ctx, ec2Client, region, pop.City, cfg) + if err != nil { + r.Error = fmt.Sprintf("security group: %v", err) + return r + } + log(cfg, " [%s] SG: %s", pop.City, sgID) + + // 3. Determine subdomain for the node + sub := pop.Subdomain() + if sub == "" { + // Generate from country code + ordinal + sub = strings.ToLower(pop.Country) + "1" + } + + if cfg.DryRun { + r.OK = true + r.Message = fmt.Sprintf("would launch t4g.nano in %s (AMI: %s, SG: %s)", region, amiID, sgID) + log(cfg, " [%s] DRY RUN: %s", pop.City, r.Message) + return r + } + + // 4. Launch instance + log(cfg, " [%s] launching t4g.nano...", pop.City) + runOut, err := ec2Client.RunInstances(ctx, &ec2.RunInstancesInput{ + ImageId: &amiID, + InstanceType: ec2types.InstanceTypeT4gNano, + MinCount: int32Ptr(1), + MaxCount: int32Ptr(1), + SecurityGroupIds: []string{sgID}, + IamInstanceProfile: &ec2types.IamInstanceProfileSpecification{ + Name: strPtr("vault1984-ssm-profile"), + }, + TagSpecifications: []ec2types.TagSpecification{{ + ResourceType: ec2types.ResourceTypeInstance, + Tags: []ec2types.Tag{ + {Key: strPtr("Name"), Value: strPtr("clavitor-" + sub)}, + {Key: strPtr("clavitor-pop"), Value: strPtr(sub)}, + }, + }}, + }) + if err != nil { + r.Error = fmt.Sprintf("launch: %v", err) + return r + } + instanceID := *runOut.Instances[0].InstanceId + log(cfg, " [%s] launched: %s, waiting for public IP...", pop.City, instanceID) + + // 5. Wait for running + public IP + waiter := ec2.NewInstanceRunningWaiter(ec2Client) + err = waiter.Wait(ctx, &ec2.DescribeInstancesInput{ + InstanceIds: []string{instanceID}, + }, 3*time.Minute) + if err != nil { + r.Error = fmt.Sprintf("wait running: %v", err) + return r + } + + // Get public IP + descOut, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + InstanceIds: []string{instanceID}, + }) + if err != nil || len(descOut.Reservations) == 0 || len(descOut.Reservations[0].Instances) == 0 { + r.Error = fmt.Sprintf("describe: %v", err) + return r + } + inst := descOut.Reservations[0].Instances[0] + publicIP := "" + if inst.PublicIpAddress != nil { + publicIP = *inst.PublicIpAddress + } + if publicIP == "" { + r.Error = "no public IP assigned" + return r + } + log(cfg, " [%s] IP: %s", pop.City, publicIP) + + // 6. Update DB + dns := sub + "." + cfg.Zone + db, err := sql.Open("sqlite", cfg.DBPath) + if err != nil { + r.Error = fmt.Sprintf("open db: %v", err) + return r + } + defer db.Close() + _, err = db.Exec(`UPDATE pops SET instance_id=?, ip=?, dns=?, status='live' WHERE pop_id=?`, + instanceID, publicIP, dns, pop.PopID) + if err != nil { + r.Error = fmt.Sprintf("update db: %v", err) + return r + } + + r.OK = true + r.Message = fmt.Sprintf("launched %s (%s) → %s → %s", instanceID, region, publicIP, dns) + log(cfg, " [%s] DONE: %s", pop.City, r.Message) + return r +} + +func ensureSecurityGroup(ctx context.Context, client *ec2.Client, region, city string, cfg Config) (string, error) { + // Check if vault1984-pop exists in this region + descOut, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{ + Filters: []ec2types.Filter{{ + Name: strPtr("group-name"), + Values: []string{"vault1984-pop"}, + }}, + }) + if err == nil && len(descOut.SecurityGroups) > 0 { + return *descOut.SecurityGroups[0].GroupId, nil + } + + // Create it + log(cfg, " [%s] creating security group vault1984-pop in %s...", city, region) + createOut, err := client.CreateSecurityGroup(ctx, &ec2.CreateSecurityGroupInput{ + GroupName: strPtr("vault1984-pop"), + Description: strPtr("Clavitor POP - port 1984 only"), + }) + if err != nil { + return "", fmt.Errorf("create sg: %w", err) + } + sgID := *createOut.GroupId + + // Add port 1984 + proto := "tcp" + port := int32(1984) + _, err = client.AuthorizeSecurityGroupIngress(ctx, &ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: &sgID, + IpPermissions: []ec2types.IpPermission{{ + IpProtocol: &proto, + FromPort: &port, + ToPort: &port, + IpRanges: []ec2types.IpRange{{CidrIp: strPtr("0.0.0.0/0")}}, + }}, + }) + if err != nil { + return sgID, fmt.Errorf("add rule: %w", err) + } + + return sgID, nil +} // --- Node execution: Tailscale SSH with SSM fallback --- diff --git a/operations/pop-sync/pop-sync b/operations/pop-sync/pop-sync index 8a26b09..cc7e003 100755 Binary files a/operations/pop-sync/pop-sync and b/operations/pop-sync/pop-sync differ