readysite / hosting / internal / websites / launch.go
5.7 KB
launch.go
package websites

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

	"github.com/readysite/readysite/hosting/models"
)

// Launch provisions a newly created site. Intended to be called as a goroutine.
// Status stays "launching" throughout, with progress messages sent via SSE.
// Final status is "active" on success or "failed" on error.
func Launch(site *models.Site) {
	steps := []struct {
		message string
		fn      func(*models.Site) error
	}{
		{"Creating container...", provision},
		{"Configuring reverse proxy...", configure},
		{"Starting services...", start},
	}

	for i, step := range steps {
		progress(site, step.message)
		if err := step.fn(site); err != nil {
			log.Printf("[websites] Launch %s failed at step %d: %v", site.ID, i+1, err)
			cleanup(site)
			setStatus(site, "failed", err.Error())
			return
		}
	}

	setStatus(site, "active", "Site is live!")
	log.Printf("[websites] Launched %s", site.ID)
}

// Upgrade recreates a site's container with a persistent database volume.
// The existing container is stopped and removed, then recreated with a
// data directory mounted at /data and DB_PATH set.
func Upgrade(site *models.Site) error {
	name := containerName(site.ID)
	dataDir := filepath.Join(SiteDataDir, site.ID)

	// Create data directory
	if err := os.MkdirAll(dataDir, 0755); err != nil {
		return fmt.Errorf("create data dir: %w", err)
	}

	// Stop and remove existing container (short timeout since we're recreating)
	Docker("stop", "-t", "2", name)
	Docker("rm", "-f", name)

	// Recreate with data volume
	args := []string{
		"run", "-d",
		"--name", name,
		"--network", SiteNetwork,
		"--restart", "unless-stopped",
		"-v", dataDir + ":/data",
		"-e", "ENV=production",
		"-e", "PORT=5000",
		"-e", "DB_PATH=/data/app.db",
	}
	args = append(args, siteEnvArgs(site)...)
	args = append(args, SiteImage)

	_, err := Docker(args...)
	if err != nil {
		return fmt.Errorf("recreate container with volume: %w", err)
	}

	log.Printf("[websites] Upgraded %s with persistent database at %s", site.ID, dataDir)
	return nil
}

// RecoverLaunching resumes launching for any sites left in "launching" state
// (e.g. after an app restart). Each step is idempotent so re-running is safe.
func RecoverLaunching() {
	sites, err := models.Sites.Search("WHERE Status = ?", "launching")
	if err != nil {
		log.Printf("[websites] RecoverLaunching query failed: %v", err)
		return
	}
	if len(sites) == 0 {
		return
	}
	log.Printf("[websites] Recovering %d site(s) stuck in launching state", len(sites))
	for _, site := range sites {
		go Launch(site)
	}
}

// setStatus updates the site's status in the database and publishes an event.
func setStatus(site *models.Site, status, message string) {
	site.Status = status
	models.Sites.Update(site)
	publish(site.ID, Event{Status: status, Message: message})
}

// progress publishes a message-only event without changing the DB status.
func progress(site *models.Site, message string) {
	publish(site.ID, Event{Status: site.Status, Message: message})
}

// cleanup removes any resources created during a failed launch.
// Errors are logged but not propagated — best-effort cleanup.
func cleanup(site *models.Site) {
	name := containerName(site.ID)
	configPath := filepath.Join(SiteConfigDir, site.ID+".caddy")

	// Remove Caddy config if it was written
	if _, err := os.Stat(configPath); err == nil {
		os.Remove(configPath)
		Docker("exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile")
	}

	// Remove container if created
	Docker("rm", "-f", name)
}

// --- Free tier steps ---

// provision creates the Docker container for a site. Idempotent.
func provision(site *models.Site) error {
	name := containerName(site.ID)

	if containerExists(name) {
		return nil
	}

	args := []string{
		"run", "-d",
		"--name", name,
		"--network", SiteNetwork,
		"--restart", "unless-stopped",
		"-e", "ENV=production",
		"-e", "PORT=5000",
	}
	args = append(args, siteEnvArgs(site)...)
	args = append(args, SiteImage)

	_, err := Docker(args...)
	return err
}

// configure writes a per-site Caddy config and reloads Caddy. Idempotent.
func configure(site *models.Site) error {
	configPath := filepath.Join(SiteConfigDir, site.ID+".caddy")

	if _, err := os.Stat(configPath); err == nil {
		return nil
	}

	name := containerName(site.ID)
	config := fmt.Sprintf("%s%s {\n\treverse_proxy %s:5000\n}\n", site.ID, DomainSuffix, name)

	if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
		return fmt.Errorf("write caddy config: %w", err)
	}

	if _, err := Docker("exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"); err != nil {
		return fmt.Errorf("reload caddy: %w", err)
	}
	return nil
}

// siteEnvArgs returns Docker "-e" flags to pass site/owner info to the container.
// The website's auto-setup uses these to skip the setup wizard.
func siteEnvArgs(site *models.Site) []string {
	var args []string
	user, err := models.Users.Get(site.UserID)
	if err != nil {
		log.Printf("[websites] siteEnvArgs: failed to get user %s: %v", site.UserID, err)
		return args
	}

	args = append(args, "-e", "ADMIN_EMAIL="+user.Email)
	if user.Name != "" {
		args = append(args, "-e", "ADMIN_NAME="+user.Name)
	}
	if site.Name != "" {
		args = append(args, "-e", "SITE_NAME="+site.Name)
	}
	if site.Description != "" {
		args = append(args, "-e", "SITE_DESC="+site.Description)
	}
	if site.AuthSecret != "" {
		args = append(args, "-e", "AUTH_SECRET="+site.AuthSecret)
	}

	// Tell the website where the hosting dashboard is, so signin redirects there
	hostingURL := "https://" + strings.TrimPrefix(DomainSuffix, ".") + "/sites"
	args = append(args, "-e", "HOSTING_URL="+hostingURL)

	return args
}

// start ensures the site container is running. Idempotent.
func start(site *models.Site) error {
	name := containerName(site.ID)
	_, err := Docker("start", name)
	return err
}
← Back