4.9 KB
sleep.go
package websites

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"time"

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

const (
	sleepAfter   = 30 * time.Minute  // Sleep free sites idle for 30 min
	deleteAfter  = 30 * 24 * time.Hour // Delete free sites inactive for 30 days
	sleepTick    = 5 * time.Minute
	cleanupTick  = 1 * time.Hour
	wakeTimeout  = 15 * time.Second
)

// StartSleepScheduler starts background goroutines for sleeping idle free sites
// and cleaning up abandoned free sites.
func StartSleepScheduler() {
	go sleepLoop()
	go cleanupLoop()
}

// sleepLoop checks every 5 minutes for idle free sites and sleeps them.
func sleepLoop() {
	ticker := time.NewTicker(sleepTick)
	defer ticker.Stop()

	for range ticker.C {
		sites, err := models.Sites.Search("WHERE Plan = 'free' AND Status = 'active'")
		if err != nil {
			log.Printf("[sleep] query active free sites: %v", err)
			continue
		}

		now := time.Now()
		for _, site := range sites {
			if site.LastActiveAt.IsZero() || now.Sub(site.LastActiveAt) < sleepAfter {
				continue
			}
			if err := Sleep(site); err != nil {
				log.Printf("[sleep] failed to sleep %s: %v", site.ID, err)
			}
		}
	}
}

// cleanupLoop checks every hour for abandoned free sites and deletes them.
func cleanupLoop() {
	ticker := time.NewTicker(cleanupTick)
	defer ticker.Stop()

	for range ticker.C {
		cutoff := time.Now().Add(-deleteAfter)
		// Only clean up sleeping sites — active sites should never be auto-deleted
		sites, err := models.Sites.Search("WHERE Plan = 'free' AND Status = 'sleeping' AND LastActiveAt < ?", cutoff)
		if err != nil {
			log.Printf("[sleep] query abandoned free sites: %v", err)
			continue
		}

		for _, site := range sites {
			log.Printf("[sleep] deleting abandoned site %s (last active: %s)", site.ID, site.LastActiveAt.Format(time.RFC3339))
			if err := Destroy(site); err != nil {
				log.Printf("[sleep] destroy failed for %s: %v", site.ID, err)
				continue
			}
			site.Status = "deleted"
			if err := models.Sites.Update(site); err != nil {
				log.Printf("[sleep] failed to mark %s as deleted: %v", site.ID, err)
			}
		}
	}
}

// Sleep stops a free site's container and rewrites its Caddy config to point
// to the hosting app (which serves a wake page).
func Sleep(site *models.Site) error {
	name := containerName(site.ID)

	// Stop container gracefully (data persists on volume)
	if _, err := Docker("stop", name); err != nil {
		return fmt.Errorf("stop container: %w", err)
	}

	// Rewrite Caddy config to point to the hosting app
	configPath := filepath.Join(SiteConfigDir, site.ID+".caddy")
	config := fmt.Sprintf("%s%s {\n\treverse_proxy readysite-org:5000\n}\n", site.ID, DomainSuffix)
	if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
		return fmt.Errorf("write caddy config: %w", err)
	}

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

	site.Status = "sleeping"
	if err := models.Sites.Update(site); err != nil {
		return fmt.Errorf("update site status: %w", err)
	}

	log.Printf("[sleep] site %s is now sleeping", site.ID)
	return nil
}

// Wake starts a sleeping site's container and restores its Caddy config.
func Wake(site *models.Site) error {
	name := containerName(site.ID)

	// Start the container
	if _, err := Docker("start", name); err != nil {
		return fmt.Errorf("start container: %w", err)
	}

	// Wait for the container to become healthy
	if err := waitForHealthy(site.ID); err != nil {
		log.Printf("[wake] %s health check failed (continuing anyway): %v", site.ID, err)
	}

	// Rewrite Caddy config back to point at the site container
	configPath := filepath.Join(SiteConfigDir, site.ID+".caddy")
	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)
	}

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

	site.Status = "active"
	site.LastActiveAt = time.Now()
	if err := models.Sites.Update(site); err != nil {
		return fmt.Errorf("update site status: %w", err)
	}

	log.Printf("[wake] site %s is now awake", site.ID)
	return nil
}

// waitForHealthy polls the site's /health endpoint until it responds or timeout.
func waitForHealthy(siteID string) error {
	name := containerName(siteID)
	url := fmt.Sprintf("http://%s:5000/health", name)
	deadline := time.Now().Add(wakeTimeout)

	client := &http.Client{Timeout: 2 * time.Second}
	for time.Now().Before(deadline) {
		resp, err := client.Get(url)
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				return nil
			}
		}
		time.Sleep(500 * time.Millisecond)
	}
	return fmt.Errorf("site %s did not become healthy within %s", siteID, wakeTimeout)
}
← Back