d8ab22876f
Build / build (push) Successful in 10m41s
End-to-end extraction of the Instance concept. After this commit:
* internal/store/instances.go — DELETED
* internal/store/models.go — Instance struct gone, ProxyRoute moved here
* containers table is the single source of truth for project/stack/site
container state. instances table is dropped via DROP TABLE migration
(idempotent; re-runnable on every boot).
* Legacy tinyforge.project / tinyforge.stage / tinyforge.instance-id
Docker labels are no longer emitted; only tinyforge.workload.{id,kind},
tinyforge.role, and tinyforge.managed are stamped on new containers.
Backend rewrites:
- internal/deployer: executeDeploy + blueGreenDeploy + rollback +
promote use store.Container natively. New
removeContainer() replaces removeInstance().
enforceMaxInstances reads via
ListContainersByStageID.
- internal/reconciler: legacy tinyforge.instance-id dispatch removed;
upsertByWorkloadLabel now finds existing rows
by docker container ID first and falls back to
the deterministic workloadID:role key.
- internal/stale/scanner: Scan + new FindStaleContainers walk the
containers table; emit StaleContainer JSON.
- internal/stats/collector: ListContainers replaces ListAllInstances.
- internal/webhook/handler: workload-secret lookup tried first; falls back
to project / static_site secret column.
- internal/api: instances.go, stale.go, stats.go, stats_history.go,
projects.go, settings.go, docker.go, dns.go all read /
write through Container.
Docker layer:
- ManagedContainer exposes WorkloadID/Kind/Role from the canonical labels.
- ListContainers filters by tinyforge.managed=true.
- Network creation uses LabelManaged instead of LabelProject.
Frontend:
- Instance type is now a Container alias; .status → .state,
.last_alive_at → .last_seen_at.
- InstanceCard takes stageId as a prop (no longer derived from Instance).
- StaleContainer JSON shape rewritten: { container, workload_name, role,
days_stale }. StaleContainerCard + /containers/stale page updated.
- ProjectCard / homepage / SystemHealthCard filter by .state.
The migration loop now tolerates "no such table" alongside "duplicate
column" / "already exists" so obsolete ALTER TABLE entries targeting the
dropped instances table no-op cleanly on first boot.
Tests: store + deployer + reconciler + webhook + staticsite + notify all
still pass. Frontend svelte-check: zero errors.
835 lines
27 KiB
Go
835 lines
27 KiB
Go
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)
|
|
}
|