package staticsite import ( "context" "fmt" "io" "log/slog" "os" "path/filepath" "strconv" "time" "github.com/moby/moby/api/types/mount" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/events" "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/staticsite/deno" "github.com/alexei/tinyforge/internal/store" ) // Manager orchestrates the static site deployment pipeline. type Manager struct { store *store.Store docker *docker.Client proxyProvider proxy.Provider eventBus *events.Bus encKey [32]byte } // NewManager creates a new static site manager. func NewManager( st *store.Store, dockerClient *docker.Client, proxyProvider proxy.Provider, eventBus *events.Bus, encKey [32]byte, ) *Manager { return &Manager{ store: st, docker: dockerClient, proxyProvider: proxyProvider, eventBus: eventBus, encKey: encKey, } } // SetProxyProvider updates the proxy provider at runtime. func (m *Manager) SetProxyProvider(provider proxy.Provider) { m.proxyProvider = provider } // Deploy fetches content from Gitea and deploys a static site container. // If force is true, skips the "no changes" check and always rebuilds/redeploys. func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { site, err := m.store.GetStaticSiteByID(siteID) if err != nil { return fmt.Errorf("get site: %w", err) } // Decrypt access token if present. token := "" if site.AccessToken != "" { decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken) if err != nil { slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err) } else { token = decrypted } } provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token) if err != nil { m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err)) return fmt.Errorf("create provider: %w", err) } // Check if there's a new commit. latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch) if err != nil { m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err)) return fmt.Errorf("get latest commit: %w", err) } // Skip redeploy only if SHA matches, status is deployed, container is running, // proxy route exists, AND force is false. Manual deploys always force a full rebuild. if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" { running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) if !running { slog.Info("static site: container not running, forcing redeploy", "site", site.Name) } else if site.Domain != "" { // Also verify the proxy route still exists (it may have been deleted externally). proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain) if err != nil { slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err) } else if !proxyOK { slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name) } else { slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) return nil } } else { slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) return nil } } // Update status to syncing. m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "") m.publishEvent(site.ID, site.Name, "syncing") // Create temp directory for the build context. buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*") if err != nil { m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err)) return fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(buildDir) // Download folder contents. if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil { m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err)) return fmt.Errorf("download folder: %w", err) } // Render markdown if enabled. if site.RenderMarkdown { if err := RenderMarkdownFiles(buildDir); err != nil { slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err) } } // Determine mode: check for api/ subdirectory. mode := site.Mode apiDir := filepath.Join(buildDir, "api") hasAPI := false if info, err := os.Stat(apiDir); err == nil && info.IsDir() { hasAPI = true } if mode == "deno" && !hasAPI { // Fallback to static if no api/ folder found. mode = "static" slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name) } // Prepare build context based on mode. imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name) contextDir, err := os.MkdirTemp("", "dw-site-build-*") if err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err)) return fmt.Errorf("create build context dir: %w", err) } defer os.RemoveAll(contextDir) if mode == "deno" { if err := m.prepareDenoBuild(buildDir, contextDir); err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err)) return fmt.Errorf("prepare deno build: %w", err) } } else { if err := m.prepareStaticBuild(buildDir, contextDir); err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err)) return fmt.Errorf("prepare static build: %w", err) } } // Build Docker image. if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err)) return fmt.Errorf("build image: %w", err) } // Prepare environment variables (secrets). env, err := m.buildEnvVars(site.ID) if err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err)) return fmt.Errorf("build env vars: %w", err) } // Determine container port. containerPort := "80" if mode == "deno" { containerPort = "8000" } // Get network settings. settings, err := m.store.GetSettings() if err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err)) return fmt.Errorf("get settings: %w", err) } networkName := settings.Network networkID, err := m.docker.EnsureNetwork(ctx, networkName) if err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err)) return fmt.Errorf("ensure network: %w", err) } containerName := fmt.Sprintf("dw-site-%s", site.Name) // Prepare volume mounts for persistent storage. var mounts []mount.Mount if site.StorageEnabled && mode == "deno" { volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name) if volErr != nil { slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr) } else { mounts = append(mounts, mount.Mount{ Type: mount.TypeVolume, Source: volName, Target: "/app/data", }) slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName) } } // Create and start new container. containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, Image: imageTag, Env: env, ExposedPorts: []string{containerPort + "/tcp"}, NetworkName: networkName, NetworkID: networkID, Mounts: mounts, Labels: map[string]string{ "tinyforge.static-site": site.ID, "tinyforge.static-site-name": site.Name, }, Project: "static-site", Stage: site.Name, }) if err != nil { // Container might already exist — try to remove and recreate. if site.ContainerID != "" { m.docker.StopContainer(ctx, site.ContainerID, 10) m.docker.RemoveContainer(ctx, site.ContainerID, true) } // Also try by name. m.removeContainerByName(ctx, containerName) containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, Image: imageTag, Env: env, ExposedPorts: []string{containerPort + "/tcp"}, NetworkName: networkName, NetworkID: networkID, Mounts: mounts, Labels: map[string]string{ "tinyforge.static-site": site.ID, "tinyforge.static-site-name": site.Name, }, Project: "static-site", Stage: site.Name, }) if err != nil { m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err)) return fmt.Errorf("create container: %w", err) } } if err := m.docker.StartContainer(ctx, containerID); err != nil { m.docker.RemoveContainer(ctx, containerID, true) m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err)) return fmt.Errorf("start container: %w", err) } // Brief health check: wait 3 seconds and verify container is still running. time.Sleep(3 * time.Second) running, err := m.docker.IsContainerRunning(ctx, containerID) if err != nil || !running { // Grab container logs for the error message. logMsg := "container exited immediately after start" if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil { buf, _ := io.ReadAll(logs) logs.Close() if len(buf) > 0 { logMsg = string(buf) // Truncate to reasonable length. if len(logMsg) > 500 { logMsg = logMsg[:500] + "..." } } } m.docker.RemoveContainer(ctx, containerID, true) m.updateStatus(site.ID, "failed", latestSHA, logMsg) return fmt.Errorf("container not running: %s", logMsg) } // Determine proxy target: container name + internal port (default), // or server IP + host port for NPM remote mode. internalPort, _ := strconv.Atoi(containerPort) forwardHost := containerName forwardPort := internalPort if settings.NpmRemote && settings.ProxyProvider == "npm" { if settings.ServerIP != "" { hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp") if err != nil { slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err) } else { forwardHost = settings.ServerIP forwardPort = int(hostPort) } } } // Configure proxy if domain is set. proxyRouteID := site.ProxyRouteID if site.Domain != "" { // Remove old proxy route if exists. if site.ProxyRouteID != "" { m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID) } routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ SSLCertificateID: settings.SSLCertificateID, }) if err != nil { slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err) } else { proxyRouteID = routeID slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID) } } // Remove old container if different. if site.ContainerID != "" && site.ContainerID != containerID { m.docker.StopContainer(ctx, site.ContainerID, 10) m.docker.RemoveContainer(ctx, site.ContainerID, true) } // Update site status. if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil { slog.Error("static site: failed to update container info", "site", site.Name, "error", err) } m.updateStatus(site.ID, "deployed", latestSHA, "") m.publishEvent(site.ID, site.Name, "deployed") slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode) return nil } // Remove stops and removes a static site's container and proxy route. func (m *Manager) Remove(ctx context.Context, siteID string) error { site, err := m.store.GetStaticSiteByID(siteID) if err != nil { return fmt.Errorf("get site: %w", err) } // Remove proxy route (best effort). if site.ProxyRouteID != "" { if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) } } // Stop and remove container (best effort). if site.ContainerID != "" { m.docker.StopContainer(ctx, site.ContainerID, 10) if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil { slog.Warn("static site: failed to remove container", "site", site.Name, "error", err) } } // Remove storage volume if it was enabled (best effort). if site.StorageEnabled { if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil { slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err) } } return nil } // Stop stops a running static site container and removes its proxy route. // The container is kept (not removed) so Start can bring it back without a full rebuild. func (m *Manager) Stop(ctx context.Context, siteID string) error { site, err := m.store.GetStaticSiteByID(siteID) if err != nil { return fmt.Errorf("get site: %w", err) } // Remove proxy route first (best effort). if site.ProxyRouteID != "" { if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) } } // Stop container. if site.ContainerID != "" { if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil { slog.Warn("static site: failed to stop container", "site", site.Name, "error", err) } } // Clear proxy route ID; keep container ID. if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil { slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err) } m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "") m.publishEvent(site.ID, site.Name, "stopped") slog.Info("static site stopped", "site", site.Name) return nil } // Start starts a previously stopped static site container and reconfigures the proxy. // If the container no longer exists, it triggers a full redeploy. func (m *Manager) Start(ctx context.Context, siteID string) error { site, err := m.store.GetStaticSiteByID(siteID) if err != nil { return fmt.Errorf("get site: %w", err) } // If no container exists, do a full deploy. if site.ContainerID == "" { return m.Deploy(ctx, siteID, true) } // Try to start the existing container. if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil { slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err) return m.Deploy(ctx, siteID, true) } // Verify it's running after a brief wait. time.Sleep(2 * time.Second) running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) if !running { return m.Deploy(ctx, siteID, true) } // Reconfigure proxy if domain is set. settings, err := m.store.GetSettings() if err == nil && site.Domain != "" { containerPort := "80" if site.Mode == "deno" { containerPort = "8000" } internalPort, _ := strconv.Atoi(containerPort) containerName := fmt.Sprintf("dw-site-%s", site.Name) forwardHost := containerName forwardPort := internalPort if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" { if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil { forwardHost = settings.ServerIP forwardPort = int(hp) } } routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ SSLCertificateID: settings.SSLCertificateID, }) if err != nil { slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err) } else { m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID) } } m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "") m.publishEvent(site.ID, site.Name, "deployed") slog.Info("static site started", "site", site.Name) return nil } // TestConnection tests connectivity to a Git repository. func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error { provider, err := m.createProvider(providerType, baseURL, accessToken) if err != nil { return err } return provider.TestConnection(ctx, owner, repo) } // ListBranches returns branches for a Git repository. func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) { provider, err := m.createProvider(providerType, baseURL, accessToken) if err != nil { return nil, err } return provider.ListBranches(ctx, owner, repo) } // ListTree returns the repository tree for the folder picker. func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) { provider, err := m.createProvider(providerType, baseURL, accessToken) if err != nil { return nil, err } return provider.ListTree(ctx, owner, repo, branch) } // ListRepos returns repositories from a Git server. func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) { provider, err := m.createProvider(providerType, baseURL, accessToken) if err != nil { return nil, err } return provider.ListRepos(ctx, query) } // DetectProvider autodetects the Git provider from a URL, with API probing. func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string { return string(DetectProviderWithProbe(ctx, baseURL)) } // createProvider builds a GitProvider from encrypted credentials. func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) { token := "" if accessToken != "" { decrypted, err := crypto.Decrypt(m.encKey, accessToken) if err != nil { token = accessToken // might be plaintext } else { token = decrypted } } return NewGitProvider(ProviderType(providerType), baseURL, token) } // prepareDenoBuild sets up the build context for a Deno container. func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error { // Move api/ to context. apiSrc := filepath.Join(srcDir, "api") apiDst := filepath.Join(contextDir, "api") if err := os.Rename(apiSrc, apiDst); err != nil { return fmt.Errorf("move api dir: %w", err) } // Move remaining files to public/. publicDir := filepath.Join(contextDir, "public") if err := os.Rename(srcDir, publicDir); err != nil { // If rename fails (cross-device), use copy. if err := copyDir(srcDir, publicDir); err != nil { return fmt.Errorf("copy public dir: %w", err) } } // Scan routes and generate router. routes, err := deno.ScanRoutes(apiDst) if err != nil { return fmt.Errorf("scan routes: %w", err) } routerSrc, err := deno.GenerateRouter(routes) if err != nil { return fmt.Errorf("generate router: %w", err) } if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil { return fmt.Errorf("write router.ts: %w", err) } // Generate Dockerfile. dockerfile := deno.GenerateDockerfile() if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { return fmt.Errorf("write Dockerfile: %w", err) } return nil } // prepareStaticBuild sets up the build context for a static nginx container. func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error { // Copy all files to context directory. if err := copyDir(srcDir, contextDir); err != nil { return fmt.Errorf("copy files: %w", err) } // Generate Dockerfile. dockerfile := deno.GenerateStaticDockerfile() if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { return fmt.Errorf("write Dockerfile: %w", err) } return nil } // buildEnvVars decrypts secrets and builds environment variable list. func (m *Manager) buildEnvVars(siteID string) ([]string, error) { secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID) if err != nil { return nil, fmt.Errorf("get secrets: %w", err) } env := make([]string, 0, len(secrets)) for _, s := range secrets { value := s.Value if s.Encrypted { decrypted, err := crypto.Decrypt(m.encKey, value) if err != nil { return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err) } value = decrypted } env = append(env, s.Key+"="+value) } return env, nil } // removeContainerByName removes a container by its name (best effort). func (m *Manager) removeContainerByName(ctx context.Context, name string) { containers, err := m.docker.ListContainers(ctx, nil) if err != nil { return } for _, c := range containers { if c.Name == name { m.docker.StopContainer(ctx, c.ID, 10) m.docker.RemoveContainer(ctx, c.ID, true) return } } } // updateStatus updates the site status in the database. // On failure, it also publishes an event to the event log. func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) { if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil { slog.Error("static site: failed to update status", "id", id, "status", status, "error", err) } // Persist failures to event log automatically. if status == "failed" { site, err := m.store.GetStaticSiteByID(id) siteName := id if err == nil { siteName = site.Name } m.publishEvent(id, siteName, "failed: "+errMsg) } } // publishEvent publishes a static site status event on the event bus // and persists it to the event log for the dashboard. func (m *Manager) publishEvent(siteID, siteName, status string) { m.eventBus.Publish(events.Event{ Type: events.EventStaticSiteStatus, Payload: events.StaticSiteStatusPayload{ SiteID: siteID, Name: siteName, Status: status, }, }) // Persist to event log. severity := "info" message := fmt.Sprintf("Static site \"%s\": %s", siteName, status) if status == "failed" { severity = "error" } metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status) evt, err := m.store.InsertEvent(store.EventLog{ Source: "static_site", Severity: severity, Message: message, Metadata: metadata, }) if err != nil { slog.Error("static site: failed to persist event log", "error", err) return } // Publish the persisted event for SSE clients. m.eventBus.Publish(events.Event{ Type: events.EventLog, Payload: events.EventLogPayload{ ID: evt.ID, Source: "static_site", Severity: severity, Message: message, Metadata: metadata, CreatedAt: evt.CreatedAt, }, }) } // copyDir recursively copies a directory. func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } dstPath := filepath.Join(dst, relPath) if info.IsDir() { return os.MkdirAll(dstPath, 0o755) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(dstPath, data, info.Mode()) }) } // hostPortStr converts a uint16 port to a string for proxy configuration. func hostPortStr(port uint16) string { return strconv.FormatUint(uint64(port), 10) }