provision.go

146 lines
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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
package scaffold

import (
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"
	"time"

	"congo.gg/pkg/platform"
)

// CreateInstance provisions a new server and waits for SSH.
func CreateInstance(p *platform.Platform, name string, spec ServerSpec, region string) (*Instance, error) {
	log.Printf("Creating server: %s", name)

	sshKey, err := readSSHKey()
	if err != nil {
		return nil, fmt.Errorf("ssh key: %w", err)
	}

	var fingerprint string
	if sshProvider, ok := p.Backend.(platform.SSHKeyProvider); ok {
		fingerprint, err = sshProvider.GetSSHKeyFingerprint(sshKey)
		if err != nil {
			return nil, fmt.Errorf("ssh key fingerprint: %w", err)
		}
	}

	opts := platform.ServerOptions{
		Name:   name,
		Region: platform.Region(region),
		Size:   platform.Size(spec.Size),
		Image:  "ubuntu-24-04-x64",
		SSHKey: fingerprint,
	}

	server, err := p.CreateServer(opts)
	if err != nil {
		return nil, fmt.Errorf("create server: %w", err)
	}

	log.Printf("Server created: %s (%s)", server.Name, server.IP)
	log.Printf("Waiting for SSH...")
	if err := server.WaitForSSH(5 * time.Minute); err != nil {
		return nil, fmt.Errorf("ssh wait: %w", err)
	}

	log.Printf("Installing Docker...")
	if err := installDocker(server); err != nil {
		return nil, fmt.Errorf("install docker: %w", err)
	}

	return &Instance{
		ID:     server.ID,
		Name:   server.Name,
		IP:     server.IP,
		Region: server.Region,
	}, nil
}

func installDocker(server *platform.Server) error {
	script := []byte(`#!/bin/bash
set -e
if command -v docker &>/dev/null; then exit 0; fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin
systemctl enable --now docker
`)
	if err := server.Write("/tmp/install-docker.sh", script, true); err != nil {
		return err
	}
	output, err := server.SSHWithTimeout(5*time.Minute, "bash", "/tmp/install-docker.sh")
	if err != nil {
		return fmt.Errorf("%w\n%s", err, output)
	}
	return nil
}

func readSSHKey() (string, error) {
	home, _ := os.UserHomeDir()
	for _, name := range []string{"id_ed25519.pub", "id_rsa.pub"} {
		data, err := os.ReadFile(filepath.Join(home, ".ssh", name))
		if err == nil {
			return string(data), nil
		}
	}
	return "", fmt.Errorf("no SSH public key found (~/.ssh/id_ed25519.pub or id_rsa.pub)")
}

// BuildSourceImages builds Docker images for all source-based services.
func BuildSourceImages(services []string, specs map[string]ServiceSpec) (map[string]string, error) {
	imagePaths := make(map[string]string)
	for _, svcName := range services {
		svcSpec := specs[svcName]
		if svcSpec.Source == "" {
			continue
		}
		log.Printf("Building %s from %s...", svcName, svcSpec.Source)
		imgPath, err := BuildImage(svcName, svcSpec.Source)
		if err != nil {
			return nil, err
		}
		imagePaths[svcName] = imgPath
	}
	return imagePaths, nil
}

// SyncSecrets pushes local env vars to the server for empty env_files.
func SyncSecrets(server *platform.Server, services map[string]ServiceSpec) {
	synced := make(map[string]bool)
	for _, svc := range services {
		for envName, filePath := range svc.EnvFiles {
			if synced[filePath] {
				continue
			}
			synced[filePath] = true

			if !SafePath(filePath) {
				log.Printf("   Warning: skipping %s — invalid path %q", envName, filePath)
				continue
			}

			localVal := os.Getenv(envName)
			if localVal == "" {
				continue
			}

			existing, _ := server.SSH("cat", filePath)
			if strings.TrimSpace(existing) != "" {
				continue
			}

			log.Printf("   Syncing %s to %s", envName, filePath)
			server.Write(filePath, []byte(localVal), false)
		}
	}
}