readysite / cmd / launch / main.go
9.1 KB
main.go
package main

import (
	"cmp"
	"flag"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"time"

	"github.com/readysite/readysite/pkg/platform"
	"github.com/readysite/readysite/pkg/platform/providers/digitalocean"
)

const usage = `
ReadySite - Deploy to Servers

Usage:
  launch [flags]

Flags:
  --server <name>       Deploy only to this server instance
  --new <app>           Create new server and deploy app
  --region <region>     Override default region from infra.json
  --skip-tests          Skip running tests before deploy

Configuration (infra.json):
  platform.provider     Cloud provider (digitalocean)
  platform.token        API token (supports $ENV_VAR)
  platform.project      Project UUID for resource tagging (supports $ENV_VAR)
  platform.region       Default region

Examples:
  launch                      # Build and deploy all to all servers
  launch --server example-1   # Deploy only to example-1
  launch --new example        # Create new server for example app
`

func main() {
	flag.Usage = func() { fmt.Print(usage) }

	var (
		serverName = flag.String("server", "", "Deploy only to this server")
		newApp     = flag.String("new", "", "Create new server for app")
		regionFlag = flag.String("region", "", "Override default region")
		skipTests  = flag.Bool("skip-tests", false, "Skip running tests before deploy")
	)

	flag.Parse()

	// Run tests before deploying
	if !*skipTests {
		if err := runTests(); err != nil {
			log.Fatalf("Tests failed, aborting deploy: %v", err)
		}
	}

	// Load infra.json from current directory
	cfg, err := loadInfraConfig(".")
	if err != nil {
		log.Fatalf("load infra.json: %v", err)
	}

	// Initialize provider from config
	p, err := initProvider(cfg.Platform)
	if err != nil {
		log.Fatal(err)
	}

	// Use region from flag, config, or default
	region := cmp.Or(*regionFlag, cfg.Platform.Region, "sfo")

	// Mode: Create new server for app
	if *newApp != "" {
		if err := createNew(p, cfg, *newApp, region); err != nil {
			log.Fatal(err)
		}
		return
	}

	// Mode: Deploy to specific server
	if *serverName != "" {
		if err := deployToServerByName(p, cfg, *serverName); err != nil {
			log.Fatal(err)
		}
		return
	}

	// Mode: Deploy to all servers
	if err := deployAll(p, cfg, region); err != nil {
		log.Fatal(err)
	}
}

// createNew creates a new server instance for the given app.
func createNew(p *platform.Platform, cfg *InfraConfig, appName, region string) error {
	// Verify app exists in binaries
	binSpec, ok := cfg.Binaries[appName]
	if !ok {
		return fmt.Errorf("app %q not found in binaries section of infra.json", appName)
	}

	// Find which server type runs this app
	var serverType string
	var spec ServerSpec
	for st, s := range cfg.Servers {
		for _, bin := range s.Binaries {
			if bin == appName {
				serverType = st
				spec = s
				break
			}
		}
	}
	if serverType == "" {
		return fmt.Errorf("no server type configured to run %q", appName)
	}

	// Generate instance name (app-N format)
	instanceNum := len(cfg.Instances[serverType]) + 1
	instanceName := fmt.Sprintf("%s-%d", appName, instanceNum)

	// Create the server
	inst, err := createInstanceByName(p, instanceName, spec, region, cfg.Platform.Project)
	if err != nil {
		return fmt.Errorf("create server: %w", err)
	}

	// Save to infra.json
	cfg.Instances[serverType] = append(cfg.Instances[serverType], *inst)
	if err := cfg.save("."); err != nil {
		return fmt.Errorf("save infra.json: %w", err)
	}

	// Build and deploy
	binPath, err := build(appName, binSpec.Source)
	if err != nil {
		return err
	}

	if err := deployToInstance(p, binPath, cfg, serverType, inst); err != nil {
		return fmt.Errorf("deploy: %w", err)
	}

	log.Printf("Created and deployed %s to %s (%s)", appName, inst.Name, inst.IP)
	return nil
}

// deployToServerByName deploys to a specific server instance.
func deployToServerByName(p *platform.Platform, cfg *InfraConfig, serverName string) error {
	// Find the instance and its server type
	var inst *Instance
	var serverType string
	for st, instances := range cfg.Instances {
		for i := range instances {
			if instances[i].Name == serverName {
				inst = &instances[i]
				serverType = st
				break
			}
		}
	}

	if inst == nil {
		return fmt.Errorf("server %q not found", serverName)
	}

	spec := cfg.Servers[serverType]

	// Build all binaries for this server type
	binPaths := make(map[string]string)
	for _, binName := range spec.Binaries {
		binSpec := cfg.Binaries[binName]
		log.Printf("Building %s from %s...", binName, binSpec.Source)
		binPath, err := build(binName, binSpec.Source)
		if err != nil {
			return err
		}
		binPaths[binName] = binPath
	}

	// Deploy
	if err := deployToInstance(p, binPaths, cfg, serverType, inst); err != nil {
		return fmt.Errorf("deploy: %w", err)
	}

	log.Printf("Deployed to %s (%s)", inst.Name, inst.IP)
	return nil
}

// deployAll builds all binaries and deploys to all server instances.
func deployAll(p *platform.Platform, cfg *InfraConfig, region string) error {
	if len(cfg.Instances) == 0 {
		log.Println("No servers deployed. Use 'launch --new <app>' to create one.")
		return nil
	}

	serverOrder := topologicalSort(cfg.Servers)
	for _, serverType := range serverOrder {
		instances := cfg.Instances[serverType]
		if len(instances) == 0 {
			continue
		}

		spec := cfg.Servers[serverType]

		// Build all binaries for this server type
		binPaths := make(map[string]string)
		for _, binName := range spec.Binaries {
			binSpec := cfg.Binaries[binName]
			log.Printf("Building %s from %s...", binName, binSpec.Source)
			binPath, err := build(binName, binSpec.Source)
			if err != nil {
				return err
			}
			binPaths[binName] = binPath
		}

		// Deploy to all instances
		for i := range instances {
			if err := deployToInstance(p, binPaths, cfg, serverType, &instances[i]); err != nil {
				return fmt.Errorf("deploy to %s: %w", instances[i].Name, err)
			}
		}
	}

	log.Println("Deployment complete")
	return nil
}

func initProvider(cfg PlatformConfig) (*platform.Platform, error) {
	if cfg.Token == "" {
		return nil, fmt.Errorf("platform.token required in infra.json")
	}

	switch cfg.Provider {
	case "digitalocean":
		return digitalocean.New(cfg.Token)
	default:
		return nil, fmt.Errorf("unsupported provider: %s", cfg.Provider)
	}
}

func build(name, source string) (string, error) {
	if err := os.MkdirAll("build", 0755); err != nil {
		return "", err
	}

	dockerfile := filepath.Join(source, "Dockerfile")
	if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
		return "", fmt.Errorf("Dockerfile not found: %s", dockerfile)
	}

	// Check if Docker is available locally
	if err := exec.Command("docker", "info").Run(); err != nil {
		log.Printf("   Local Docker unavailable, will build on server")
		return "", nil // Signal to build on server
	}

	// Docker build
	buildCmd := exec.Command("docker", "build", "-t", name, "-f", dockerfile, ".")
	buildCmd.Stdout = os.Stdout
	buildCmd.Stderr = os.Stderr
	if err := buildCmd.Run(); err != nil {
		return "", fmt.Errorf("docker build failed: %w", err)
	}

	// Docker save (gzipped)
	out := filepath.Join("build", name+".tar.gz")
	saveCmd := exec.Command("bash", "-c", fmt.Sprintf("docker save %s | gzip > %s", name, out))
	saveCmd.Stdout = os.Stdout
	saveCmd.Stderr = os.Stderr
	if err := saveCmd.Run(); err != nil {
		return "", fmt.Errorf("docker save failed: %w", err)
	}

	return out, nil
}

func createInstanceByName(
	p *platform.Platform,
	name string,
	spec ServerSpec,
	region string,
	project string,
) (*Instance, error) {
	log.Printf("Creating server: %s", name)

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

	// Get fingerprint from provider (finds or registers key)
	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)
		}
	}

	tags := []string{"readysite"}
	if project != "" {
		tags = append(tags, "project:"+project)
	}

	server, err := p.CreateServer(platform.ServerOptions{
		Name:   name,
		Region: platform.Region(region),
		Size:   platform.Size(spec.Size),
		Image:  "docker-20-04",
		SSHKey: fingerprint,
		Tags:   tags,
	})
	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)
	}

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

func runTests() error {
	log.Println("Running tests...")
	cmd := exec.Command("go", "test", "-timeout", "5m", "./...")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

func readSSHKey() (string, error) {
	home, _ := os.UserHomeDir()
	paths := []string{
		filepath.Join(home, ".ssh", "id_ed25519.pub"),
		filepath.Join(home, ".ssh", "id_rsa.pub"),
	}

	for _, p := range paths {
		data, err := os.ReadFile(p)
		if err == nil {
			return string(data), nil
		}
	}

	return "", fmt.Errorf("no SSH public key found (tried ~/.ssh/id_ed25519.pub, ~/.ssh/id_rsa.pub)")
}
← Back