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/notify" "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 notifier *notify.Notifier encKey [32]byte } // NewManager creates a new static site manager. func NewManager( st *store.Store, dockerClient *docker.Client, proxyProvider proxy.Provider, eventBus *events.Bus, notifier *notify.Notifier, encKey [32]byte, ) *Manager { return &Manager{ store: st, docker: dockerClient, proxyProvider: proxyProvider, eventBus: eventBus, notifier: notifier, encKey: encKey, } } // SetProxyProvider updates the proxy provider at runtime. func (m *Manager) SetProxyProvider(provider proxy.Provider) { m.proxyProvider = provider } // resolveSiteWorkloadID returns the workload ID paired with a static site. // Boot-time backfill guarantees the row exists; on lookup miss this returns // empty so the caller can decide (the deployer continues without the label). func (m *Manager) resolveSiteWorkloadID(siteID string) string { w, err := m.store.GetWorkloadByRef(store.WorkloadKindSite, siteID) if err != nil { slog.Warn("static site: resolve workload", "site_id", siteID, "error", err) return "" } return w.ID } // upsertSiteContainer keeps the global container index in sync with the // site's current container. Row ID is deterministic (workloadID + ":site") // so re-deploys update in place. Best-effort. func (m *Manager) upsertSiteContainer(site store.StaticSite, containerID, state string) { workloadID := m.resolveSiteWorkloadID(site.ID) if workloadID == "" { return } if err := m.store.UpsertContainer(store.Container{ ID: workloadID + ":site", WorkloadID: workloadID, WorkloadKind: string(store.WorkloadKindSite), Role: "", ContainerID: containerID, Host: "local", State: state, Subdomain: site.Domain, ProxyRouteID: site.ProxyRouteID, LastSeenAt: store.Now(), }); err != nil { slog.Warn("static site: upsert container row", "site_id", site.ID, "error", err) } } // markSiteContainerState bulk-updates state for the site's container row. // Used by Stop/Start which only flip state. func (m *Manager) markSiteContainerState(siteID, state string) { workloadID := m.resolveSiteWorkloadID(siteID) if workloadID == "" { return } rowID := workloadID + ":site" if err := m.store.UpdateContainerState(rowID, state); err != nil { // NotFound is fine — the site may have never deployed. slog.Debug("static site: update container state", "row", rowID, "error", err) } } // 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, }, WorkloadID: m.resolveSiteWorkloadID(site.ID), WorkloadKind: string(store.WorkloadKindSite), Role: "", }) 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, }, WorkloadID: m.resolveSiteWorkloadID(site.ID), WorkloadKind: string(store.WorkloadKindSite), Role: "", }) 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) } site.ContainerID = containerID site.ProxyRouteID = proxyRouteID m.upsertSiteContainer(site, containerID, "running") 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.markSiteContainerState(site.ID, "stopped") 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.markSiteContainerState(site.ID, "running") 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. On terminal // state transitions (deployed / failed), it dispatches an outgoing // notification using the per-site URL+secret with fall-through to global. 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) } if status == "deployed" || status == "failed" { m.dispatchSiteNotification(id, status, errMsg) } } // dispatchSiteNotification emits a site_sync_success or site_sync_failure // event to the configured outgoing webhook. Resolution: per-site URL+secret // first, falling through to the global settings.notification_url/secret. // Always best-effort — failures are logged but never block status updates. func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) { if m.notifier == nil { return } site, err := m.store.GetStaticSiteByID(siteID) if err != nil { slog.Warn("static site: notify lookup failed", "site", siteID, "error", err) return } settings, err := m.store.GetSettings() if err != nil { slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err) return } url, secret, tier := resolveSiteTarget(site, settings) if url == "" { return } eventType := "site_sync_success" if status == "failed" { eventType = "site_sync_failure" } siteURL := "" if site.Domain != "" { siteURL = "https://" + site.Domain } m.notifier.SendSigned(url, secret, tier, notify.Event{ Type: eventType, Project: site.Name, URL: siteURL, Error: errMsg, }) } // resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site // URL beats global, secret travels with the URL that sourced it. func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) { if site.NotificationURL != "" { return site.NotificationURL, site.NotificationSecret, notify.TierSite } return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings } // 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) }