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
}