feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s

The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
This commit is contained in:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
-111
View File
@@ -1,111 +0,0 @@
package staticsite
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
"github.com/robfig/cron/v3"
)
// HealthChecker periodically checks that deployed static site containers
// are still running. If a container has crashed, it updates the site status
// to "failed" and optionally triggers a redeploy.
type HealthChecker struct {
store *store.Store
docker *docker.Client
manager *Manager
cron *cron.Cron
mu sync.Mutex
entryID cron.EntryID
running bool
}
// NewHealthChecker creates a new static site health checker.
func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker {
return &HealthChecker{
store: st,
docker: dockerClient,
manager: mgr,
cron: cron.New(),
}
}
// Start begins the periodic health check with the given interval (e.g., "5m", "1m").
func (h *HealthChecker) Start(interval string) error {
h.mu.Lock()
defer h.mu.Unlock()
duration, err := time.ParseDuration(interval)
if err != nil {
return fmt.Errorf("parse interval %q: %w", interval, err)
}
if h.running {
h.cron.Remove(h.entryID)
}
spec := fmt.Sprintf("@every %s", duration)
id, err := h.cron.AddFunc(spec, h.check)
if err != nil {
return fmt.Errorf("schedule health check: %w", err)
}
h.entryID = id
h.running = true
h.cron.Start()
slog.Info("static site health checker started", "interval", interval)
return nil
}
// Stop stops the periodic health checker.
func (h *HealthChecker) Stop() {
h.mu.Lock()
defer h.mu.Unlock()
if h.running {
h.cron.Stop()
h.running = false
slog.Info("static site health checker stopped")
}
}
// check runs a single health check pass over all deployed static sites.
func (h *HealthChecker) check() {
sites, err := h.store.GetAllStaticSites()
if err != nil {
slog.Error("static site health check: failed to list sites", "error", err)
return
}
ctx := context.Background()
for _, site := range sites {
if site.Status != "deployed" || site.ContainerID == "" {
continue
}
running, err := h.docker.IsContainerRunning(ctx, site.ContainerID)
if err != nil {
// Container might have been removed externally.
slog.Warn("static site health check: container inspect failed",
"site", site.Name, "container", site.ContainerID[:12], "error", err)
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found")
h.manager.publishEvent(site.ID, site.Name, "failed: container not found")
continue
}
if !running {
slog.Warn("static site health check: container not running",
"site", site.Name, "container", site.ContainerID[:12])
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly")
h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly")
}
}
}
-834
View File
@@ -1,834 +0,0 @@
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)
}
-63
View File
@@ -1,63 +0,0 @@
package staticsite
import (
"testing"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// TestResolveSiteTarget locks the per-site → global precedence for static
// site sync notifications. Distinct from the deploy resolver because there
// is no project tier between site and settings; a regression that swapped
// the order would silently route per-site events to the global receiver.
func TestResolveSiteTarget(t *testing.T) {
cases := []struct {
name string
site store.StaticSite
settings store.Settings
wantURL string
wantSec string
wantTier notify.Tier
}{
{
name: "site wins when URL set",
site: store.StaticSite{NotificationURL: "https://site.example/wh", NotificationSecret: "site-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://site.example/wh",
wantSec: "site-key",
wantTier: notify.TierSite,
},
{
name: "site URL empty → global wins",
site: store.StaticSite{},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://global.example/wh",
wantSec: "global-key",
wantTier: notify.TierSettings,
},
{
name: "both empty → empty URL with settings tier",
site: store.StaticSite{},
settings: store.Settings{},
wantURL: "",
wantSec: "",
wantTier: notify.TierSettings,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotSec, gotTier := resolveSiteTarget(tc.site, tc.settings)
if gotURL != tc.wantURL {
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
}
if gotSec != tc.wantSec {
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
}
if gotTier != tc.wantTier {
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
}
})
}
}