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