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)")
}