A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1package main
2
3import (
4 "bufio"
5 "context"
6 crypto_rand "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "fmt"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
17 "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
18 "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service"
19 "github.com/spf13/cobra"
20)
21
22var provisionCmd = &cobra.Command{
23 Use: "provision",
24 Short: "Create all infrastructure (servers, network, LB, firewall)",
25 RunE: func(cmd *cobra.Command, args []string) error {
26 token, _ := cmd.Root().PersistentFlags().GetString("token")
27 zone, _ := cmd.Flags().GetString("zone")
28 plan, _ := cmd.Flags().GetString("plan")
29 sshKey, _ := cmd.Flags().GetString("ssh-key")
30 s3Secret, _ := cmd.Flags().GetString("s3-secret")
31 withScanner, _ := cmd.Flags().GetBool("with-scanner")
32 return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner)
33 },
34}
35
36func init() {
37 provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)")
38 provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)")
39 provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)")
40 provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)")
41 provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold")
42 _ = provisionCmd.MarkFlagRequired("ssh-key")
43 rootCmd.AddCommand(provisionCmd)
44}
45
46func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bool) error {
47 cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret)
48 if err != nil {
49 return err
50 }
51
52 naming := cfg.Naming()
53
54 svc, err := newService(token)
55 if err != nil {
56 return err
57 }
58
59 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
60 defer cancel()
61
62 // Load existing state or start fresh
63 state, err := loadState()
64 if err != nil {
65 state = &InfraState{}
66 }
67
68 // Use zone from state if not provided via flags
69 if cfg.Zone == "" && state.Zone != "" {
70 cfg.Zone = state.Zone
71 }
72
73 // Only need interactive picker if we still need to create resources
74 needsServers := state.Appview.UUID == "" || state.Hold.UUID == ""
75 if cfg.Zone == "" || (needsServers && cfg.Plan == "") {
76 if err := resolveInteractive(ctx, svc, cfg); err != nil {
77 return err
78 }
79 }
80
81 if state.Zone == "" {
82 state.Zone = cfg.Zone
83 }
84 state.ClientName = cfg.ClientName
85 state.RepoBranch = cfg.RepoBranch
86
87 // Scanner setup
88 if withScanner {
89 state.ScannerEnabled = true
90 if state.ScannerSecret == "" {
91 secret, err := generateScannerSecret()
92 if err != nil {
93 return fmt.Errorf("generate scanner secret: %w", err)
94 }
95 state.ScannerSecret = secret
96 fmt.Printf("Generated scanner shared secret\n")
97 }
98 _ = saveState(state)
99 }
100
101 fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone)
102 if needsServers {
103 fmt.Printf("Server plan: %s\n", cfg.Plan)
104 }
105 fmt.Println()
106
107 // S3 secret key — from flag for existing storage, from API for new
108 s3SecretKey := cfg.S3SecretKey
109
110 // 1. Object storage
111 if state.ObjectStorage.UUID != "" {
112 fmt.Printf("Object storage: %s (exists)\n", state.ObjectStorage.UUID)
113 // Refresh discoverable fields if missing (e.g. pre-seeded UUID only)
114 if state.ObjectStorage.Endpoint == "" || state.ObjectStorage.Bucket == "" {
115 fmt.Println(" Discovering endpoint, bucket, access key...")
116 discovered, err := lookupObjectStorage(ctx, svc, state.ObjectStorage.UUID)
117 if err != nil {
118 return err
119 }
120 state.ObjectStorage.Endpoint = discovered.Endpoint
121 state.ObjectStorage.Region = discovered.Region
122 if discovered.Bucket != "" {
123 state.ObjectStorage.Bucket = discovered.Bucket
124 }
125 if discovered.AccessKeyID != "" {
126 state.ObjectStorage.AccessKeyID = discovered.AccessKeyID
127 }
128 _ = saveState(state)
129 }
130 } else {
131 fmt.Println("Creating object storage...")
132 objState, secretKey, err := provisionObjectStorage(ctx, svc, cfg.Zone, naming.S3Name())
133 if err != nil {
134 return fmt.Errorf("object storage: %w", err)
135 }
136 state.ObjectStorage = objState
137 s3SecretKey = secretKey
138 _ = saveState(state)
139 fmt.Printf(" S3 Secret Key: %s\n", secretKey)
140 }
141
142 fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint)
143 fmt.Printf(" Region: %s\n", state.ObjectStorage.Region)
144 fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket)
145 fmt.Printf(" Access Key: %s\n\n", state.ObjectStorage.AccessKeyID)
146
147 // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev)
148 holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain
149
150 // Build config template values
151 vals := &ConfigValues{
152 S3Endpoint: state.ObjectStorage.Endpoint,
153 S3Region: state.ObjectStorage.Region,
154 S3Bucket: state.ObjectStorage.Bucket,
155 S3AccessKey: state.ObjectStorage.AccessKeyID,
156 S3SecretKey: s3SecretKey,
157 Zone: cfg.Zone,
158 HoldDomain: holdDomain,
159 HoldDid: "did:web:" + holdDomain,
160 BasePath: naming.BasePath(),
161 ScannerSecret: state.ScannerSecret,
162 }
163
164 // 2. Private network
165 if state.Network.UUID != "" {
166 fmt.Printf("Network: %s (exists)\n", state.Network.UUID)
167 } else {
168 fmt.Println("Creating private network...")
169 network, err := svc.CreateNetwork(ctx, &request.CreateNetworkRequest{
170 Name: naming.NetworkName(),
171 Zone: cfg.Zone,
172 IPNetworks: upcloud.IPNetworkSlice{
173 {
174 Address: privateNetworkCIDR,
175 DHCP: upcloud.True,
176 DHCPDefaultRoute: upcloud.False,
177 DHCPDns: []string{"8.8.8.8", "1.1.1.1"},
178 Family: upcloud.IPAddressFamilyIPv4,
179 Gateway: "",
180 },
181 },
182 })
183 if err != nil {
184 return fmt.Errorf("create network: %w", err)
185 }
186 state.Network = StateRef{UUID: network.UUID}
187 _ = saveState(state)
188 fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR)
189 }
190
191 // Find Debian template (needed for server creation)
192 templateUUID, err := findDebianTemplate(ctx, svc)
193 if err != nil {
194 return err
195 }
196
197 // 3. Appview server
198 appviewCreated := false
199 if state.Appview.UUID != "" {
200 fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID)
201 appviewScript, err := generateAppviewCloudInit(cfg, vals)
202 if err != nil {
203 return err
204 }
205 if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil {
206 return err
207 }
208 appviewConfigYAML, err := renderConfig(appviewConfigTmpl, vals)
209 if err != nil {
210 return fmt.Errorf("render appview config: %w", err)
211 }
212 if err := syncConfigKeys("appview", state.Appview.PublicIP, naming.AppviewConfigPath(), appviewConfigYAML); err != nil {
213 return fmt.Errorf("appview config sync: %w", err)
214 }
215 } else {
216 fmt.Println("Creating appview server...")
217 appviewUserData, err := generateAppviewCloudInit(cfg, vals)
218 if err != nil {
219 return err
220 }
221 appview, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Appview(), appviewUserData)
222 if err != nil {
223 return fmt.Errorf("create appview: %w", err)
224 }
225 state.Appview = *appview
226 _ = saveState(state)
227 appviewCreated = true
228 fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP)
229 }
230
231 // 4. Hold server
232 holdCreated := false
233 if state.Hold.UUID != "" {
234 fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID)
235 holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
236 if err != nil {
237 return err
238 }
239 if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil {
240 return err
241 }
242 holdConfigYAML, err := renderConfig(holdConfigTmpl, vals)
243 if err != nil {
244 return fmt.Errorf("render hold config: %w", err)
245 }
246 if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil {
247 return fmt.Errorf("hold config sync: %w", err)
248 }
249 if state.ScannerEnabled {
250 scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals)
251 if err != nil {
252 return fmt.Errorf("render scanner config: %w", err)
253 }
254 if err := syncConfigKeys("scanner", state.Hold.PublicIP, naming.ScannerConfigPath(), scannerConfigYAML); err != nil {
255 return fmt.Errorf("scanner config sync: %w", err)
256 }
257 }
258 } else {
259 fmt.Println("Creating hold server...")
260 holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
261 if err != nil {
262 return err
263 }
264 hold, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Hold(), holdUserData)
265 if err != nil {
266 return fmt.Errorf("create hold: %w", err)
267 }
268 state.Hold = *hold
269 _ = saveState(state)
270 holdCreated = true
271 fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP)
272 }
273
274 // 5. Firewall rules (idempotent — replaces all rules)
275 fmt.Println("Configuring firewall rules...")
276 for _, s := range []struct {
277 name string
278 uuid string
279 }{
280 {"appview", state.Appview.UUID},
281 {"hold", state.Hold.UUID},
282 } {
283 if err := createFirewallRules(ctx, svc, s.uuid, privateNetworkCIDR); err != nil {
284 return fmt.Errorf("firewall %s: %w", s.name, err)
285 }
286 }
287
288 // 6. Load balancer
289 if state.LB.UUID != "" {
290 fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID)
291 } else {
292 fmt.Println("Creating load balancer (Essentials tier)...")
293 lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain)
294 if err != nil {
295 return fmt.Errorf("create LB: %w", err)
296 }
297 state.LB = StateRef{UUID: lb.UUID}
298 _ = saveState(state)
299 }
300
301 // Always reconcile forwarded headers rule (handles existing LBs)
302 if err := ensureLBForwardedHeaders(ctx, svc, state.LB.UUID); err != nil {
303 return fmt.Errorf("LB forwarded headers: %w", err)
304 }
305
306 // Always reconcile TLS certs (handles partial failures and re-runs)
307 tlsDomains := []string{cfg.BaseDomain}
308 tlsDomains = append(tlsDomains, cfg.RegistryDomains...)
309 tlsDomains = append(tlsDomains, holdDomain)
310 if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil {
311 return fmt.Errorf("LB certificates: %w", err)
312 }
313
314 // Fetch LB DNS name for output
315 lbDNS := ""
316 if state.LB.UUID != "" {
317 lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: state.LB.UUID})
318 if err == nil {
319 for _, n := range lb.Networks {
320 if n.Type == upcloud.LoadBalancerNetworkTypePublic {
321 lbDNS = n.DNSName
322 }
323 }
324 }
325 }
326
327 // 7. Build locally and deploy binaries to new servers
328 if appviewCreated || holdCreated {
329 rootDir := projectRoot()
330
331 if err := runGenerate(rootDir); err != nil {
332 return fmt.Errorf("go generate: %w", err)
333 }
334
335 fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...")
336 if appviewCreated {
337 outputPath := filepath.Join(rootDir, "bin", "atcr-appview")
338 if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil {
339 return fmt.Errorf("build appview: %w", err)
340 }
341 }
342 if holdCreated {
343 outputPath := filepath.Join(rootDir, "bin", "atcr-hold")
344 if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil {
345 return fmt.Errorf("build hold: %w", err)
346 }
347 if state.ScannerEnabled {
348 outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
349 if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
350 return fmt.Errorf("build scanner: %w", err)
351 }
352 }
353 }
354
355 fmt.Println("\nWaiting for cloud-init to complete on new servers...")
356 if appviewCreated {
357 if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil {
358 return err
359 }
360 }
361 if holdCreated {
362 if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil {
363 return err
364 }
365 }
366
367 fmt.Println("\nDeploying binaries...")
368 if appviewCreated {
369 localPath := filepath.Join(rootDir, "bin", "atcr-appview")
370 remotePath := naming.InstallDir() + "/bin/" + naming.Appview()
371 if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil {
372 return fmt.Errorf("upload appview: %w", err)
373 }
374 }
375 if holdCreated {
376 localPath := filepath.Join(rootDir, "bin", "atcr-hold")
377 remotePath := naming.InstallDir() + "/bin/" + naming.Hold()
378 if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil {
379 return fmt.Errorf("upload hold: %w", err)
380 }
381 if state.ScannerEnabled {
382 scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner")
383 scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner()
384 if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil {
385 return fmt.Errorf("upload scanner: %w", err)
386 }
387 }
388 }
389 }
390
391 fmt.Println("\n=== Provisioning Complete ===")
392 fmt.Println()
393 fmt.Println("DNS records needed:")
394 if lbDNS != "" {
395 fmt.Printf(" CNAME %-24s → %s\n", cfg.BaseDomain, lbDNS)
396 for _, rd := range cfg.RegistryDomains {
397 fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS)
398 }
399 fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS)
400 } else {
401 fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)")
402 }
403 fmt.Println()
404 fmt.Println("SSH access:")
405 fmt.Printf(" ssh root@%s # appview\n", state.Appview.PublicIP)
406 fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP)
407 fmt.Println()
408 fmt.Println("Next steps:")
409 if appviewCreated || holdCreated {
410 fmt.Println(" 1. Edit configs if needed, then start services:")
411 } else {
412 fmt.Println(" 1. Start services:")
413 }
414 if state.ScannerEnabled {
415 fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner())
416 } else {
417 fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold())
418 }
419 fmt.Println(" 2. Configure DNS records above")
420
421 return nil
422}
423
424// provisionObjectStorage creates a new Managed Object Storage with a user, access key, and bucket.
425// Returns the state and the secret key separately (only available at creation time).
426func provisionObjectStorage(ctx context.Context, svc *service.Service, zone, s3Name string) (ObjectStorageState, string, error) {
427 // Map compute zone to object storage region (e.g. us-chi1 → us-east-1)
428 region := objectStorageRegion(zone)
429
430 storage, err := svc.CreateManagedObjectStorage(ctx, &request.CreateManagedObjectStorageRequest{
431 Name: s3Name,
432 Region: region,
433 ConfiguredStatus: upcloud.ManagedObjectStorageConfiguredStatusStarted,
434 Networks: []upcloud.ManagedObjectStorageNetwork{
435 {
436 Family: upcloud.IPAddressFamilyIPv4,
437 Name: "public",
438 Type: "public",
439 },
440 },
441 })
442 if err != nil {
443 return ObjectStorageState{}, "", fmt.Errorf("create storage: %w", err)
444 }
445 fmt.Printf(" Created: %s (region: %s)\n", storage.UUID, region)
446
447 // Find endpoint
448 var endpoint string
449 for _, ep := range storage.Endpoints {
450 if ep.DomainName != "" {
451 endpoint = "https://" + ep.DomainName
452 break
453 }
454 }
455
456 // Create user
457 _, err = svc.CreateManagedObjectStorageUser(ctx, &request.CreateManagedObjectStorageUserRequest{
458 ServiceUUID: storage.UUID,
459 Username: s3Name,
460 })
461 if err != nil {
462 return ObjectStorageState{}, "", fmt.Errorf("create user: %w", err)
463 }
464
465 // Attach admin policy
466 err = svc.AttachManagedObjectStorageUserPolicy(ctx, &request.AttachManagedObjectStorageUserPolicyRequest{
467 ServiceUUID: storage.UUID,
468 Username: s3Name,
469 Name: "admin",
470 })
471 if err != nil {
472 return ObjectStorageState{}, "", fmt.Errorf("attach policy: %w", err)
473 }
474
475 // Create access key (secret is only returned here)
476 accessKey, err := svc.CreateManagedObjectStorageUserAccessKey(ctx, &request.CreateManagedObjectStorageUserAccessKeyRequest{
477 ServiceUUID: storage.UUID,
478 Username: s3Name,
479 })
480 if err != nil {
481 return ObjectStorageState{}, "", fmt.Errorf("create access key: %w", err)
482 }
483
484 secretKey := ""
485 if accessKey.SecretAccessKey != nil {
486 secretKey = *accessKey.SecretAccessKey
487 }
488
489 // Create bucket
490 _, err = svc.CreateManagedObjectStorageBucket(ctx, &request.CreateManagedObjectStorageBucketRequest{
491 ServiceUUID: storage.UUID,
492 Name: s3Name,
493 })
494 if err != nil {
495 return ObjectStorageState{}, "", fmt.Errorf("create bucket: %w", err)
496 }
497
498 return ObjectStorageState{
499 UUID: storage.UUID,
500 Endpoint: endpoint,
501 Region: region,
502 Bucket: s3Name,
503 AccessKeyID: accessKey.AccessKeyID,
504 }, secretKey, nil
505}
506
507// objectStorageRegion maps a compute zone to the nearest object storage region.
508func objectStorageRegion(zone string) string {
509 switch {
510 case strings.HasPrefix(zone, "us-"):
511 return "us-east-1"
512 case strings.HasPrefix(zone, "de-"):
513 return "europe-1"
514 case strings.HasPrefix(zone, "fi-"):
515 return "europe-1"
516 case strings.HasPrefix(zone, "nl-"):
517 return "europe-1"
518 case strings.HasPrefix(zone, "es-"):
519 return "europe-1"
520 case strings.HasPrefix(zone, "pl-"):
521 return "europe-1"
522 case strings.HasPrefix(zone, "se-"):
523 return "europe-1"
524 case strings.HasPrefix(zone, "au-"):
525 return "australia-1"
526 case strings.HasPrefix(zone, "sg-"):
527 return "singapore-1"
528 default:
529 return "us-east-1"
530 }
531}
532
533func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) {
534 storageTier := "maxiops"
535 if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") {
536 storageTier = "standard"
537 }
538
539 // Look up the plan's storage size from the API instead of hardcoding.
540 diskSize := 25 // fallback
541 plans, err := svc.GetPlans(ctx)
542 if err == nil {
543 for _, p := range plans.Plans {
544 if p.Name == cfg.Plan {
545 diskSize = p.StorageSize
546 break
547 }
548 }
549 }
550
551 details, err := svc.CreateServer(ctx, &request.CreateServerRequest{
552 Zone: cfg.Zone,
553 Title: title,
554 Hostname: title,
555 Plan: cfg.Plan,
556 Metadata: upcloud.True,
557 UserData: userData,
558 Firewall: "on",
559 PasswordDelivery: "none",
560 StorageDevices: request.CreateServerStorageDeviceSlice{
561 {
562 Action: "clone",
563 Storage: templateUUID,
564 Title: title + "-disk",
565 Size: diskSize,
566 Tier: storageTier,
567 },
568 },
569 Networking: &request.CreateServerNetworking{
570 Interfaces: request.CreateServerInterfaceSlice{
571 {
572 Index: 1,
573 Type: upcloud.IPAddressAccessPublic,
574 IPAddresses: request.CreateServerIPAddressSlice{
575 {Family: upcloud.IPAddressFamilyIPv4},
576 },
577 },
578 {
579 Index: 2,
580 Type: upcloud.IPAddressAccessPrivate,
581 Network: networkUUID,
582 IPAddresses: request.CreateServerIPAddressSlice{
583 {Family: upcloud.IPAddressFamilyIPv4},
584 },
585 },
586 },
587 },
588 LoginUser: &request.LoginUser{
589 CreatePassword: "no",
590 SSHKeys: request.SSHKeySlice{cfg.SSHPublicKey},
591 },
592 })
593 if err != nil {
594 return nil, err
595 }
596
597 fmt.Printf(" Waiting for server %s to start...\n", details.UUID)
598 details, err = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{
599 UUID: details.UUID,
600 DesiredState: upcloud.ServerStateStarted,
601 })
602 if err != nil {
603 return nil, fmt.Errorf("wait for server: %w", err)
604 }
605
606 s := &ServerState{UUID: details.UUID}
607 for _, iface := range details.Networking.Interfaces {
608 for _, addr := range iface.IPAddresses {
609 if addr.Family == upcloud.IPAddressFamilyIPv4 {
610 switch iface.Type {
611 case upcloud.IPAddressAccessPublic:
612 s.PublicIP = addr.Address
613 case upcloud.IPAddressAccessPrivate:
614 s.PrivateIP = addr.Address
615 }
616 }
617 }
618 }
619
620 return s, nil
621}
622
623func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, privateCIDR string) error {
624 networkBase := strings.TrimSuffix(privateCIDR, "/24")
625 networkBase = strings.TrimSuffix(networkBase, ".0")
626
627 return svc.CreateFirewallRules(ctx, &request.CreateFirewallRulesRequest{
628 ServerUUID: serverUUID,
629 FirewallRules: request.FirewallRuleSlice{
630 {
631 Direction: upcloud.FirewallRuleDirectionIn,
632 Action: upcloud.FirewallRuleActionAccept,
633 Family: upcloud.IPAddressFamilyIPv4,
634 Protocol: upcloud.FirewallRuleProtocolTCP,
635 DestinationPortStart: "22",
636 DestinationPortEnd: "22",
637 Position: 1,
638 Comment: "Allow SSH",
639 },
640 {
641 Direction: upcloud.FirewallRuleDirectionIn,
642 Action: upcloud.FirewallRuleActionAccept,
643 Family: upcloud.IPAddressFamilyIPv4,
644 SourceAddressStart: networkBase + ".0",
645 SourceAddressEnd: networkBase + ".255",
646 Position: 2,
647 Comment: "Allow private network",
648 },
649 {
650 Direction: upcloud.FirewallRuleDirectionIn,
651 Action: upcloud.FirewallRuleActionAccept,
652 Family: upcloud.IPAddressFamilyIPv4,
653 Protocol: upcloud.FirewallRuleProtocolUDP,
654 SourcePortStart: "123",
655 SourcePortEnd: "123",
656 Position: 3,
657 Comment: "Allow NTP replies",
658 },
659 {
660 Direction: upcloud.FirewallRuleDirectionIn,
661 Action: upcloud.FirewallRuleActionDrop,
662 Position: 4,
663 Comment: "Drop all other inbound",
664 },
665 },
666 })
667}
668
669func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) {
670 lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{
671 Name: naming.LBName(),
672 Plan: "essentials",
673 Zone: cfg.Zone,
674 ConfiguredStatus: upcloud.LoadBalancerConfiguredStatusStarted,
675 Networks: []request.LoadBalancerNetwork{
676 {
677 Name: "public",
678 Type: upcloud.LoadBalancerNetworkTypePublic,
679 Family: upcloud.LoadBalancerAddressFamilyIPv4,
680 },
681 {
682 Name: "private",
683 Type: upcloud.LoadBalancerNetworkTypePrivate,
684 Family: upcloud.LoadBalancerAddressFamilyIPv4,
685 UUID: networkUUID,
686 },
687 },
688 Frontends: []request.LoadBalancerFrontend{
689 {
690 Name: "https",
691 Mode: upcloud.LoadBalancerModeHTTP,
692 Port: 443,
693 DefaultBackend: "appview",
694 Networks: []upcloud.LoadBalancerFrontendNetwork{
695 {Name: "public"},
696 },
697 Rules: []request.LoadBalancerFrontendRule{
698 {
699 Name: "set-forwarded-headers",
700 Priority: 1,
701 Matchers: []upcloud.LoadBalancerMatcher{},
702 Actions: []upcloud.LoadBalancerAction{
703 request.NewLoadBalancerSetForwardedHeadersAction(),
704 },
705 },
706 {
707 Name: "route-hold",
708 Priority: 10,
709 Matchers: []upcloud.LoadBalancerMatcher{
710 {
711 Type: upcloud.LoadBalancerMatcherTypeHost,
712 Host: &upcloud.LoadBalancerMatcherHost{
713 Value: holdDomain,
714 },
715 },
716 },
717 Actions: []upcloud.LoadBalancerAction{
718 {
719 Type: upcloud.LoadBalancerActionTypeUseBackend,
720 UseBackend: &upcloud.LoadBalancerActionUseBackend{
721 Backend: "hold",
722 },
723 },
724 },
725 },
726 },
727 },
728 {
729 Name: "http-redirect",
730 Mode: upcloud.LoadBalancerModeHTTP,
731 Port: 80,
732 DefaultBackend: "appview",
733 Networks: []upcloud.LoadBalancerFrontendNetwork{
734 {Name: "public"},
735 },
736 Rules: []request.LoadBalancerFrontendRule{
737 {
738 Name: "redirect-https",
739 Priority: 10,
740 Matchers: []upcloud.LoadBalancerMatcher{
741 {
742 Type: upcloud.LoadBalancerMatcherTypeSrcPort,
743 SrcPort: &upcloud.LoadBalancerMatcherInteger{
744 Method: upcloud.LoadBalancerIntegerMatcherMethodEqual,
745 Value: 80,
746 },
747 },
748 },
749 Actions: []upcloud.LoadBalancerAction{
750 {
751 Type: upcloud.LoadBalancerActionTypeHTTPRedirect,
752 HTTPRedirect: &upcloud.LoadBalancerActionHTTPRedirect{
753 Scheme: upcloud.LoadBalancerActionHTTPRedirectSchemeHTTPS,
754 },
755 },
756 },
757 },
758 },
759 },
760 },
761 Resolvers: []request.LoadBalancerResolver{},
762 Backends: []request.LoadBalancerBackend{
763 {
764 Name: "appview",
765 Members: []request.LoadBalancerBackendMember{
766 {
767 Name: "appview-1",
768 Type: upcloud.LoadBalancerBackendMemberTypeStatic,
769 IP: appviewIP,
770 Port: 5000,
771 Weight: 100,
772 MaxSessions: 1000,
773 Enabled: true,
774 },
775 },
776 Properties: &upcloud.LoadBalancerBackendProperties{
777 HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
778 HealthCheckURL: "/health",
779 },
780 },
781 {
782 Name: "hold",
783 Members: []request.LoadBalancerBackendMember{
784 {
785 Name: "hold-1",
786 Type: upcloud.LoadBalancerBackendMemberTypeStatic,
787 IP: holdIP,
788 Port: 8080,
789 Weight: 100,
790 MaxSessions: 1000,
791 Enabled: true,
792 },
793 },
794 Properties: &upcloud.LoadBalancerBackendProperties{
795 HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
796 HealthCheckURL: "/xrpc/_health",
797 },
798 },
799 },
800 })
801 if err != nil {
802 return nil, err
803 }
804
805 return lb, nil
806}
807
808// ensureLBCertificates reconciles TLS certificate bundles on the load balancer.
809// It skips domains that already have a TLS config attached and creates missing ones.
810func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error {
811 lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: lbUUID})
812 if err != nil {
813 return fmt.Errorf("get load balancer: %w", err)
814 }
815
816 // Build set of existing TLS config names on the "https" frontend
817 existing := make(map[string]bool)
818 for _, fe := range lb.Frontends {
819 if fe.Name == "https" {
820 for _, tc := range fe.TLSConfigs {
821 existing[tc.Name] = true
822 }
823 }
824 }
825
826 for _, domain := range tlsDomains {
827 certName := "tls-" + strings.ReplaceAll(domain, ".", "-")
828 if existing[certName] {
829 fmt.Printf(" TLS certificate: %s (exists)\n", domain)
830 continue
831 }
832
833 bundle, err := svc.CreateLoadBalancerCertificateBundle(ctx, &request.CreateLoadBalancerCertificateBundleRequest{
834 Type: upcloud.LoadBalancerCertificateBundleTypeDynamic,
835 Name: certName,
836 KeyType: "ecdsa",
837 Hostnames: []string{domain},
838 })
839 if err != nil {
840 return fmt.Errorf("create TLS cert for %s: %w", domain, err)
841 }
842
843 _, err = svc.CreateLoadBalancerFrontendTLSConfig(ctx, &request.CreateLoadBalancerFrontendTLSConfigRequest{
844 ServiceUUID: lbUUID,
845 FrontendName: "https",
846 Config: request.LoadBalancerFrontendTLSConfig{
847 Name: certName,
848 CertificateBundleUUID: bundle.UUID,
849 },
850 })
851 if err != nil {
852 return fmt.Errorf("attach TLS cert %s to frontend: %w", domain, err)
853 }
854 fmt.Printf(" TLS certificate: %s\n", domain)
855 }
856
857 return nil
858}
859
860// ensureLBForwardedHeaders ensures the "https" frontend has a set_forwarded_headers rule.
861// This makes the LB set X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Port headers,
862// overwriting any pre-existing values (prevents spoofing).
863func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID string) error {
864 rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{
865 ServiceUUID: lbUUID,
866 FrontendName: "https",
867 })
868 if err != nil {
869 return fmt.Errorf("get frontend rules: %w", err)
870 }
871
872 for _, r := range rules {
873 if r.Name == "set-forwarded-headers" {
874 fmt.Println(" Forwarded headers rule: exists")
875 return nil
876 }
877 }
878
879 _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
880 ServiceUUID: lbUUID,
881 FrontendName: "https",
882 Rule: request.LoadBalancerFrontendRule{
883 Name: "set-forwarded-headers",
884 Priority: 1,
885 Matchers: []upcloud.LoadBalancerMatcher{},
886 Actions: []upcloud.LoadBalancerAction{
887 request.NewLoadBalancerSetForwardedHeadersAction(),
888 },
889 },
890 })
891 if err != nil {
892 return fmt.Errorf("create forwarded headers rule: %w", err)
893 }
894 fmt.Println(" Forwarded headers rule: created")
895
896 return nil
897}
898
899// lookupObjectStorage discovers details of an existing Managed Object Storage.
900func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) {
901 storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{
902 UUID: uuid,
903 })
904 if err != nil {
905 return ObjectStorageState{}, fmt.Errorf("get object storage %s: %w", uuid, err)
906 }
907
908 var endpoint string
909 for _, ep := range storage.Endpoints {
910 if ep.DomainName != "" {
911 endpoint = "https://" + ep.DomainName
912 break
913 }
914 }
915
916 var bucket string
917 buckets, err := svc.GetManagedObjectStorageBucketMetrics(ctx, &request.GetManagedObjectStorageBucketMetricsRequest{
918 ServiceUUID: uuid,
919 })
920 if err == nil {
921 for _, b := range buckets {
922 if !b.Deleted {
923 bucket = b.Name
924 break
925 }
926 }
927 }
928
929 var accessKeyID string
930 users, err := svc.GetManagedObjectStorageUsers(ctx, &request.GetManagedObjectStorageUsersRequest{
931 ServiceUUID: uuid,
932 })
933 if err == nil {
934 for _, u := range users {
935 for _, k := range u.AccessKeys {
936 if k.Status == "Active" {
937 accessKeyID = k.AccessKeyID
938 break
939 }
940 }
941 if accessKeyID != "" {
942 break
943 }
944 }
945 }
946
947 return ObjectStorageState{
948 UUID: uuid,
949 Endpoint: endpoint,
950 Region: storage.Region,
951 Bucket: bucket,
952 AccessKeyID: accessKeyID,
953 }, nil
954}
955
956func findDebianTemplate(ctx context.Context, svc *service.Service) (string, error) {
957 storages, err := svc.GetStorages(ctx, &request.GetStoragesRequest{
958 Type: "template",
959 })
960 if err != nil {
961 return "", fmt.Errorf("list templates: %w", err)
962 }
963
964 var debian13, debian12 string
965 for _, s := range storages.Storages {
966 title := strings.ToLower(s.Title)
967 if strings.Contains(title, "debian") {
968 if strings.Contains(title, "13") || strings.Contains(title, "trixie") {
969 debian13 = s.UUID
970 } else if strings.Contains(title, "12") || strings.Contains(title, "bookworm") {
971 debian12 = s.UUID
972 }
973 }
974 }
975
976 if debian13 != "" {
977 return debian13, nil
978 }
979 if debian12 != "" {
980 fmt.Println(" Debian 13 not available, using Debian 12")
981 return debian12, nil
982 }
983
984 return "", fmt.Errorf("no Debian template found — check UpCloud template list")
985}
986
987const cloudInitPath = "/var/lib/cloud/instance/scripts/part-001"
988
989// syncCloudInit compares a locally-generated cloud-init script against what's
990// on the server. If they differ (or the remote is missing), it prompts the
991// user and re-runs the script over SSH.
992func syncCloudInit(name, ip, localScript string) error {
993 // Fetch the remote script
994 remoteScript, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", cloudInitPath), false)
995 if err != nil {
996 fmt.Printf(" cloud-init: could not reach %s (%v)\n", name, err)
997 return nil
998 }
999 remoteScript = strings.TrimSpace(remoteScript)
1000
1001 if remoteScript == "__MISSING__" {
1002 fmt.Printf(" cloud-init: not found on %s (server may need initial setup)\n", name)
1003 } else {
1004 localHash := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.TrimSpace(localScript))))
1005 remoteHash := fmt.Sprintf("%x", sha256.Sum256([]byte(remoteScript)))
1006 if localHash == remoteHash {
1007 fmt.Printf(" cloud-init: up to date\n")
1008 return nil
1009 }
1010 fmt.Printf(" cloud-init: differs from local\n")
1011 }
1012
1013 fmt.Printf(" Re-run cloud-init on %s? [Y/n] ", name)
1014 scanner := bufio.NewScanner(os.Stdin)
1015 scanner.Scan()
1016 answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
1017 if answer != "" && answer != "y" && answer != "yes" {
1018 fmt.Printf(" Skipped\n")
1019 // Still update the remote reference so next provision sees an accurate diff
1020 if err := writeRemoteCloudInit(ip, localScript); err != nil {
1021 fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err)
1022 }
1023 return nil
1024 }
1025
1026 // Write the reference file first so next provision can detect real diffs,
1027 // regardless of whether the script execution succeeds or fails.
1028 if err := writeRemoteCloudInit(ip, localScript); err != nil {
1029 fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err)
1030 }
1031
1032 fmt.Printf(" Running cloud-init on %s (%s)... (this may take several minutes)\n", name, ip)
1033 output, err := runSSH(ip, localScript, true)
1034 if err != nil {
1035 fmt.Printf(" ERROR: %v\n", err)
1036 fmt.Printf(" Output:\n%s\n", output)
1037 return fmt.Errorf("cloud-init %s failed", name)
1038 }
1039
1040 fmt.Printf(" %s: cloud-init complete\n", name)
1041 return nil
1042}
1043
1044// generateScannerSecret generates a random 32-byte hex-encoded shared secret
1045// for authenticating scanner-to-hold WebSocket connections.
1046func generateScannerSecret() (string, error) {
1047 b := make([]byte, 32)
1048 if _, err := crypto_rand.Read(b); err != nil {
1049 return "", err
1050 }
1051 return hex.EncodeToString(b), nil
1052}
1053
1054// writeRemoteCloudInit writes the local cloud-init script to the remote server
1055// so that subsequent provision runs can accurately detect real changes.
1056// Uses base64 encoding to avoid heredoc nesting issues (the cloud-init script
1057// itself contains heredocs like CFGEOF and SVCEOF).
1058func writeRemoteCloudInit(ip, script string) error {
1059 encoded := base64.StdEncoding.EncodeToString([]byte(script))
1060 cmd := fmt.Sprintf("mkdir -p $(dirname %s) && echo '%s' | base64 -d > %s", cloudInitPath, encoded, cloudInitPath)
1061 _, err := runSSH(ip, cmd, false)
1062 return err
1063}
1064
1065// waitForSetup polls SSH availability on a newly created server, then waits
1066// for cloud-init to complete before returning.
1067func waitForSetup(ip, name string) error {
1068 fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip)
1069 for i := 0; i < 30; i++ {
1070 _, err := runSSH(ip, "echo ssh_ready", false)
1071 if err == nil {
1072 break
1073 }
1074 if i == 29 {
1075 return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip)
1076 }
1077 time.Sleep(10 * time.Second)
1078 }
1079
1080 fmt.Printf(" %s: waiting for cloud-init...\n", name)
1081 _, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false)
1082 if err != nil {
1083 return fmt.Errorf("cloud-init wait on %s: %w", name, err)
1084 }
1085 fmt.Printf(" %s: ready\n", name)
1086 return nil
1087}