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
-236
View File
@@ -1,236 +0,0 @@
package api
import (
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
)
// listDeploys handles GET /api/deploys.
func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
offsetStr := r.URL.Query().Get("offset")
offset := 0
if offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
offset = parsed
}
}
projectID := r.URL.Query().Get("project_id")
stageID := r.URL.Query().Get("stage_id")
deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset)
if err != nil {
slog.Error("failed to list deploys", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, deploys)
}
// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go.
// The new handler supports both SSE streaming and JSON fallback via Accept header.
// inspectRequest is the expected JSON body for POST /api/deploy/inspect.
type inspectRequest struct {
Image string `json:"image"`
}
// inspectResponse is the response body for POST /api/deploy/inspect.
type inspectResponse struct {
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
}
// inspectImage handles POST /api/deploy/inspect.
// Pulls the image and inspects it for EXPOSE ports and healthcheck config.
func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
var req inspectRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
ctx := r.Context()
// Pull the image first so it's available locally for inspection.
// Split image:tag for the pull call.
imageRef, tag := splitImageTag(req.Image)
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
// Try to inspect anyway in case the image is already local.
}
info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil {
slog.Error("failed to inspect image", "image", req.Image, "error", err)
errMsg := "Failed to inspect image. "
if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") {
errMsg += "Docker is not available on this machine. Enter port and project name manually."
} else {
errMsg += "Image may not exist or registry requires authentication."
}
respondError(w, http.StatusBadGateway, errMsg)
return
}
port := docker.ExtractPort(info.ExposedPorts)
respondJSON(w, http.StatusOK, inspectResponse{
Image: req.Image,
Port: port,
Healthcheck: info.Healthcheck,
})
}
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
type quickDeployRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately)
}
// quickDeploy handles POST /api/deploy/quick.
// Creates a project, a default stage, and triggers a deploy in one call.
func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
var req quickDeployRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
if req.Tag == "" {
imageRef, tag := splitImageTag(req.Image)
if tag != "" {
req.Image = imageRef
req.Tag = tag
} else {
req.Tag = "latest"
}
}
if req.Name == "" {
// Derive name from image (without tag).
parts := strings.Split(req.Image, "/")
req.Name = parts[len(parts)-1]
}
// Check for existing projects with the same image.
if !req.Force {
existing, err := s.store.GetProjectsByImage(req.Image)
if err != nil {
slog.Error("failed to check existing projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if len(existing) > 0 {
respondJSON(w, http.StatusConflict, map[string]any{
"message": "A project with this image already exists",
"existing_projects": existing,
})
return
}
}
// Create project.
project, err := s.store.CreateProject(store.Project{
Name: req.Name,
Image: req.Image,
Registry: req.Registry,
Port: req.Port,
Env: "{}",
Volumes: "{}",
})
if err != nil {
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Create default stage.
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
shouldDeploy := true
if req.AutoDeploy != nil {
shouldDeploy = *req.AutoDeploy
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: project.ID,
Name: "dev",
TagPattern: "*",
AutoDeploy: shouldDeploy,
MaxInstances: 1,
EnableProxy: enableProxy,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Only trigger deploy if auto_deploy is enabled.
var deployID string
if shouldDeploy {
deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
if err != nil {
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
}
status := "created"
if shouldDeploy {
status = "deploying"
}
respondJSON(w, http.StatusAccepted, map[string]any{
"project": project,
"stage": stage,
"tag": req.Tag,
"deploy_id": deployID,
"status": status,
})
}
// splitImageTag splits "image:tag" into image and tag parts.
// Returns the full string and empty tag if no colon separator is found.
func splitImageTag(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx != -1 {
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
return ref[:idx], afterColon
}
}
return ref, ""
}
+35 -40
View File
@@ -190,27 +190,34 @@ func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers.
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS
// consumers. Sourced from the containers index now that legacy project/stage
// tables are gone — the workload's name + the container's role + tag is what
// operators see in the UI.
func (s *Server) buildConsumerNameMap() map[string]string {
names := make(map[string]string)
// Instance consumers: "instance:id" -> "project/stage:tag"
projects, _ := s.store.GetAllProjects()
projectNames := make(map[string]string, len(projects))
for _, p := range projects {
projectNames[p.ID] = p.Name
containers, err := s.store.ListContainers(store.ContainerFilter{})
if err != nil {
return names
}
for _, p := range projects {
stages, _ := s.store.GetStagesByProjectID(p.ID)
for _, st := range stages {
rows, _ := s.store.ListContainersByStageID(st.ID)
for _, c := range rows {
names["instance:"+c.ID] = p.Name + "/" + st.Name + ":" + c.ImageTag
workloadNames := make(map[string]string)
for _, c := range containers {
wname, ok := workloadNames[c.WorkloadID]
if !ok {
if w, err := s.store.GetWorkloadByID(c.WorkloadID); err == nil {
wname = w.Name
}
workloadNames[c.WorkloadID] = wname
}
label := wname
if c.Role != "" {
label = label + "/" + c.Role
}
if c.ImageTag != "" {
label = label + ":" + c.ImageTag
}
names["instance:"+c.ID] = label
}
return names
}
@@ -343,38 +350,26 @@ func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) {
})
}
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers.
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID"
// for every running container that has a proxy route configured. Sourced
// directly from the containers index — the workload-first cutover dropped
// the per-stage enable_proxy toggle in favour of "if a proxy route ID
// exists, the workload wanted a route."
func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]string, error) {
expected := make(map[string]string)
// Instances with proxy enabled.
projects, err := s.store.GetAllProjects()
containers, err := s.store.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("get projects: %w", err)
return nil, fmt.Errorf("list containers: %w", err)
}
for _, p := range projects {
stages, err := s.store.GetStagesByProjectID(p.ID)
if err != nil {
slog.Warn("dns: failed to get stages", "project_id", p.ID, "error", err)
for _, c := range containers {
if c.Subdomain == "" || c.State != "running" {
continue
}
for _, st := range stages {
if !st.EnableProxy {
continue
}
rows, err := s.store.ListContainersByStageID(st.ID)
if err != nil {
slog.Warn("dns: failed to get containers", "stage_id", st.ID, "error", err)
continue
}
for _, c := range rows {
if c.NpmProxyID > 0 && c.Subdomain != "" && c.State == "running" {
fqdn := c.Subdomain + "." + settings.Domain
expected[fqdn] = "instance:" + c.ID
}
}
if c.NpmProxyID == 0 && c.ProxyRouteID == "" {
continue
}
fqdn := c.Subdomain + "." + settings.Domain
expected[fqdn] = "instance:" + c.ID
}
return expected, nil
}
+59 -128
View File
@@ -3,7 +3,6 @@ package api
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -14,17 +13,15 @@ import (
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// Limits and constants for the log endpoints.
const (
defaultLogTail = 200
maxLogTail = 5000
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
defaultLogTail = 200
maxLogTail = 5000
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
logHeartbeatPeriod = 20 * time.Second
)
@@ -37,82 +34,8 @@ var (
ctlBytePattern = regexp.MustCompile(`[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]`)
)
// listProjectImages handles GET /api/projects/{id}/images.
// Returns all local Docker images matching the project's image reference.
func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if s.docker == nil || project.Image == "" {
respondJSON(w, http.StatusOK, []any{})
return
}
images, err := s.docker.ListImagesByRef(r.Context(), project.Image)
if err != nil {
slog.Warn("list project images", "project", project.Name, "error", err)
respondJSON(w, http.StatusOK, []any{})
return
}
respondJSON(w, http.StatusOK, images)
}
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
// Streams container logs via SSE. {iid} is the container row ID. Ownership is
// verified by joining through workload + stage so an attacker cannot stream
// logs for a foreign container by guessing IDs under the wrong project URL.
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
containerRowID := chi.URLParam(r, "iid")
c, err := s.store.GetContainerByID(containerRowID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return
}
slog.Error("failed to get container", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
wl, err := s.store.GetWorkloadByID(c.WorkloadID)
if err != nil {
respondNotFound(w, "container")
return
}
stage, err := s.store.GetStageByID(stageID)
if err != nil || stage.ProjectID != projectID {
respondNotFound(w, "container")
return
}
if wl.Kind != string(store.WorkloadKindProject) || wl.RefID != projectID || c.Role != stage.Name {
respondNotFound(w, "container")
return
}
if c.ContainerID == "" {
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
return
}
s.streamLogsForContainer(w, r, c.ContainerID)
}
// streamLogsForContainer streams logs for an arbitrary container ID using the
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site)
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (workload-container)
// should validate ownership and then delegate here.
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
if s.docker == nil {
@@ -255,11 +178,7 @@ func sanitizeDockerLogLine(line string) string {
// by any container, computed in a single DB pass against the normalized
// containers index. Returning an error (rather than swallowing) prevents
// prune logic from treating a transient DB failure as "nothing is active".
func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string]bool, error) {
// `projects` is unused now — kept in the signature for back-compat with
// callers that already happen to have the slice. The image_ref column
// holds the full "image:tag" string written by the deployer.
_ = projects
func buildActiveImagesSet(st *store.Store) (map[string]bool, error) {
containers, err := st.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
@@ -274,8 +193,43 @@ func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string
return active, nil
}
// unusedImageStats handles GET /api/docker/unused-images.
// Returns the total size of unused project images and whether the threshold is exceeded.
// workloadImageBases returns the set of "image" strings (no tag) that
// some workload currently mounts to, derived from container.image_ref.
// This replaces the legacy "list all projects → projects[].Image" view
// after the workload-first cutover.
func workloadImageBases(st *store.Store) (map[string]bool, error) {
containers, err := st.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
bases := make(map[string]bool, len(containers))
for _, c := range containers {
if c.ImageRef == "" {
continue
}
ref, _ := splitImageTag(c.ImageRef)
if ref != "" {
bases[ref] = true
}
}
return bases, nil
}
// splitImageTag splits "image:tag" into image and tag parts. Returns the
// full string and empty tag if no colon separator is found. Inlined here
// because the legacy deploys.go that owned it was removed.
func splitImageTag(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx != -1 {
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
return ref[:idx], afterColon
}
}
return ref, ""
}
// unusedImageStats handles GET /api/docker/unused-images. Returns the total
// size of unused workload images and whether the threshold is exceeded.
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondJSON(w, http.StatusOK, map[string]any{
@@ -291,32 +245,25 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
return
}
projects, err := s.store.GetAllProjects()
imageBases, err := workloadImageBases(s.store)
if err != nil {
slog.Error("unused images: list projects", "error", err)
slog.Error("unused images: list workload images", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Build set of active image refs in one DB pass instead of N×K queries.
// A flaky read here previously masqueraded as "no images are active",
// which on the prune endpoint would have deleted *running* images.
activeImages, err := buildActiveImagesSet(s.store, projects)
activeImages, err := buildActiveImagesSet(s.store)
if err != nil {
slog.Error("unused images: build active set", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Sum unused image sizes.
ctx := r.Context()
var totalSize int64
var count int
for _, p := range projects {
if p.Image == "" {
continue
}
images, err := s.docker.ListImagesByRef(ctx, p.Image)
for base := range imageBases {
images, err := s.docker.ListImagesByRef(ctx, base)
if err != nil {
continue
}
@@ -339,69 +286,53 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
})
}
// pruneImages handles POST /api/docker/prune-images.
// Only removes images that belong to Tinyforge projects (not all system images).
// pruneImages handles POST /api/docker/prune-images. Only removes images that
// some workload references (via container.image_ref), never arbitrary host
// images.
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
// Collect all image references from our projects.
projects, err := s.store.GetAllProjects()
imageBases, err := workloadImageBases(s.store)
if err != nil {
slog.Error("prune: failed to list projects", "error", err)
slog.Error("prune: list workload images", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Build a set of image refs used by active instances. Bail out on error
// — silently treating a DB blip as "no active images" would prune
// images currently in use by running containers.
activeImages, err := buildActiveImagesSet(s.store, projects)
activeImages, err := buildActiveImagesSet(s.store)
if err != nil {
slog.Error("prune: build active set", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Collect all unique image bases from projects (without tags).
projectImages := make(map[string]bool)
for _, p := range projects {
if p.Image != "" {
projectImages[p.Image] = true
}
}
if len(projectImages) == 0 {
if len(imageBases) == 0 {
respondJSON(w, http.StatusOK, map[string]any{
"images_removed": 0,
"space_reclaimed_mb": 0,
"message": "No project images to clean up",
"message": "No workload images to clean up",
})
return
}
// List all local Docker images and find ones matching our projects but not actively used.
ctx := r.Context()
removed := 0
var reclaimedBytes int64
for imageBase := range projectImages {
// List all tags for this image.
images, err := s.docker.ListImagesByRef(ctx, imageBase)
for base := range imageBases {
images, err := s.docker.ListImagesByRef(ctx, base)
if err != nil {
slog.Warn("prune: list images", "image", imageBase, "error", err)
slog.Warn("prune: list images", "image", base, "error", err)
continue
}
for _, img := range images {
// Skip images that are actively used by running instances.
if activeImages[img.Ref] {
continue
}
// Remove unused image.
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
continue
+4 -8
View File
@@ -239,17 +239,13 @@ func (s *Server) proxyHealth(ctx context.Context) map[string]any {
return out
}
// managedRouteCount returns the number of proxy routes Tinyforge manages
// (Docker instances + static sites combined). The domain argument doesn't
// managedRouteCount returns the number of proxy routes Tinyforge manages,
// reading from the unified containers index. The domain argument doesn't
// affect the count so we pass an empty string to skip FQDN rendering.
func (s *Server) managedRouteCount() (int, error) {
instanceRoutes, err := s.store.ListProxyRoutes("")
routes, err := s.store.ListProxyRoutes("")
if err != nil {
return 0, err
}
siteRoutes, err := s.store.ListStaticSiteProxyRoutes("")
if err != nil {
return 0, err
}
return len(instanceRoutes) + len(siteRoutes), nil
return len(routes), nil
}
-293
View File
@@ -1,293 +0,0 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
// Reads the normalized container index — the legacy `instances` table is gone.
// JSON shape stays Container-shaped (id, container_id, image_tag, subdomain,
// state, port, etc.), so the frontend type may show some renamed fields
// (status→state, last_alive_at→last_seen_at).
func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
containers, err := s.store.ListContainersByStageID(stageID)
if err != nil {
slog.Error("failed to list containers", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Reconcile container state with Docker's actual state — covers the
// case where a container was killed externally between deployer writes
// and the next reconciler tick.
ctx := r.Context()
for i, c := range containers {
if c.ContainerID == "" || c.State == "removing" {
continue
}
running, err := s.docker.IsContainerRunning(ctx, c.ContainerID)
if err != nil {
continue
}
actual := "stopped"
if running {
actual = "running"
}
if c.State != actual {
containers[i].State = actual
_ = s.store.UpdateContainerState(c.ID, actual)
}
}
respondJSON(w, http.StatusOK, containers)
}
// deployRequest is the expected JSON body for triggering a deploy.
type deployRequest struct {
ImageTag string `json:"image_tag"`
}
// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances.
func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req deployRequest
if !decodeJSON(w, r, &req) {
return
}
if req.ImageTag == "" {
respondError(w, http.StatusBadRequest, "image_tag is required")
return
}
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
if err != nil {
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusAccepted, map[string]string{
"status": "deploying",
"deploy_id": deployID,
"project_id": projectID,
"stage_id": stageID,
"image_tag": req.ImageTag,
})
}
// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}.
// {iid} is the container row ID (same UUID as the legacy instance ID).
// Verifies that the container belongs to the project + stage in the URL —
// without this check, a stale URL could delete an unrelated stack/site row.
func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
c, ok := s.resolveAndAuthorizeInstance(w, r)
if !ok {
return
}
id := c.ID
// Remove the Docker container if it has one.
if c.ContainerID != "" {
if err := s.docker.RemoveContainer(r.Context(), c.ContainerID, true); err != nil {
slog.Error("remove container", "container_id", c.ContainerID, "error", err)
}
}
// Delete proxy route if it has one.
if c.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(r.Context(), c.ProxyRouteID); err != nil {
slog.Warn("delete proxy route on container removal", "route_id", c.ProxyRouteID, "error", err)
}
}
// Delete container row.
if err := s.store.DeleteContainer(id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete container")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop.
func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "stop")
}
// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start.
func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "start")
}
// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart.
func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "restart")
}
// controlInstance performs a stop/start/restart action on a container.
// The container's ownership of the URL-provided project + stage is verified
// before any Docker call — see resolveAndAuthorizeInstance for rationale.
func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) {
c, ok := s.resolveAndAuthorizeInstance(w, r)
if !ok {
return
}
id := c.ID
if c.ContainerID == "" {
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
return
}
ctx := r.Context()
var controlErr error
var newState string
switch action {
case "stop":
controlErr = s.docker.StopContainer(ctx, c.ContainerID, 10)
newState = "stopped"
case "start":
controlErr = s.docker.StartContainer(ctx, c.ContainerID)
newState = "running"
case "restart":
controlErr = s.docker.RestartContainer(ctx, c.ContainerID, 10)
newState = "running"
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action))
return
}
if controlErr != nil {
slog.Error("failed to control container", "action", action, "id", id, "error", controlErr)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if err := s.store.UpdateContainerState(id, newState); err != nil {
slog.Error("update container state", "id", id, "state", newState, "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{
"instance_id": id,
"action": action,
"status": newState,
})
}
// DeployTriggerer is the interface for triggering deployments. The legacy
// project/stage methods continue to drive image-tag CI promotions; the
// plugin methods (DispatchPlugin / DispatchTeardown / DispatchReconcile)
// route through the unified Source registry. Both surfaces are kept on
// one interface so the API layer holds a single deployer reference and
// the type assertion in hooks.go / workloads_plugin.go is replaced with
// compile-time checking.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error
DispatchTeardown(ctx context.Context, w plugin.Workload) error
DispatchReconcile(ctx context.Context, w plugin.Workload) error
PluginDeps() plugin.Deps
}
// resolveAndAuthorizeInstance loads the container row identified by {iid} and
// verifies it actually belongs to the project + stage in the URL path.
// Without this, a stale or hand-crafted URL like
//
// DELETE /api/projects/<projectA>/stages/<stageA>/instances/<rowOfStackB>
//
// would happily delete an unrelated stack/site container — admin-only doesn't
// excuse the cross-project bypass. Returns the container on success or
// nothing (with the response already written) on failure.
func (s *Server) resolveAndAuthorizeInstance(w http.ResponseWriter, r *http.Request) (store.Container, bool) {
projectID := chi.URLParam(r, "id")
stageName := ""
if stageID := chi.URLParam(r, "stage"); stageID != "" {
st, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return store.Container{}, false
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return store.Container{}, false
}
if st.ProjectID != projectID {
respondNotFound(w, "stage")
return store.Container{}, false
}
stageName = st.Name
}
id := chi.URLParam(r, "iid")
c, err := s.store.GetContainerByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return store.Container{}, false
}
slog.Error("failed to get container", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return store.Container{}, false
}
w2, err := s.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
if err != nil {
respondNotFound(w, "container")
return store.Container{}, false
}
if c.WorkloadID != w2.ID {
respondNotFound(w, "container")
return store.Container{}, false
}
if stageName != "" && c.Role != stageName {
respondNotFound(w, "container")
return store.Container{}, false
}
return c, true
}
+5 -25
View File
@@ -30,14 +30,12 @@ func logging(next http.Handler) http.Handler {
}
// redactPath strips secrets from URL paths that carry them in segments.
// Only the canonical /api/webhook/triggers/{secret} surface remains after
// the hard cutover.
func redactPath(path string) string {
const projectPrefix = "/api/webhook/"
const sitePrefix = "/api/webhook/sites/"
switch {
case strings.HasPrefix(path, sitePrefix):
return sitePrefix + "***"
case strings.HasPrefix(path, projectPrefix):
return projectPrefix + "***"
const triggerPrefix = "/api/webhook/triggers/"
if strings.HasPrefix(path, triggerPrefix) {
return triggerPrefix + "***"
}
return path
}
@@ -161,24 +159,6 @@ func jsonContentType(next http.Handler) http.Handler {
})
}
// deprecated marks responses with RFC-8594-style headers so API consumers
// can detect that an endpoint is on its way out. The Workload-first
// refactor is migrating away from /api/projects, /api/stages,
// /api/static_sites, and /api/stacks toward /api/workloads; this signals
// it to integrators without breaking them. Date is the operator-facing
// sunset hint, not a hard switch.
func deprecated(replacement string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
if replacement != "" {
w.Header().Set("Link", `<`+replacement+`>; rel="successor-version"`)
}
next.ServeHTTP(w, r)
})
}
}
// rateLimitMiddleware wraps a handler with per-IP rate limiting using the
// supplied limiter. Requests over the limit get 429.
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
+5 -302
View File
@@ -1,24 +1,17 @@
package api
// Outgoing-webhook signing-secret + send-test endpoints. There are four
// tiers — settings, project, stage, site — each exposing the same three
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
// event. Returning a 200 from "send test" doesn't mean the receiver
// processed the event correctly — only that it answered with 2xx. The UI
// surfaces the receiver's status code + body preview so operators can
// distinguish "wired" from "wired and accepted".
// Outgoing-webhook signing-secret + send-test endpoints. After the hard
// cutover only the settings tier survives at the API surface; per-workload
// notification settings live on the workload row itself and are accessed
// via the workload endpoints.
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// notificationSecretResponse is what the GET / regenerate endpoints return.
@@ -92,8 +85,7 @@ func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *ht
// settingsNotificationTest handles POST /api/settings/notification-test.
// Sends a synthetic test event to the global webhook URL using the global
// secret. No tier resolution — that's the whole point: each tier's test
// button proves *that* tier is wired correctly.
// secret.
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -112,292 +104,3 @@ func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request
)
respondJSON(w, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// Project tier
// ---------------------------------------------------------------------------
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureProjectNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("get project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
slog.Error("regenerate project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("project notification secret rotated", "project", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveProjectTestTarget(project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
// resolution but without a stage in scope. Used by the project-level test
// button so the operator sees exactly what a project-only event would do.
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Stage tier
// ---------------------------------------------------------------------------
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
secret, err := s.store.EnsureStageNotificationSecret(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("get stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("stage notification secret rotated", "stage", stageID)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
stage, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
project, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
// Reuse the production resolver so the test button exercises the exact
// fall-through logic a real deploy would.
url, secret, tier := resolveDeployTarget(stage, project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, stage.Name),
)
respondJSON(w, http.StatusOK, result)
}
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
// than imported to avoid an api → deployer dependency cycle and to keep the
// test-endpoint code self-contained. If divergence becomes a risk we can
// move this into a shared internal/notify subpackage.
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
if stage.NotificationURL != "" {
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
}
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Static-site tier
// ---------------------------------------------------------------------------
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("get static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
slog.Error("regenerate static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("static site notification secret rotated", "site", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveSiteTestTarget(site, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(site.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
func resolveSiteTestTarget(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
}
-225
View File
@@ -1,225 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// projectRequest is the expected JSON body for creating/updating a project.
type projectRequest struct {
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"`
Volumes string `json:"volumes"`
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
NotificationURL *string `json:"notification_url,omitempty"`
}
// listProjects handles GET /api/projects.
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.GetAllProjects()
if err != nil {
slog.Error("failed to list projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, projects)
}
// createProject handles POST /api/projects.
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
var req projectRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
if req.Env == "" {
req.Env = "{}"
}
if req.Volumes == "" {
req.Volumes = "{}"
}
npmAccessListID := 0
if req.NpmAccessListID != nil {
npmAccessListID = *req.NpmAccessListID
}
project, err := s.store.CreateProject(store.Project{
Name: req.Name,
Registry: req.Registry,
Image: req.Image,
Port: req.Port,
Healthcheck: req.Healthcheck,
Env: req.Env,
Volumes: req.Volumes,
NpmAccessListID: npmAccessListID,
})
if err != nil {
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project created: " + project.Name,
},
})
respondJSON(w, http.StatusCreated, project)
}
// getProject handles GET /api/projects/{id}.
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Also fetch stages for this project.
stages, err := s.store.GetStagesByProjectID(id)
if err != nil {
slog.Error("failed to get stages", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"project": project,
"stages": stages,
})
}
// updateProject handles PUT /api/projects/{id}.
func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req projectRequest
if !decodeJSON(w, r, &req) {
return
}
// Apply updates to existing project, preserving fields not provided.
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.Image != "" {
updated.Image = req.Image
}
updated.Registry = req.Registry
updated.Port = req.Port
updated.Healthcheck = req.Healthcheck
if req.Env != "" {
updated.Env = req.Env
}
if req.Volumes != "" {
updated.Volumes = req.Volumes
}
if req.NpmAccessListID != nil {
updated.NpmAccessListID = *req.NpmAccessListID
}
if req.NotificationURL != nil {
updated.NotificationURL = *req.NotificationURL
}
if err := s.store.UpdateProject(updated); err != nil {
slog.Error("failed to update project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
// deleteProject handles DELETE /api/projects/{id}.
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Clean up Docker containers and proxy routes before deleting the project.
ctx := r.Context()
stages, _ := s.store.GetStagesByProjectID(id)
for _, stage := range stages {
rows, _ := s.store.ListContainersByStageID(stage.ID)
for _, c := range rows {
if c.ContainerID != "" {
if err := s.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
slog.Warn("delete project: remove container", "container", c.ContainerID, "error", err)
}
}
if c.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(ctx, c.ProxyRouteID); err != nil {
slog.Warn("delete project: delete proxy route", "route", c.ProxyRouteID, "error", err)
}
}
}
}
if err := s.store.DeleteProject(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to delete project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project deleted: " + id,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+5 -13
View File
@@ -6,9 +6,9 @@ import (
"sort"
)
// listProxyRoutes handles GET /api/proxies.
// Returns proxy routes from both Docker instances and static sites,
// merged and sorted by domain.
// listProxyRoutes handles GET /api/proxies. Returns proxy routes derived
// from the containers index — the legacy static-site / project split is
// gone; any workload whose container carries a proxy route ID is listed.
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -17,21 +17,13 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
routes, err := s.store.ListProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list instance proxy routes", "error", err)
slog.Error("failed to list proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list static site proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
routes := append(instanceRoutes, siteRoutes...)
sort.SliceStable(routes, func(i, j int) bool {
if routes[i].Domain == routes[j].Domain {
return routes[i].ProjectName < routes[j].ProjectName
+38 -209
View File
@@ -16,43 +16,49 @@ import (
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/stack"
"github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// DNSProviderChangedFunc is called when DNS settings change so the caller can
// update the provider on the deployer.
type DNSProviderChangedFunc func(provider dns.Provider)
// PluginDispatcher is the subset of the deployer the API layer uses for the
// plugin-native dispatch surface (generic-hooks endpoint + workload teardown
// + future surfaces). Defined here so the API does not import the deployer
// package directly.
type PluginDispatcher interface {
webhook.PluginDispatcher
DispatchTeardown(ctx context.Context, w plugin.Workload) error
}
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
docker *docker.Client
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
proxyProvider proxy.Provider
deployer DeployTriggerer
deployer PluginDispatcher
notifier *notify.Notifier
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
localAuth *auth.LocalAuth
oidcProvider *auth.OIDCProvider
staleScanner *stale.Scanner
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
localAuth *auth.LocalAuth
oidcProvider *auth.OIDCProvider
staleScanner *stale.Scanner
dnsProviderMu sync.RWMutex
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
staticSiteManager *staticsite.Manager
stackManager *stack.Manager
backupEngine *backup.Engine
sseGate *sseGate
logScanReloader LogScanReloader
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
backupEngine *backup.Engine
sseGate *sseGate
logScanReloader LogScanReloader
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
}
@@ -63,7 +69,7 @@ func NewServer(
dockerClient *docker.Client,
npmClient *npm.Client,
proxyProvider proxy.Provider,
deployer DeployTriggerer,
deployer PluginDispatcher,
notifier *notify.Notifier,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
@@ -94,16 +100,6 @@ func NewServer(
return s
}
// SetStaticSiteManager sets the static site manager on the server.
func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
s.staticSiteManager = mgr
}
// SetStackManager sets the docker-compose stack manager on the server.
func (s *Server) SetStackManager(mgr *stack.Manager) {
s.stackManager = mgr
}
// SetStaleScanner sets the stale scanner on the server.
// Called after both the API server and scanner are initialized.
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
@@ -218,12 +214,7 @@ func (s *Server) Router() chi.Router {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.localAuth))
// Plugin registry inspection + unified ingress (Workload refactor).
// /hooks/kinds is informational and visible to any authenticated
// caller. /hooks/generic dispatches deploys and is admin-gated —
// vendor-specific webhooks (with their own per-target HMAC
// secrets) live under /webhook/* and remain the only ingress
// reachable by external CI systems until Phase 5 consolidates them.
// Plugin registry inspection + unified ingress.
r.Get("/hooks/kinds", s.listHookKinds)
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
@@ -234,134 +225,6 @@ func (s *Server) Router() chi.Router {
r.Post("/auth/logout", s.logout)
r.Get("/proxies", s.listProxyRoutes)
r.Get("/docker/unused-images", s.unusedImageStats)
// Legacy project/stage/site/stack endpoints carry a Deprecation
// header pointing at /api/workloads. Functional behavior is
// unchanged until the hard cutover removes them.
r.With(deprecated("/api/workloads")).Get("/projects", s.listProjects)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
r.Get("/stages/{stage}/env", s.listStageEnv)
r.Get("/stages/{stage}/instances", s.listInstances)
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory)
r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
r.Get("/images", s.listProjectImages)
r.Get("/volumes", s.listVolumes)
r.Get("/volumes/{volId}/browse", s.browseVolume)
r.Get("/volumes/{volId}/download", s.downloadVolume)
// Admin-only project mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject)
// Per-project webhook URL management.
r.Get("/webhook", s.getProjectWebhook)
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
// Inbound HMAC signing — secret rotation + enforcement toggle.
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
// Per-project outgoing-webhook signing & test.
r.Get("/notification-secret", s.getProjectNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
r.Post("/notification-test", s.projectNotificationTest)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Per-stage outgoing-webhook signing & test.
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
// Stage env override endpoints.
r.Post("/stages/{stage}/env", s.createStageEnv)
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
// Instance endpoints.
r.Post("/stages/{stage}/instances", s.deployInstance)
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
// Instance control endpoints.
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
// Volume endpoints.
r.Post("/volumes", s.createVolume)
r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume)
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
})
})
// Stacks (docker-compose).
r.With(deprecated("/api/workloads?kind=plugin&source_kind=compose")).Get("/stacks", s.listStacks)
r.Route("/stacks/{id}", func(r chi.Router) {
r.Get("/", s.getStack)
r.Get("/revisions", s.listStackRevisions)
r.Get("/revisions/{revId}", s.getStackRevision)
r.Get("/services", s.getStackServices)
r.Get("/logs", s.getStackLogs)
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateStack)
r.Delete("/", s.deleteStack)
r.Post("/revisions", s.createStackRevision)
r.Post("/rollback/{revId}", s.rollbackStack)
r.Post("/stop", s.stopStack)
r.Post("/start", s.startStack)
})
})
r.With(auth.AdminOnly).Post("/stacks", s.createStack)
// Static sites.
r.With(deprecated("/api/workloads?kind=plugin&source_kind=static")).Get("/sites", s.listStaticSites)
r.Route("/sites/{id}", func(r chi.Router) {
r.Get("/", s.getStaticSite)
r.Get("/secrets", s.listStaticSiteSecrets)
r.Get("/storage", s.getStaticSiteStorage)
r.Get("/logs", s.streamStaticSiteLogs)
r.Get("/stats", s.getStaticSiteStats)
r.Get("/stats/history", s.getStaticSiteStatsHistory)
// Admin-only mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateStaticSite)
r.Delete("/", s.deleteStaticSite)
r.Post("/deploy", s.deployStaticSite)
r.Post("/stop", s.stopStaticSite)
r.Post("/start", s.startStaticSite)
r.Get("/webhook", s.getStaticSiteWebhook)
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
// Per-site outgoing-webhook signing & test.
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
r.Post("/notification-test", s.staticSiteNotificationTest)
r.Post("/secrets", s.createStaticSiteSecret)
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
})
})
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
r.Get("/events", s.streamEvents)
r.Get("/events/log", s.listEventLog)
r.Get("/events/log/stats", s.getEventLogStats)
@@ -388,12 +251,9 @@ func (s *Server) Router() chi.Router {
// Stale container endpoints (read).
r.Get("/containers/stale", s.listStaleContainers)
// Workload-shaped endpoints (the unifying layer over project /
// stack / site). Read endpoints are open to any authenticated
// user; create / update / deploy mutate state and are admin-gated.
// Plugin-native workloads (source_kind + trigger_kind set) are
// created here; legacy project / stack / site mutations remain at
// their dedicated endpoints during the cutover.
// Workload-shaped endpoints the canonical surface after the
// hard cutover. Reads open to any authenticated user; mutations
// admin-gated.
r.Get("/workloads", s.listWorkloads)
r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload)
r.Route("/workloads/{id}", func(r chi.Router) {
@@ -405,22 +265,17 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
// Per-workload env vars (analog of legacy stage_env).
// Listing is open to authenticated readers; mutations are
// admin-gated. Encrypted values are write-only after store.
// Per-workload env vars. Listing open to authenticated readers;
// mutations admin-gated. Encrypted values are write-only after store.
r.Get("/env", s.listWorkloadEnv)
r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv)
r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv)
// Per-workload inbound webhook URL: rotate the secret + fetch
// the canonical URL. Mirrors the project / site webhook UX.
r.With(auth.AdminOnly).Get("/webhook", s.getWorkloadWebhook)
r.With(auth.AdminOnly).Post("/webhook/regenerate", s.regenerateWorkloadWebhook)
// Per-workload inbound webhook URL handlers were dropped in
// the hard legacy cutover; inbound webhooks are now first-
// class Triggers reachable via /api/triggers/{id}/webhook.
// Per-workload volume mounts (analog of legacy project volumes).
// Reads are open to authenticated users; mutations admin-gated.
// Source/target paths are validated for traversal safety here;
// host-path allow-listing happens at deploy time.
// Per-workload volume mounts.
r.Get("/volumes", s.listWorkloadVolumes)
r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume)
r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume)
@@ -432,8 +287,7 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
// Trigger bindings on this workload — the symmetric view
// of /triggers/{id}/bindings keyed on the workload side
// so the workload detail page is one round-trip.
// of /triggers/{id}/bindings keyed on the workload side.
r.Get("/triggers", s.listBindingsForWorkload)
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
})
@@ -453,10 +307,7 @@ func (s *Server) Router() chi.Router {
})
// First-class Triggers (redeploy signal sources). One trigger
// (registry / git / webhook / manual / schedule / log_scan)
// fans out to many workloads via workload_trigger_bindings.
// Reads are open to authenticated users; mutations + secret
// rotation are admin-gated.
r.Get("/triggers", s.listTriggers)
r.Get("/triggers/{id}", s.getTrigger)
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
@@ -472,10 +323,7 @@ func (s *Server) Router() chi.Router {
r.Delete("/bindings/{bid}", s.deleteBinding)
})
// Event triggers: filter+action rules over the event_log
// stream. Read endpoints are available to any authenticated
// user; mutations + test-dispatch are admin-gated since they
// can fire arbitrary outbound webhooks.
// Event triggers: filter+action rules over the event_log stream.
r.Get("/event-triggers", s.listEventTriggers)
r.Get("/event-triggers/{id}", s.getEventTrigger)
r.Group(func(r chi.Router) {
@@ -486,11 +334,7 @@ func (s *Server) Router() chi.Router {
r.Post("/event-triggers/{id}/test", s.testEventTrigger)
})
// Log-scan rules: regex patterns the scanner manager
// applies to container log lines. Read endpoints are
// available to any authenticated user; mutations are
// admin-gated since they can change global observability
// behavior across every workload.
// Log-scan rules.
r.Get("/log-scan-rules", s.listLogScanRules)
r.Get("/log-scan-rules/stats", s.getLogScanStats)
r.Get("/log-scan-rules/{id}", s.getLogScanRule)
@@ -512,7 +356,7 @@ func (s *Server) Router() chi.Router {
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Config export (reveals project/infra details).
// Config export (reveals registry/global details).
r.Get("/config/export", s.exportConfig)
// Event log management.
@@ -528,21 +372,6 @@ func (s *Server) Router() chi.Router {
r.Put("/auth/users/{uid}/password", s.changePassword)
r.Delete("/auth/users/{uid}", s.deleteUser)
// Project creation.
r.Post("/projects", s.createProject)
// Static site creation and tools.
r.Post("/sites", s.createStaticSite)
r.Post("/sites/test-connection", s.testStaticSiteConnection)
r.Post("/sites/branches", s.listStaticSiteBranches)
r.Post("/sites/tree", s.listStaticSiteTree)
r.Post("/sites/detect-provider", s.detectStaticSiteProvider)
r.Post("/sites/repos", s.listStaticSiteRepos)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry creation.
r.Post("/registries", s.createRegistry)
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"strconv"
"github.com/alexei/tinyforge/internal/store"
)
// generateWebhookSecret is a one-line bridge to store.GenerateWebhookSecret
// so the api handlers and the store CRUD share one secret-generation
// path — no panic-vs-UUID-fallback divergence.
func generateWebhookSecret() string { return store.GenerateWebhookSecret() }
// webhookURLResponse is the common payload returned by every webhook
// endpoint. Clients never see raw secrets except at issue/rotate time via
// these fields; the URL shape is "/api/webhook/..." so callers can prepend
// their own origin.
type webhookURLResponse struct {
WebhookURL string `json:"webhook_url"`
WebhookSecret string `json:"webhook_secret"`
HasSigningSecret bool `json:"has_signing_secret"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// signingSecretResponse is returned when a signing secret is issued or rotated.
type signingSecretResponse struct {
SigningSecret string `json:"signing_secret"`
}
// parseLimit clamps a query-string limit to [1, max], falling back to def.
func parseLimit(raw string, def, max int) int {
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > max {
return max
}
return n
}
-138
View File
@@ -2,147 +2,14 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// streamDeployLogs handles GET /api/deploys/{id}/logs.
// It supports both SSE streaming and JSON fallback based on the Accept header.
//
// SSE mode (Accept: text/event-stream):
//
// Streams deploy log events in real-time. Existing logs are sent first,
// then new logs are pushed as they arrive via the event bus.
//
// JSON mode (default):
//
// Returns all existing deploy logs as a JSON array.
func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
deployID := chi.URLParam(r, "id")
// Verify deploy exists.
deploy, err := s.store.GetDeployByID(deployID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "deploy")
return
}
slog.Error("failed to get deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// JSON fallback: return existing logs as array.
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/event-stream") {
logs, err := s.store.GetDeployLogs(deployID)
if err != nil {
slog.Error("failed to get deploy logs", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, logs)
return
}
// SSE mode.
release, ok := acquireSSESlot(w, s.sseGate)
if !ok {
return
}
defer release()
flusher, ok := w.(http.Flusher)
if !ok {
respondError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Send existing logs first.
existingLogs, err := s.store.GetDeployLogs(deployID)
if err != nil {
slog.Error("get existing deploy logs", "error", err)
} else {
for _, entry := range existingLogs {
writeSSE(w, flusher, events.Event{
Type: events.EventDeployLog,
Payload: events.DeployLogPayload{
DeployID: deployID,
Message: entry.Message,
Level: entry.Level,
},
})
}
}
// If deploy is already finished, send completion and close.
if isTerminalStatus(deploy.Status) {
writeSSE(w, flusher, events.Event{
Type: events.EventDeployStatus,
Payload: events.DeployStatusPayload{
DeployID: deployID,
ProjectID: deploy.ProjectID,
StageID: deploy.StageID,
ImageTag: deploy.ImageTag,
Status: deploy.Status,
Error: deploy.Error,
},
})
return
}
// Subscribe to new deploy log events for this deploy.
sub := s.eventBus.Subscribe(func(evt events.Event) bool {
switch payload := evt.Payload.(type) {
case events.DeployLogPayload:
return payload.DeployID == deployID
case events.DeployStatusPayload:
return payload.DeployID == deployID
default:
return false
}
})
defer s.eventBus.Unsubscribe(sub)
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case evt, ok := <-sub:
if !ok {
return
}
writeSSE(w, flusher, evt)
// Close stream when deploy reaches terminal status.
if evt.Type == events.EventDeployStatus {
if payload, ok := evt.Payload.(events.DeployStatusPayload); ok {
if isTerminalStatus(payload.Status) {
return
}
}
}
}
}
}
// streamEvents handles GET /api/events.
// It streams instance status changes and deploy status changes via SSE.
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
@@ -203,8 +70,3 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
// isTerminalStatus returns true if the deploy status is final.
func isTerminalStatus(status string) bool {
return store.IsTerminalDeployStatus(status)
}
-285
View File
@@ -1,285 +0,0 @@
package api
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) {
stacks, err := s.store.GetAllStacks()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list stacks")
return
}
respondJSON(w, http.StatusOK, stacks)
}
func (s *Server) getStack(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
st, err := s.store.GetStackByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stack")
return
}
respondJSON(w, http.StatusOK, st)
}
// ── Create ──────────────────────────────────────────────────────────
type createStackRequest struct {
Name string `json:"name"`
Description string `json:"description"`
YAML string `json:"yaml"`
Deploy bool `json:"deploy"` // if true, deploy immediately after create
}
func (s *Server) createStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available (docker compose missing?)")
return
}
var req createStackRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.YAML == "" {
respondError(w, http.StatusBadRequest, "name and yaml are required")
return
}
author := authorFromRequest(r)
ctx := r.Context()
st, rev, err := s.stackManager.Create(ctx, req.Name, req.Description, req.YAML, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Deploy {
// Deploy asynchronously so the client gets a fast response.
go func(stackID, revID string) {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
_ = s.stackManager.Deploy(bgCtx, stackID, revID)
}(st.ID, rev.ID)
}
respondJSON(w, http.StatusCreated, map[string]any{
"stack": st,
"revision": rev,
})
}
// ── Update (metadata only) ─────────────────────────────────────────
type updateStackRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) updateStack(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStackByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stack")
return
}
var req updateStackRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name != "" {
existing.Name = req.Name
}
existing.Description = req.Description
if err := s.store.UpdateStack(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update stack")
return
}
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
removeVolumes := r.URL.Query().Get("remove_volumes") == "true"
if err := s.stackManager.Delete(r.Context(), id, removeVolumes); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete stack: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Revisions ──────────────────────────────────────────────────────
func (s *Server) listStackRevisions(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
revs, err := s.store.GetStackRevisionsByStackID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list revisions")
return
}
respondJSON(w, http.StatusOK, revs)
}
func (s *Server) getStackRevision(w http.ResponseWriter, r *http.Request) {
revID := chi.URLParam(r, "revId")
rev, err := s.store.GetStackRevisionByID(revID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "revision")
return
}
respondError(w, http.StatusInternalServerError, "failed to get revision")
return
}
respondJSON(w, http.StatusOK, rev)
}
type newRevisionRequest struct {
YAML string `json:"yaml"`
}
func (s *Server) createStackRevision(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
var req newRevisionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.YAML == "" {
respondError(w, http.StatusBadRequest, "yaml is required")
return
}
author := authorFromRequest(r)
// Deploy asynchronously; return the revision immediately.
ctx := r.Context()
rev, err := s.stackManager.NewRevisionAndDeployAsync(ctx, id, req.YAML, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusAccepted, rev)
}
func (s *Server) rollbackStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
revID := chi.URLParam(r, "revId")
author := authorFromRequest(r)
rev, err := s.stackManager.RollbackAsync(r.Context(), id, revID, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusAccepted, rev)
}
// ── Control ──────────────────────────────────────────────────────
func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
if err := s.stackManager.Stop(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func (s *Server) startStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
if err := s.stackManager.Start(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "running"})
}
func (s *Server) getStackServices(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
services, err := s.stackManager.Services(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, services)
}
func (s *Server) getStackLogs(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
service := r.URL.Query().Get("service")
tail := 200
if t := r.URL.Query().Get("tail"); t != "" {
if n, err := strconv.Atoi(t); err == nil && n > 0 {
tail = n
}
}
logs, err := s.stackManager.Logs(r.Context(), id, service, tail)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, logs)
}
// authorFromRequest best-effort returns the username of the acting user.
// Falls back to "system" if no auth context is present.
func authorFromRequest(r *http.Request) string {
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
return claims.Username
}
return "system"
}
-186
View File
@@ -1,186 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// stageEnvRequest is the expected JSON body for creating/updating a stage env override.
type stageEnvRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted *bool `json:"encrypted"`
}
// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env.
func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
// Verify stage exists.
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
envs, err := s.store.GetStageEnvByStageID(stageID)
if err != nil {
slog.Error("failed to list stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted values in the response.
masked := make([]store.StageEnv, len(envs))
for i, env := range envs {
masked[i] = env
if env.Encrypted {
masked[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, masked)
}
// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env.
func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
// Verify stage exists.
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageEnvRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
encrypted := false
if req.Encrypted != nil {
encrypted = *req.Encrypted
}
value := req.Value
if encrypted && value != "" {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
}
env, err := s.store.CreateStageEnv(store.StageEnv{
StageID: stageID,
Key: req.Key,
Value: value,
Encrypted: encrypted,
})
if err != nil {
slog.Error("failed to create stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted value in the response.
if env.Encrypted {
env.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, env)
}
// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}.
func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envId")
existing, err := s.store.GetStageEnvByID(envID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage env")
return
}
slog.Error("failed to get stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageEnvRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Key != "" {
updated.Key = req.Key
}
if req.Encrypted != nil {
updated.Encrypted = *req.Encrypted
}
// Only update value if provided (allows updating key/encrypted without changing the value).
if req.Value != "" {
value := req.Value
if updated.Encrypted {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
}
updated.Value = value
}
if err := s.store.UpdateStageEnv(updated); err != nil {
slog.Error("failed to update stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted value in the response.
if updated.Encrypted {
updated.Value = "••••••••"
}
respondJSON(w, http.StatusOK, updated)
}
// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}.
func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envId")
if err := s.store.DeleteStageEnv(envID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage env")
return
}
slog.Error("failed to delete stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
-202
View File
@@ -1,202 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// stageRequest is the expected JSON body for creating/updating a stage.
type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
EnableProxy *bool `json:"enable_proxy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
CpuLimit *float64 `json:"cpu_limit"`
MemoryLimit *int `json:"memory_limit"`
}
// createStage handles POST /api/projects/{id}/stages.
func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
// Verify project exists.
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.TagPattern == "" {
req.TagPattern = "*"
}
autoDeploy := false
if req.AutoDeploy != nil {
autoDeploy = *req.AutoDeploy
}
maxInstances := 1
if req.MaxInstances != nil {
maxInstances = *req.MaxInstances
}
confirm := false
if req.Confirm != nil {
confirm = *req.Confirm
}
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
var cpuLimit float64
if req.CpuLimit != nil {
cpuLimit = *req.CpuLimit
}
var memoryLimit int
if req.MemoryLimit != nil {
memoryLimit = *req.MemoryLimit
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: projectID,
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
EnableProxy: enableProxy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
NotificationURL: req.NotificationURL,
CpuLimit: cpuLimit,
MemoryLimit: memoryLimit,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage created: " + stage.Name,
},
})
respondJSON(w, http.StatusCreated, stage)
}
// updateStage handles PUT /api/projects/{id}/stages/{stage}.
func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
existing, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.TagPattern != "" {
updated.TagPattern = req.TagPattern
}
if req.AutoDeploy != nil {
updated.AutoDeploy = *req.AutoDeploy
}
if req.EnableProxy != nil {
updated.EnableProxy = *req.EnableProxy
}
if req.MaxInstances != nil {
updated.MaxInstances = *req.MaxInstances
}
if req.Confirm != nil {
updated.Confirm = *req.Confirm
}
updated.PromoteFrom = req.PromoteFrom
updated.Subdomain = req.Subdomain
updated.NotificationURL = req.NotificationURL
if req.CpuLimit != nil {
updated.CpuLimit = *req.CpuLimit
}
if req.MemoryLimit != nil {
updated.MemoryLimit = *req.MemoryLimit
}
if err := s.store.UpdateStage(updated); err != nil {
slog.Error("failed to update stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
// deleteStage handles DELETE /api/projects/{id}/stages/{stage}.
func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.DeleteStage(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to delete stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage deleted: " + stageID,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID})
}
-662
View File
@@ -1,662 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
sites, err := s.store.GetAllStaticSites()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list static sites")
return
}
// Mask access tokens in response.
for i := range sites {
sites[i].AccessToken = maskToken(sites[i].AccessToken)
}
respondJSON(w, http.StatusOK, sites)
}
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
site.AccessToken = maskToken(site.AccessToken)
respondJSON(w, http.StatusOK, site)
}
// ── Create ──────────────────────────────────────────────────────────
type createStaticSiteRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
NotificationURL *string `json:"notification_url,omitempty"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
return
}
if req.Branch == "" {
req.Branch = "main"
}
if req.Mode == "" {
req.Mode = "static"
}
if req.SyncTrigger == "" {
req.SyncTrigger = "manual"
}
// Encrypt access token if provided.
encryptedToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
encryptedToken = encrypted
}
site := store.StaticSite{
Name: req.Name,
Provider: req.Provider,
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
RepoOwner: req.RepoOwner,
RepoName: req.RepoName,
Branch: req.Branch,
FolderPath: req.FolderPath,
AccessToken: encryptedToken,
Domain: req.Domain,
Mode: req.Mode,
RenderMarkdown: req.RenderMarkdown,
SyncTrigger: req.SyncTrigger,
TagPattern: req.TagPattern,
StorageEnabled: req.StorageEnabled,
StorageLimitMB: req.StorageLimitMB,
Status: "idle",
}
if req.NotificationURL != nil {
site.NotificationURL = *req.NotificationURL
}
created, err := s.store.CreateStaticSite(site)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
return
}
created.AccessToken = maskToken(created.AccessToken)
respondJSON(w, http.StatusCreated, created)
}
// ── Update ──────────────────────────────────────────────────────────
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
// Update fields.
if req.Name != "" {
existing.Name = req.Name
}
if req.Provider != "" {
existing.Provider = req.Provider
}
if req.GiteaURL != "" {
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
}
if req.RepoOwner != "" {
existing.RepoOwner = req.RepoOwner
}
if req.RepoName != "" {
existing.RepoName = req.RepoName
}
if req.Branch != "" {
existing.Branch = req.Branch
}
if req.FolderPath != "" {
existing.FolderPath = req.FolderPath
}
if req.Domain != "" {
existing.Domain = req.Domain
}
if req.Mode != "" {
existing.Mode = req.Mode
}
if req.SyncTrigger != "" {
existing.SyncTrigger = req.SyncTrigger
}
existing.RenderMarkdown = req.RenderMarkdown
existing.TagPattern = req.TagPattern
existing.StorageEnabled = req.StorageEnabled
existing.StorageLimitMB = req.StorageLimitMB
if req.NotificationURL != nil {
existing.NotificationURL = *req.NotificationURL
}
// Update access token only if a new one is provided.
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
existing.AccessToken = encrypted
}
if err := s.store.UpdateStaticSite(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
return
}
existing.AccessToken = maskToken(existing.AccessToken)
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Remove container and proxy route first.
if s.staticSiteManager != nil {
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
// Log but don't fail — still delete the DB record.
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
return
}
}
if err := s.store.DeleteStaticSite(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete static site")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Deploy ──────────────────────────────────────────────────────────
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
// Trigger deploy asynchronously with a detached context
// (the HTTP request context is canceled when the response is sent).
// Manual deploys always force a full rebuild + proxy regeneration.
go func() {
ctx := context.Background()
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
// Error is already stored in the site status.
return
}
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
}
// ── Stop / Start ────────────────────────────────────────────────────
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Stop(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
}
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Start(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
}
// ── Test Connection ─────────────────────────────────────────────────
type testConnectionRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
var req testConnectionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
return
}
// Encrypt token for the manager to decrypt (consistent handling).
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
}
// ── Branches ────────────────────────────────────────────────────────
type listBranchesRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
var req listBranchesRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
return
}
respondJSON(w, http.StatusOK, branches)
}
// ── Tree ────────────────────────────────────────────────────────────
type listTreeRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
}
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
var req listTreeRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
return
}
respondJSON(w, http.StatusOK, entries)
}
// ── Secrets ─────────────────────────────────────────────────────────
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list secrets")
return
}
// Mask encrypted values.
for i := range secrets {
if secrets[i].Encrypted {
secrets[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, secrets)
}
type createSecretRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
value := req.Value
if req.Encrypted && value != "" {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
secret := store.StaticSiteSecret{
SiteID: siteID,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
}
created, err := s.store.CreateStaticSiteSecret(secret)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
return
}
if created.Encrypted {
created.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, created)
}
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
existing, err := s.store.GetStaticSiteSecretByID(secretID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to get secret")
return
}
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key != "" {
existing.Key = req.Key
}
existing.Encrypted = req.Encrypted
if req.Value != "" {
value := req.Value
if req.Encrypted {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
existing.Value = value
}
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
return
}
if existing.Encrypted {
existing.Value = "••••••••"
}
respondJSON(w, http.StatusOK, existing)
}
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete secret")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
}
// ── List Repos ──────────────────────────────────────────────────────
type listReposRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
Query string `json:"query"`
}
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
var req listReposRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" {
respondError(w, http.StatusBadRequest, "gitea_url is required")
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
return
}
respondJSON(w, http.StatusOK, repos)
}
// ── Detect Provider ─────────────────────────────────────────────────
type detectProviderRequest struct {
URL string `json:"url"`
}
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
var req detectProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required")
return
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
}
// ── Storage Usage ──────────────────────────────────────────────────
func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
if !site.StorageEnabled {
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": false,
"used_bytes": 0,
"limit_mb": 0,
})
return
}
usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to inspect storage usage")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"used_bytes": usage.UsedBytes,
"limit_mb": site.StorageLimitMB,
})
}
// maskToken returns a masked version of a token string for API responses.
func maskToken(token string) string {
if token == "" {
return ""
}
return "••••••••"
}
+5 -126
View File
@@ -1,28 +1,23 @@
package api
import (
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/store"
)
// topConsumerWindow is how recent a container sample must be to count toward
// topConsumerMinWindow is how recent a container sample must be to count toward
// the "top consumers" list. Scaled with the collector interval (read from
// settings) so it stays meaningful even when sampling is sparse.
const topConsumerMinWindow = 2 * time.Minute
// TopContainerSample augments a stats sample with the human-readable owner
// name so the UI can show "project/stage" or the static-site name without an
// extra round-trip per row.
// name so the UI can show "workload/role" without an extra round-trip per row.
type TopContainerSample struct {
store.ContainerStatsSample
OwnerName string `json:"owner_name"`
@@ -90,107 +85,6 @@ func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, samples)
}
// getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history.
// {iid} is the container row ID (same UUID as the legacy instance ID).
func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) {
instanceID := chi.URLParam(r, "iid")
if _, err := s.store.GetContainerByID(instanceID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return
}
slog.Error("failed to get container", "id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get container")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot.
func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID)
if err != nil {
slog.Error("failed to get site stats", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site stats")
return
}
respondJSON(w, http.StatusOK, cs)
}
// getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history.
func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list site stats samples", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true.
// Reuses the shared container log streamer so the SSE + multiplex handling
// matches /api/projects/.../instances/.../logs exactly.
func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
s.streamLogsForContainer(w, r, site.ContainerID)
}
// listTopContainers handles GET /api/system/stats/top?limit=5&by=cpu.
// Returns the top-N most recent samples across containers, sorted by CPU or
// memory. Container IDs are stripped for non-admins so a low-privilege viewer
@@ -246,8 +140,6 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
top = top[:limit]
}
// Resolve owner names so the UI can show "project/stage" or the site name
// without a per-row round trip.
enriched := s.enrichWithOwnerNames(top)
// Scrub container IDs for non-admins. The owner name is the actionable
@@ -264,18 +156,13 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
}
// enrichWithOwnerNames attaches a human-readable owner name to each sample.
// Looks up instances and sites in batch so the cost is independent of the
// number of samples (which is at most 'limit').
// Names are resolved through the containers index → workloads, which after
// the cutover is the only available lookup path.
func (s *Server) enrichWithOwnerNames(samples []store.ContainerStatsSample) []TopContainerSample {
out := make([]TopContainerSample, len(samples))
for i, sm := range samples {
out[i] = TopContainerSample{ContainerStatsSample: sm}
switch sm.OwnerType {
case stats.OwnerTypeInstance:
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
case stats.OwnerTypeSite:
out[i].OwnerName = s.lookupSiteName(sm.OwnerID)
}
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
}
return out
}
@@ -300,11 +187,3 @@ func (s *Server) lookupInstanceName(instanceID string) string {
return w.Name
}
// lookupSiteName returns the site's display name or empty on lookup error.
func (s *Server) lookupSiteName(siteID string) string {
site, err := s.store.GetStaticSiteByID(siteID)
if err != nil {
return ""
}
return site.Name
}
-209
View File
@@ -1,209 +0,0 @@
package api
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
return strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r == '\n' || r == '\r' {
return '_'
}
return r
}, name)
}
const maxUploadSize = 100 * 1024 * 1024 // 100MB
// resolveVolumeRoot looks up a volume and resolves its host path.
func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) {
projectID := chi.URLParam(r, "id")
volID := chi.URLParam(r, "volId")
proj, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return "", false
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return "", false
}
vol, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return "", false
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return "", false
}
// Verify volume belongs to this project.
if vol.ProjectID != projectID {
respondNotFound(w, "volume")
return "", false
}
if vol.Scope == "ephemeral" {
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
return "", false
}
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings", "error", err)
respondError(w, http.StatusInternalServerError, "failed to get settings")
return "", false
}
q := r.URL.Query()
params := volume.ResolveParams{
BasePath: settings.BaseVolumePath,
ProjectName: proj.Name,
StageName: q.Get("stage"),
ImageTag: q.Get("tag"),
AllowedVolumePaths: settings.AllowedVolumePaths,
}
rootPath, err := volume.ResolvePath(vol, params)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return "", false
}
return rootPath, true
}
// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag=
func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
entries, err := volume.ListDir(rootPath, relPath)
if err != nil {
slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list directory")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"path": relPath,
"entries": entries,
})
}
// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag=
// Downloads a single file directly, or a directory/root as a zip archive.
func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
// If path is empty or points to a directory, serve as zip.
if relPath == "" {
s.serveZip(w, rootPath, "", "volume")
return
}
// Check if it's a file or directory.
f, info, err := volume.OpenFile(rootPath, relPath)
if err != nil {
// Might be a directory — try zip.
entries, listErr := volume.ListDir(rootPath, relPath)
if listErr == nil && entries != nil {
name := filepath.Base(relPath)
s.serveZip(w, rootPath, relPath, name)
return
}
slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusNotFound, "file not found")
return
}
defer f.Close()
// Serve single file with forced download.
safeName := sanitizeFilename(filepath.Base(relPath))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName))
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
}
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
safeName := sanitizeFilename(name)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName))
w.WriteHeader(http.StatusOK)
if err := volume.WriteZip(rootPath, relPath, w); err != nil {
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
}
}
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
// Accepts multipart form uploads. Overrides the global body limit for large files.
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
// Override the global 1MB body limit for uploads.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
respondError(w, http.StatusBadRequest, "upload too large (max 100MB)")
return
}
relPath := r.URL.Query().Get("path")
uploaded := []string{}
for _, fileHeaders := range r.MultipartForm.File {
for _, fh := range fileHeaders {
f, err := fh.Open()
if err != nil {
slog.Error("failed to open upload", "filename", fh.Filename, "error", err)
continue
}
// Strip directory components from filename to prevent directory creation attacks.
targetRel := filepath.Join(relPath, filepath.Base(fh.Filename))
if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
f.Close()
slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename)
return
}
f.Close()
uploaded = append(uploaded, fh.Filename)
}
}
respondJSON(w, http.StatusOK, map[string]any{
"uploaded": uploaded,
"count": len(uploaded),
})
}
-327
View File
@@ -1,327 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// validateVolumePath checks that the source path does not contain path traversal.
func validateVolumePath(source string) bool {
cleaned := filepath.Clean(source)
return !strings.Contains(cleaned, "..")
}
// volumeRequest is the expected JSON body for creating/updating a volume.
type volumeRequest struct {
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
Scope string `json:"scope"`
Name string `json:"name"`
}
// validScopes lists all accepted scope values.
var validScopes = map[string]bool{
"instance": true, "stage": true, "project": true,
"project_named": true, "named": true, "ephemeral": true,
"absolute": true,
}
// validateVolumeScope validates the scope, name, and source combination.
func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
if !validScopes[scope] {
return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
}
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
return "name is required for " + scope + " scope"
}
if name != "" && !safeNamePattern.MatchString(name) {
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
}
if scope == "absolute" {
if source == "" {
return "source path is required for absolute scope"
}
if !filepath.IsAbs(source) {
return "absolute scope requires an absolute source path"
}
// Validate against allowlist.
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
if err != nil {
return "failed to parse allowed volume paths"
}
if len(allowed) == 0 {
return "absolute volume paths are disabled — configure allowed paths in settings first"
}
matched := false
cleanSource := filepath.Clean(source)
for _, prefix := range allowed {
cleanPrefix := filepath.Clean(prefix)
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
matched = true
break
}
}
if !matched {
return "source path is not under any allowed volume path"
}
}
return ""
}
// scopeDescriptions returns metadata about each scope for the UI.
type scopeInfo struct {
Scope string `json:"scope"`
Description string `json:"description"`
NeedsName bool `json:"needs_name"`
PathExample string `json:"path_example"`
}
// listVolumeScopes handles GET /api/volumes/scopes.
// Returns all available scopes with descriptions for UI hints.
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
scopes := []scopeInfo{
{
Scope: "instance",
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
},
{
Scope: "stage",
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}/{source}",
},
{
Scope: "project",
Description: "Shared across all stages of the project. Good for common config or shared assets.",
NeedsName: false,
PathExample: "{base}/{project}/{source}",
},
{
Scope: "project_named",
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
NeedsName: true,
PathExample: "{base}/{project}/_named/{name}/{source}",
},
{
Scope: "named",
Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.",
NeedsName: true,
PathExample: "{base}/_named/{name}/{source}",
},
{
Scope: "ephemeral",
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.",
NeedsName: false,
PathExample: "(tmpfs — no host path)",
},
{
Scope: "absolute",
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
NeedsName: false,
PathExample: "/mnt/nfs/data (must match allowed paths)",
},
}
respondJSON(w, http.StatusOK, scopes)
}
// listVolumes handles GET /api/projects/{id}/volumes.
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
vols, err := s.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list volumes")
return
}
respondJSON(w, http.StatusOK, vols)
}
// createVolume handles POST /api/projects/{id}/volumes.
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
// Ephemeral volumes don't need a source path.
if req.Scope != "ephemeral" {
if req.Source == "" {
respondError(w, http.StatusBadRequest, "source is required")
return
}
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
}
if req.Target == "" {
respondError(w, http.StatusBadRequest, "target is required")
return
}
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
// Resolve scope — support legacy mode field.
scope := req.Scope
if scope == "" {
switch req.Mode {
case "isolated":
scope = "instance"
case "shared":
scope = "project"
default:
scope = "project"
}
}
// Fetch settings for absolute path allowlist validation.
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
vol, err := s.store.CreateVolume(store.Volume{
ProjectID: projectID,
Source: req.Source,
Target: req.Target,
Scope: scope,
Name: strings.TrimSpace(req.Name),
})
if err != nil {
slog.Error("failed to create volume", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to create volume")
return
}
respondJSON(w, http.StatusCreated, vol)
}
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
existing, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Source != "" {
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
updated.Source = req.Source
}
if req.Target != "" {
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
updated.Target = req.Target
}
if req.Scope != "" {
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
source := updated.Source
if req.Source != "" {
source = req.Source
}
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
updated.Scope = req.Scope
updated.Name = strings.TrimSpace(req.Name)
}
// Non-ephemeral scopes require a source path.
if updated.Scope != "ephemeral" && updated.Source == "" {
respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes")
return
}
if err := s.store.UpdateVolume(updated); err != nil {
slog.Error("failed to update volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to update volume")
return
}
respondJSON(w, http.StatusOK, updated)
}
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
if err := s.store.DeleteVolume(volID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to delete volume")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
}
-370
View File
@@ -1,370 +0,0 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors
// the helper in internal/store; kept here to avoid an import cycle and so the
// rotation handlers don't pretend to use uuid for what is really a secret.
func generateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
// webhookURLResponse is the common payload returned by every webhook endpoint.
// Clients never see raw secrets except at issue/rotate time via these fields;
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
type webhookURLResponse struct {
WebhookURL string `json:"webhook_url"`
WebhookSecret string `json:"webhook_secret"`
HasSigningSecret bool `json:"has_signing_secret"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// signingSecretResponse is returned when a signing secret is issued or rotated.
type signingSecretResponse struct {
SigningSecret string `json:"signing_secret"`
}
// signingToggleRequest is the body of the require-signature toggle endpoint.
type signingToggleRequest struct {
RequireSignature bool `json:"require_signature"`
}
// getProjectWebhook handles GET /api/projects/{id}/webhook.
// Returns the project's webhook URL + secret, generating one lazily if the
// project predates the per-project webhook migration.
func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureProjectWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("get project webhook: ensure secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
project, err := s.store.GetProjectByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/" + secret,
WebhookSecret: secret,
HasSigningSecret: project.WebhookSigningSecret != "",
WebhookRequireSignature: project.WebhookRequireSignature,
})
}
// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate.
// Issues a fresh HMAC signing secret for inbound webhook verification. The
// secret is returned exactly once — the UI is responsible for letting the
// user copy it into their CI configuration.
func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil {
slog.Error("rotate project signing secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
return
}
slog.Info("project webhook signing secret rotated", "project", id)
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
}
// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret.
// Clears the HMAC signing secret and disables enforcement.
func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
return
}
if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil {
slog.Warn("disable project require_signature", "project", id, "error", err)
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature.
// Toggles whether unsigned/invalidly-signed inbound webhook requests are
// rejected with 401.
func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req signingToggleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RequireSignature {
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
if project.WebhookSigningSecret == "" {
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
return
}
}
if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to update setting")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
// Rotates the project's webhook secret, invalidating the old URL.
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Verify project exists before rotating.
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("regenerate project webhook: lookup", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectWebhookSecret(id, secret); err != nil {
slog.Error("regenerate project webhook: set secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
slog.Info("project webhook secret rotated", "project", id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/" + secret,
WebhookSecret: secret,
})
}
// getStaticSiteWebhook handles GET /api/sites/{id}/webhook.
func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureStaticSiteWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("get site webhook: ensure secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/sites/" + secret,
WebhookSecret: secret,
HasSigningSecret: site.WebhookSigningSecret != "",
WebhookRequireSignature: site.WebhookRequireSignature,
})
}
// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate.
func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil {
slog.Error("rotate site signing secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
return
}
slog.Info("static site webhook signing secret rotated", "site", id)
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
}
// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret.
func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
return
}
if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil {
slog.Warn("disable site require_signature", "site", id, "error", err)
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
// Returns the most recent webhook deliveries for the project so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
if err != nil {
slog.Error("list project webhook deliveries", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
if err != nil {
slog.Error("list site webhook deliveries", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// parseLimit clamps a query-string limit to [1, max], falling back to def.
func parseLimit(raw string, def, max int) int {
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > max {
return max
}
return n
}
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req signingToggleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RequireSignature {
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
if site.WebhookSigningSecret == "" {
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
return
}
}
if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to update setting")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("regenerate site webhook: lookup", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil {
slog.Error("regenerate site webhook: set secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
slog.Info("static site webhook secret rotated", "site", id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/sites/" + secret,
WebhookSecret: secret,
})
}
+6 -55
View File
@@ -136,61 +136,12 @@ func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns
// the canonical URL + secret + signature-state flags. Lazily generates
// a secret if the workload row predates the column.
func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureWorkloadWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("ensure workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate.
// Rotates the URL secret. The old secret is invalidated immediately —
// any external system still hitting the old URL gets a 404 on next call.
func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
secret := generateWebhookSecret()
if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil {
slog.Error("rotate workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
row, _ := s.store.GetWorkloadByID(id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// Workload-level webhook URL handlers were dropped in the hard legacy
// cutover: the old `/api/webhook/workloads/{secret}` route is gone, so
// minting a workload secret would hand operators a URL that 404s. The
// inbound webhook surface is now exclusively first-class triggers
// (`/api/webhook/triggers/{secret}`); use the trigger CRUD + bindings
// endpoints to wire a workload to inbound deploys.
// validEnvKey accepts POSIX-style env names. Rejects anything that would
// confuse Docker's env parser (=, spaces, control chars).
+60
View File
@@ -20,6 +20,66 @@ type workloadVolumeRequest struct {
Name string `json:"name"`
}
// scopeInfo carries one volume scope plus its operator-facing description.
// The UI uses NeedsName to decide whether to show the name input.
type scopeInfo struct {
Scope string `json:"scope"`
Description string `json:"description"`
NeedsName bool `json:"needs_name"`
PathExample string `json:"path_example"`
}
// listVolumeScopes handles GET /api/volumes/scopes. Returns the catalogue
// of supported volume scopes so the workload-volume editor can render
// scope-specific help text without baking the list into the frontend.
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
scopes := []scopeInfo{
{
Scope: "instance",
Description: "Each deploy gets its own isolated directory keyed by image tag.",
NeedsName: false,
PathExample: "{base}/{workload}/instance-{tag}/{source}",
},
{
Scope: "stage",
Description: "Shared across all instances of this workload (alias of project scope).",
NeedsName: false,
PathExample: "{base}/{workload}/{source}",
},
{
Scope: "project",
Description: "Shared across all instances of this workload.",
NeedsName: false,
PathExample: "{base}/{workload}/{source}",
},
{
Scope: "project_named",
Description: "A named volume within the workload — multiple mounts can share the name.",
NeedsName: true,
PathExample: "{base}/{workload}/_named/{name}/{source}",
},
{
Scope: "named",
Description: "Globally named volume shared across workloads (e.g. shared databases).",
NeedsName: true,
PathExample: "{base}/_named/{name}/{source}",
},
{
Scope: "ephemeral",
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops.",
NeedsName: false,
PathExample: "(tmpfs — no host path)",
},
{
Scope: "absolute",
Description: "Direct host path. Must be under an allowed path configured in settings.",
NeedsName: false,
PathExample: "/mnt/nfs/data (must match allowed paths)",
},
}
respondJSON(w, http.StatusOK, scopes)
}
func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
+5 -45
View File
@@ -7,11 +7,12 @@ import (
"gopkg.in/yaml.v3"
)
// SeedConfig represents the top-level YAML seed configuration.
// SeedConfig represents the top-level YAML seed configuration. After the
// hard cutover only global settings + registries are supported; workloads
// are created through the API.
type SeedConfig struct {
Global GlobalConfig `yaml:"global"`
Registries map[string]RegistryDef `yaml:"registries"`
Projects map[string]ProjectDef `yaml:"projects"`
Global GlobalConfig `yaml:"global"`
Registries map[string]RegistryDef `yaml:"registries"`
}
// GlobalConfig holds domain-wide settings from the seed file.
@@ -38,27 +39,6 @@ type RegistryDef struct {
Token string `yaml:"token"`
}
// ProjectDef defines a project from the seed file.
type ProjectDef struct {
Registry string `yaml:"registry"`
Image string `yaml:"image"`
Port int `yaml:"port"`
Healthcheck string `yaml:"healthcheck"`
Env map[string]string `yaml:"env"`
Volumes map[string]string `yaml:"volumes"`
Stages map[string]StageDef `yaml:"stages"`
}
// StageDef defines a deployment stage from the seed file.
type StageDef struct {
TagPattern string `yaml:"tag_pattern"`
AutoDeploy bool `yaml:"auto_deploy"`
MaxInstances int `yaml:"max_instances"`
Confirm bool `yaml:"confirm"`
PromoteFrom string `yaml:"promote_from"`
Subdomain string `yaml:"subdomain"`
}
// LoadSeedFile reads and parses the YAML seed config from the given path.
func LoadSeedFile(path string) (SeedConfig, error) {
data, err := os.ReadFile(path)
@@ -88,25 +68,5 @@ func validate(cfg SeedConfig) error {
if cfg.Global.Domain == "" {
return fmt.Errorf("global.domain is required")
}
for name, proj := range cfg.Projects {
if proj.Image == "" {
return fmt.Errorf("project %q: image is required", name)
}
if proj.Registry != "" {
if _, ok := cfg.Registries[proj.Registry]; !ok {
return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry)
}
}
for stageName, stage := range proj.Stages {
if stage.TagPattern == "" {
return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName)
}
if stage.MaxInstances < 0 {
return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName)
}
}
}
return nil
}
+4 -57
View File
@@ -1,7 +1,6 @@
package config
import (
"encoding/json"
"fmt"
"github.com/alexei/tinyforge/internal/store"
@@ -9,8 +8,10 @@ import (
)
// ExportConfig reads the current database state and produces a SeedConfig YAML
// representation. Credential fields (tokens, passwords) are exported as placeholder
// strings since they are encrypted in the database.
// representation. Credential fields (tokens, passwords) are exported as
// placeholder strings since they are encrypted in the database. After the hard
// cutover, only global settings + registries are exported — workloads and
// triggers are created through the API, not via seed files.
func ExportConfig(db *store.Store) ([]byte, error) {
cfg, err := buildSeedConfig(db)
if err != nil {
@@ -25,7 +26,6 @@ func ExportConfig(db *store.Store) ([]byte, error) {
return data, nil
}
// buildSeedConfig constructs a SeedConfig from the current database state.
func buildSeedConfig(db *store.Store) (SeedConfig, error) {
settings, err := db.GetSettings()
if err != nil {
@@ -37,11 +37,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
return SeedConfig{}, fmt.Errorf("get registries: %w", err)
}
projects, err := db.GetAllProjects()
if err != nil {
return SeedConfig{}, fmt.Errorf("get projects: %w", err)
}
cfg := SeedConfig{
Global: GlobalConfig{
Domain: settings.Domain,
@@ -56,7 +51,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
},
},
Registries: make(map[string]RegistryDef),
Projects: make(map[string]ProjectDef),
}
for _, reg := range registries {
@@ -67,52 +61,5 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
}
}
for _, proj := range projects {
stages, err := db.GetStagesByProjectID(proj.ID)
if err != nil {
return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err)
}
stageDefs := make(map[string]StageDef)
for _, st := range stages {
stageDefs[st.Name] = StageDef{
TagPattern: st.TagPattern,
AutoDeploy: st.AutoDeploy,
MaxInstances: st.MaxInstances,
Confirm: st.Confirm,
PromoteFrom: st.PromoteFrom,
Subdomain: st.Subdomain,
}
}
envMap := parseJSONMap(proj.Env)
volMap := parseJSONMap(proj.Volumes)
cfg.Projects[proj.Name] = ProjectDef{
Registry: proj.Registry,
Image: proj.Image,
Port: proj.Port,
Healthcheck: proj.Healthcheck,
Env: envMap,
Volumes: volMap,
Stages: stageDefs,
}
}
return cfg, nil
}
// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure.
func parseJSONMap(jsonStr string) map[string]string {
if jsonStr == "" || jsonStr == "{}" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
return nil
}
if len(m) == 0 {
return nil
}
return m
}
+10 -70
View File
@@ -1,7 +1,11 @@
// Package config loads and exports seed configuration. After the hard
// cutover the seed shape covers only what survives the workload-first
// refactor: global settings and registries. Project / stage / volume
// seeding is gone; the new way to bootstrap a workload is the plugin
// pipeline (POST /api/workloads).
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -12,7 +16,7 @@ import (
)
// ImportSeed loads the seed YAML file and imports its contents into the store.
// Import is idempotent: it is skipped if any projects or registries already exist.
// Import is idempotent: it is skipped if any registries already exist.
// Credential fields (registry tokens, NPM password) are encrypted before storage.
func ImportSeed(db *store.Store, seedPath string) error {
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
@@ -47,16 +51,10 @@ func ImportSeed(db *store.Store, seedPath string) error {
return nil
}
// isPopulated returns true if the store already contains projects or registries.
// isPopulated returns true if the store already contains any registries.
// Workloads / apps are intentionally not consulted — they get created
// through the API, not seeded.
func isPopulated(db *store.Store) (bool, error) {
projects, err := db.GetAllProjects()
if err != nil {
return false, fmt.Errorf("get projects: %w", err)
}
if len(projects) > 0 {
return true, nil
}
registries, err := db.GetAllRegistries()
if err != nil {
return false, fmt.Errorf("get registries: %w", err)
@@ -64,8 +62,7 @@ func isPopulated(db *store.Store) (bool, error) {
return len(registries) > 0, nil
}
// importAll runs the full seed import inside a database transaction.
// Uses raw SQL within the transaction so all inserts are atomic.
// importAll runs the seed import inside a database transaction.
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
tx, err := db.DB().Begin()
if err != nil {
@@ -75,7 +72,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
timestamp := store.Now()
// Import registries first — projects reference them by name.
for name, regDef := range cfg.Registries {
encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token)
if err != nil {
@@ -93,50 +89,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
}
}
// Import projects and their stages.
for name, projDef := range cfg.Projects {
envJSON, err := mapToJSON(projDef.Env)
if err != nil {
return fmt.Errorf("encode env for project %q: %w", name, err)
}
volJSON, err := mapToJSON(projDef.Volumes)
if err != nil {
return fmt.Errorf("encode volumes for project %q: %w", name, err)
}
projectID := uuid.New().String()
_, err = tx.Exec(
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, name, projDef.Registry, projDef.Image, projDef.Port,
projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp,
)
if err != nil {
return fmt.Errorf("insert project %q: %w", name, err)
}
for stageName, stageDef := range projDef.Stages {
maxInstances := stageDef.MaxInstances
if maxInstances == 0 {
maxInstances = 1
}
stageID := uuid.New().String()
_, err = tx.Exec(
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
stageID, projectID, stageName, stageDef.TagPattern,
store.BoolToInt(stageDef.AutoDeploy), maxInstances,
store.BoolToInt(stageDef.Confirm), stageDef.PromoteFrom,
stageDef.Subdomain, timestamp, timestamp,
)
if err != nil {
return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err)
}
}
}
// Import global settings — encrypt NPM password.
encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password)
if err != nil {
return fmt.Errorf("encrypt npm password: %w", err)
@@ -166,15 +118,3 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
return nil
}
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
func mapToJSON(m map[string]string) (string, error) {
if m == nil {
return "{}", nil
}
b, err := json.Marshal(m)
if err != nil {
return "", err
}
return string(b), nil
}
-210
View File
@@ -1,210 +0,0 @@
package deployer
import (
"context"
"fmt"
"log/slog"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
"github.com/google/uuid"
)
// blueGreenDeploy performs a zero-downtime deployment:
// 1. Start new container (green)
// 2. Health check green
// 3. Swap NPM proxy to point to green
// 4. Stop old container (blue)
//
// If the new container fails health check, it is removed and the old one stays.
func (d *Deployer) blueGreenDeploy(
ctx context.Context,
project store.Project,
stage store.Stage,
settings store.Settings,
deployID string,
imageTag string,
) (string, string, string, error) {
// Find existing running container for this stage (the "blue" container).
existing, err := d.store.ListContainersByStageID(stage.ID)
if err != nil {
return "", "", "", fmt.Errorf("get existing containers: %w", err)
}
var blueContainer *store.Container
for _, c := range existing {
if c.State == "running" {
cCopy := c
blueContainer = &cCopy
break
}
}
// Step 1: Pull image.
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info")
authConfig, err := d.buildRegistryAuth(project)
if err != nil {
return "", "", "", fmt.Errorf("build registry auth: %w", err)
}
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
return "", "", "", fmt.Errorf("pull image: %w", err)
}
d.logDeploy(deployID, "Image pulled successfully", "info")
// Step 2: Ensure network.
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
if err != nil {
return "", "", "", fmt.Errorf("ensure network: %w", err)
}
// Step 3: Create and start green container.
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
instanceID := uuid.New().String()
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
workloadID := d.resolveProjectWorkloadID(project.ID)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
Image: project.Image + ":" + imageTag,
Env: envVars,
ExposedPorts: []string{portStr},
NetworkName: settings.Network,
NetworkID: networkID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
Mounts: mounts,
CpuLimit: stage.CpuLimit,
MemoryLimit: stage.MemoryLimit,
}
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
if stage.EnableProxy {
fqdn := subdomain + "." + settings.Domain
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
if containerCfg.Labels == nil {
containerCfg.Labels = make(map[string]string)
}
for k, v := range proxyLabels {
containerCfg.Labels[k] = v
}
}
}
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
if err != nil {
return "", "", instanceID, fmt.Errorf("create container: %w", err)
}
// Create container row.
row, err := d.store.CreateContainer(store.Container{
ID: instanceID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
StageID: stage.ID,
ContainerID: containerID,
ImageRef: project.Image + ":" + imageTag,
ImageTag: imageTag,
Host: "local",
State: "stopped",
Port: project.Port,
Subdomain: subdomain,
})
if err != nil {
return containerID, "", instanceID, fmt.Errorf("create container row: %w", err)
}
instanceID = row.ID
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
slog.Warn("link deploy to container", "error", err)
}
d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info")
if err := d.docker.StartContainer(ctx, containerID); err != nil {
return containerID, "", instanceID, fmt.Errorf("start container: %w", err)
}
if err := d.store.UpdateContainerState(instanceID, "running"); err != nil {
slog.Warn("update container state", "error", err)
}
row.State = "running"
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
// Step 4: Health check the green container.
if project.Healthcheck != "" {
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info")
if err := d.health.Check(ctx, healthURL); err != nil {
return containerID, "", instanceID, fmt.Errorf("health check green: %w", err)
}
d.logDeploy(deployID, "Blue-green: green health check passed", "info")
}
// Step 5: Swap proxy to green.
var proxyRouteID string
if stage.EnableProxy {
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
accessListID := settings.NpmAccessListID
if project.NpmAccessListID > 0 {
accessListID = project.NpmAccessListID
}
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID)
if err != nil {
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
}
row.ProxyRouteID = proxyRouteID
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
// Create/update DNS record for the green container.
fqdn := subdomain + "." + settings.Domain
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
} else {
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
}
row.Subdomain = subdomain
if err := d.store.UpdateContainer(row); err != nil {
slog.Warn("update container with proxy ID", "error", err)
}
// Step 6: Stop the blue container.
if blueContainer != nil {
d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue container %s (tag: %s)", blueContainer.ID, blueContainer.ImageTag), "info")
if err := d.removeContainer(ctx, *blueContainer, settings); err != nil {
// Non-fatal: log but continue. Green is already serving traffic.
d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue container: %v", err), "warn")
} else {
d.logDeploy(deployID, "Blue-green: blue container removed", "info")
}
}
return containerID, proxyRouteID, instanceID, nil
}
+19 -796
View File
@@ -1,16 +1,15 @@
// Package deployer dispatches plugin-native Source deploys. The legacy
// project-pipeline lived here until the hard cutover; what remains is a
// thin holder for the Deployer's shared dependencies that `dispatch.go`
// hands to every Source via PluginDeps().
package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"sync/atomic"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
@@ -18,14 +17,11 @@ import (
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
"github.com/moby/moby/api/types/mount"
"github.com/google/uuid"
)
// Deployer orchestrates the full deployment flow: pull image, create container,
// start, configure proxy, health check, and handle rollback on failure.
// It implements both webhook.DeployTriggerer and registry.DeployTriggerer.
// Deployer owns the dependency bundle each Source plugin needs at deploy
// time. The plugin pipeline reaches in via PluginDeps(); see dispatch.go
// for the dispatch surface itself.
type Deployer struct {
docker *docker.Client
proxy proxy.Provider
@@ -88,21 +84,20 @@ func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) {
d.backuper = b
}
// maybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
// MaybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
// the setting is enabled. Failures are logged but do not abort the deploy:
// missing a backup is preferable to refusing to ship a fix.
func (d *Deployer) maybeBackupBeforeDeploy(deployID string, settings store.Settings) {
// missing a backup is preferable to refusing to ship a fix. Exposed so
// Source plugins can opt into the same behaviour.
func (d *Deployer) MaybeBackupBeforeDeploy(deployID string, settings store.Settings) {
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
return
}
backup, err := d.backuper.CreateBackup("pre-deploy")
if err != nil {
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup failed: %v", err), "warn")
return
}
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup created: %s", backup.Filename), "info")
}
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
@@ -113,796 +108,24 @@ func (d *Deployer) SetDNSProvider(provider dns.Provider) {
d.dns = provider
}
// getDNS returns the current DNS provider under read lock.
func (d *Deployer) getDNS() dns.Provider {
d.dnsMu.RLock()
defer d.dnsMu.RUnlock()
return d.dns
}
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
func (d *Deployer) Drain() {
d.shuttingDown.Store(true)
if !d.shuttingDown.CompareAndSwap(false, true) {
// Already draining.
}
slog.Info("deployer: draining in-progress deploys")
d.activeWg.Wait()
slog.Info("deployer: all deploys drained")
}
// AsyncTriggerDeploy creates a deploy record and returns the deploy ID immediately,
// then runs the full deploy pipeline in a background goroutine. Use this from HTTP handlers
// to avoid blocking the request. Progress is streamed via SSE.
func (d *Deployer) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) {
if d.shuttingDown.Load() {
return "", fmt.Errorf("deployer is shutting down, rejecting new deploy")
}
// ShuttingDown reports whether Drain() has been called.
func (d *Deployer) ShuttingDown() bool { return d.shuttingDown.Load() }
// Validate inputs synchronously so the caller gets immediate feedback.
project, err := d.store.GetProjectByID(projectID)
if err != nil {
return "", fmt.Errorf("get project: %w", err)
}
stage, err := d.store.GetStageByID(stageID)
if err != nil {
return "", fmt.Errorf("get stage: %w", err)
}
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
return "", fmt.Errorf("promote validation: %w", err)
}
// Create deploy record synchronously so caller gets the ID.
deploy, err := d.store.CreateDeploy(store.Deploy{
ProjectID: projectID,
StageID: stageID,
ImageTag: imageTag,
Status: "pending",
})
if err != nil {
return "", fmt.Errorf("create deploy record: %w", err)
}
// Run the actual deploy in the background.
d.activeWg.Add(1)
go func() {
defer d.activeWg.Done()
// Use a detached context so client disconnect doesn't abort the deploy.
bgCtx := context.Background()
if err := d.runDeploy(bgCtx, project, stage, deploy.ID, imageTag); err != nil {
slog.Error("async deploy failed", "deploy_id", deploy.ID, "error", err)
}
}()
return deploy.ID, nil
}
// runDeploy is the internal deploy pipeline used by AsyncTriggerDeploy.
// It assumes the deploy record already exists and project/stage are validated.
func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage store.Stage, deployID string, imageTag string) error {
settings, err := d.store.GetSettings()
if err != nil {
if updateErr := d.store.UpdateDeployStatus(deployID, "failed", err.Error()); updateErr != nil {
slog.Warn("update deploy status", "error", updateErr)
}
return fmt.Errorf("get settings: %w", err)
}
slog.Info("starting deploy",
"deploy_id", deployID,
"project", project.Name,
"stage", stage.Name,
"tag", imageTag,
)
d.logDeploy(deployID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info")
// Take a pre-deploy DB snapshot if the operator opted in. Runs before
// any state-mutating work so a corrupted deploy is recoverable.
d.maybeBackupBeforeDeploy(deployID, settings)
// Enforce max_instances before deploying.
if err := d.enforceMaxInstances(ctx, stage, deployID, settings); err != nil {
d.logDeploy(deployID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error")
}
var containerID string
var proxyRouteID string
var instanceID string
var deployErr error
if stage.MaxInstances == 1 {
containerID, proxyRouteID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag)
} else {
containerID, proxyRouteID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag)
}
if deployErr != nil {
d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error())
d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID)
url, secret, tier := resolveDeployTarget(stage, project, settings)
d.notifier.SendSigned(url, secret, tier, notify.Event{
Type: "deploy_failure",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Error: deployErr.Error(),
})
return fmt.Errorf("deploy failed: %w", deployErr)
}
if err := d.store.UpdateDeployStatus(deployID, "success", ""); err != nil {
slog.Warn("update deploy status to success", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "success", "")
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain)
d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
url, secret, tier := resolveDeployTarget(stage, project, settings)
d.notifier.SendSigned(url, secret, tier, notify.Event{
Type: "deploy_success",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Subdomain: subdomain,
URL: fullURL,
})
return nil
}
// resolveDeployTarget picks the most-specific (URL, secret, tier) for a
// deploy notification: stage > project > global. An empty URL at a tier
// means "fall through to the next" — never "send unsigned to nowhere". The
// secret is always paired with the URL that sourced it, so a stage can sign
// even when project and global are unsigned (and vice versa).
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
if stage.NotificationURL != "" {
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
}
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
// It validates inputs, creates a deploy record, and delegates to runDeploy.
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
// rejectIfDraining is exposed in case any plugin wants the same hard-stop
// behaviour the legacy pipeline used.
func (d *Deployer) rejectIfDraining() error {
if d.shuttingDown.Load() {
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
}
d.activeWg.Add(1)
defer d.activeWg.Done()
project, err := d.store.GetProjectByID(projectID)
if err != nil {
return fmt.Errorf("get project: %w", err)
}
stage, err := d.store.GetStageByID(stageID)
if err != nil {
return fmt.Errorf("get stage: %w", err)
}
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
return fmt.Errorf("promote validation: %w", err)
}
deploy, err := d.store.CreateDeploy(store.Deploy{
ProjectID: projectID,
StageID: stageID,
ImageTag: imageTag,
Status: "pending",
})
if err != nil {
return fmt.Errorf("create deploy record: %w", err)
}
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
return err
}
return nil
}
// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state.
// It returns (containerID, proxyRouteID, instanceID, error).
func (d *Deployer) executeDeploy(
ctx context.Context,
project store.Project,
stage store.Stage,
settings store.Settings,
deployID string,
imageTag string,
) (string, string, string, error) {
var containerID string
var proxyRouteID string
var instanceID string
// Step 1: Pull image.
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info")
authConfig, err := d.buildRegistryAuth(project)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("build registry auth: %w", err)
}
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("pull image: %w", err)
}
d.logDeploy(deployID, "Image pulled successfully", "info")
// Step 2: Ensure network exists.
if settings.Network == "" {
return containerID, proxyRouteID, instanceID, fmt.Errorf("docker network not configured in settings")
}
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err)
}
d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info")
// Step 3: Create and start container.
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
// Pre-generate instance ID so it can be set as a container label.
instanceID = uuid.New().String()
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
workloadID := d.resolveProjectWorkloadID(project.ID)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
// Remove any stale container with the same name (e.g., from a previous failed deploy).
_ = d.docker.RemoveContainer(ctx, containerName, true)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
Image: project.Image + ":" + imageTag,
Env: envVars,
ExposedPorts: []string{portStr},
NetworkName: settings.Network,
NetworkID: networkID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
Mounts: mounts,
CpuLimit: stage.CpuLimit,
MemoryLimit: stage.MemoryLimit,
}
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
if stage.EnableProxy {
fqdn := subdomain + "." + settings.Domain
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
if containerCfg.Labels == nil {
containerCfg.Labels = make(map[string]string)
}
for k, v := range proxyLabels {
containerCfg.Labels[k] = v
}
}
}
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("create container: %w", err)
}
d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info")
// Create container row with the pre-generated ID. The deployer is the
// authoritative writer until the next reconciler tick — it's important
// the row exists before StartContainer so a fast tick doesn't see an
// orphan and mark it missing.
row, err := d.store.CreateContainer(store.Container{
ID: instanceID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
StageID: stage.ID,
ContainerID: containerID,
ImageRef: project.Image + ":" + imageTag,
ImageTag: imageTag,
Host: "local",
State: "stopped",
Port: project.Port,
Subdomain: subdomain,
})
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("create container row: %w", err)
}
instanceID = row.ID
// Link deploy to container row (the existing Deploy.InstanceID column
// stores the row ID — same value as before, just a renamed concept).
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
slog.Warn("link deploy to container", "error", err)
}
d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info")
if err := d.docker.StartContainer(ctx, containerID); err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("start container: %w", err)
}
if err := d.store.UpdateContainerState(instanceID, "running"); err != nil {
slog.Warn("update container state to running", "error", err)
}
row.State = "running"
row.LastSeenAt = store.Now()
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
d.logDeploy(deployID, "Container started", "info")
// Step 4: Configure NPM proxy (optional per stage).
if stage.EnableProxy {
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
accessListID := settings.NpmAccessListID
if project.NpmAccessListID > 0 {
accessListID = project.NpmAccessListID
}
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
}
// Update container row with proxy route ID.
row.ProxyRouteID = proxyRouteID
row.Subdomain = subdomain
if err := d.store.UpdateContainer(row); err != nil {
slog.Warn("update container with proxy ID", "error", err)
}
// Create DNS record for this container.
fqdn := subdomain + "." + settings.Domain
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
} else {
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
row.Subdomain = subdomain
if err := d.store.UpdateContainer(row); err != nil {
slog.Warn("update container", "error", err)
}
}
// Step 5: Health check.
if project.Healthcheck != "" {
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info")
if err := d.health.Check(ctx, healthURL); err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("health check: %w", err)
}
d.logDeploy(deployID, "Health check passed", "info")
} else {
d.logDeploy(deployID, "No health check configured, skipping", "info")
}
return containerID, proxyRouteID, instanceID, nil
}
// configureProxy creates or updates a proxy route for the deployed container.
// Uses the configured proxy.Provider (NPM, Traefik, or None).
// In NPM remote mode, uses server_ip + published host port instead of container name.
// Returns the proxy route ID string.
func (d *Deployer) configureProxy(
ctx context.Context,
deployID string,
settings store.Settings,
containerID string,
containerName string,
containerPort int,
subdomain string,
accessListID int,
) (string, error) {
fqdn := subdomain + "." + settings.Domain
forwardHost := containerName
forwardPort := containerPort
// In NPM remote mode, use server_ip and the published host port.
if settings.NpmRemote && settings.ProxyProvider == "npm" {
if settings.ServerIP == "" {
return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings")
}
forwardHost = settings.ServerIP
hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort))
if err != nil {
return "", fmt.Errorf("look up host port for remote NPM: %w", err)
}
forwardPort = int(hostPort)
d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info")
}
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info")
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
AccessListID: accessListID,
})
if err != nil {
return "", fmt.Errorf("configure proxy route: %w", err)
}
if routeID != "" {
d.logDeploy(deployID, fmt.Sprintf("Proxy route configured (ID: %s)", routeID), "info")
}
return routeID, nil
}
// enforceMaxInstances removes the oldest container rows when the stage has
// reached its instance limit, making room for the new deploy.
func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, deployID string, settings store.Settings) error {
if stage.MaxInstances <= 0 {
return nil
}
containers, err := d.store.ListContainersByStageID(stage.ID)
if err != nil {
return fmt.Errorf("get containers for stage: %w", err)
}
// Filter to running/stopped containers (not already failed/removing).
var active []store.Container
for _, c := range containers {
if c.State == "running" || c.State == "stopped" {
active = append(active, c)
}
}
// We need room for one more container, so remove the oldest when at limit.
removeCount := len(active) - stage.MaxInstances + 1
if removeCount <= 0 {
return nil
}
// Sort by created_at ascending (oldest first).
sort.Slice(active, func(i, j int) bool {
return active[i].CreatedAt < active[j].CreatedAt
})
for i := 0; i < removeCount && i < len(active); i++ {
c := active[i]
d.logDeploy(deployID, fmt.Sprintf("Removing oldest container %s (tag: %s) to enforce max_instances=%d", c.ID, c.ImageTag, stage.MaxInstances), "info")
if err := d.removeContainer(ctx, c, settings); err != nil {
d.logDeploy(deployID, fmt.Sprintf("Failed to remove container %s: %v", c.ID, err), "warn")
continue
}
d.logDeploy(deployID, fmt.Sprintf("Removed container %s", c.ID), "info")
}
return nil
}
// removeContainer stops + removes the Docker container, deletes its proxy
// route, drops the DNS record, and removes the container row from the store.
func (d *Deployer) removeContainer(ctx context.Context, c store.Container, settings store.Settings) error {
// Mark as removing.
if err := d.store.UpdateContainerState(c.ID, "removing"); err != nil {
slog.Warn("update container state to removing", "id", c.ID, "error", err)
}
// Remove Docker container.
if c.ContainerID != "" {
if err := d.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
slog.Warn("remove docker container", "container_id", c.ContainerID, "error", err)
}
}
// Delete proxy route.
if c.ProxyRouteID != "" {
if err := d.proxy.DeleteRoute(ctx, c.ProxyRouteID); err != nil {
slog.Warn("delete proxy route", "route_id", c.ProxyRouteID, "error", err)
}
// Remove DNS record.
if c.Subdomain != "" && settings.Domain != "" {
fqdn := c.Subdomain + "." + settings.Domain
d.removeDNS(ctx, fqdn, "")
}
}
// Drop the container row.
if err := d.store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("delete container row: %w", err)
}
return nil
}
// buildSubdomain generates the subdomain for an instance based on settings and stage config.
func (d *Deployer) buildSubdomain(project store.Project, stage store.Stage, settings store.Settings, imageTag string) string {
return GenerateTaggedSubdomain(settings.SubdomainPattern, project.Name, stage.Name, imageTag, stage.Subdomain)
}
// buildRegistryAuth constructs the Docker registry auth string for pulling images.
// If the project has a registry configured, it looks up the registry token.
func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
if project.Registry == "" {
return "", nil
}
reg, err := d.store.GetRegistryByName(project.Registry)
if err != nil {
return "", fmt.Errorf("get registry %s: %w", project.Registry, err)
}
if reg.Token != "" {
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
if err != nil {
return "", fmt.Errorf("decrypt registry token: %w", err)
}
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
}
return "", nil
}
// mergeEnvVars builds the final environment variable list for a container:
// 1. Parse project-level env JSON
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
// 3. Decrypt any encrypted (secret) values
// Returns a []string of KEY=VALUE pairs.
func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string {
// Step 1: Parse project-level env.
envMap := make(map[string]string)
if project.Env != "" && project.Env != "{}" {
var projectEnv map[string]string
if err := json.Unmarshal([]byte(project.Env), &projectEnv); err != nil {
slog.Warn("parse project env vars", "error", err)
} else {
for k, v := range projectEnv {
envMap[k] = v
}
}
}
// Step 2: Overlay with stage-level overrides.
stageEnvs, err := d.store.GetStageEnvByStageID(stageID)
if err != nil {
slog.Warn("get stage env overrides", "stage_id", stageID, "error", err)
} else {
for _, se := range stageEnvs {
value := se.Value
if se.Encrypted {
// Step 3: Decrypt secret values.
decrypted, err := crypto.Decrypt(d.encKey, se.Value)
if err != nil {
slog.Warn("decrypt stage env value", "key", se.Key, "error", err)
continue
}
value = decrypted
}
envMap[se.Key] = value
}
}
vars := make([]string, 0, len(envMap))
for k, v := range envMap {
vars = append(vars, k+"="+v)
}
return vars
}
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
// Uses the shared volume.ResolvePath for path resolution.
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
vols, err := d.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Warn("get project volumes", "project_id", projectID, "error", err)
return nil
}
if len(vols) == 0 {
return nil
}
params := volume.ResolveParams{
BasePath: basePath,
ProjectName: projectName,
StageName: stageName,
ImageTag: imageTag,
}
mounts := make([]mount.Mount, 0, len(vols))
for _, vol := range vols {
scope := vol.Scope
if scope == "" {
switch vol.Mode {
case "isolated":
scope = "instance"
default:
scope = "project"
}
}
// Ephemeral volumes use tmpfs — no host path.
if scope == "ephemeral" {
mounts = append(mounts, mount.Mount{
Type: mount.TypeTmpfs,
Target: vol.Target,
})
continue
}
source, err := volume.ResolvePath(vol, params)
if err != nil {
slog.Warn("resolve volume path", "volume_id", vol.ID, "error", err)
continue
}
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: source,
Target: vol.Target,
})
}
return mounts
}
// logDeploy appends a log entry for a deploy and publishes it on the event bus.
// Errors are logged to stderr but not propagated.
func (d *Deployer) logDeploy(deployID, message, level string) {
if err := d.store.AppendDeployLog(deployID, message, level); err != nil {
slog.Warn("append deploy log", "error", err)
}
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventDeployLog,
Payload: events.DeployLogPayload{
DeployID: deployID,
Message: message,
Level: level,
},
})
}
}
// publishDeployStatus publishes a deploy status change event on the bus.
func (d *Deployer) publishDeployStatus(deployID, projectID, stageID, imageTag, status, deployErr string) {
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventDeployStatus,
Payload: events.DeployStatusPayload{
DeployID: deployID,
ProjectID: projectID,
StageID: stageID,
ImageTag: imageTag,
Status: status,
Error: deployErr,
},
})
}
}
// publishInstanceStatus publishes an instance status change event on the bus.
func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status string) {
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventInstanceStatus,
Payload: events.InstanceStatusPayload{
InstanceID: instanceID,
ProjectID: projectID,
StageID: stageID,
Status: status,
},
})
}
}
// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) {
dnsProvider := d.getDNS()
if dnsProvider == nil {
return
}
settings, err := d.store.GetSettings()
if err != nil {
slog.Warn("dns: get settings for server IP", "error", err)
return
}
if settings.ServerIP == "" {
slog.Warn("dns: server IP not configured, skipping DNS record creation", "fqdn", fqdn)
return
}
recordID, err := dnsProvider.EnsureRecord(ctx, fqdn, settings.ServerIP)
if err != nil {
msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err)
slog.Warn(msg)
if deployID != "" {
d.logDeploy(deployID, msg, "warn")
}
return
}
// Track the record locally.
if _, err := d.store.CreateDNSRecord(store.DNSRecord{
FQDN: fqdn,
ProviderRecordID: recordID,
ConsumerType: consumerType,
ConsumerID: consumerID,
}); err != nil {
// May already exist — try updating.
if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil {
slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr)
}
}
logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn)
slog.Info(logMsg)
if deployID != "" {
d.logDeploy(deployID, logMsg, "info")
}
}
// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) {
dnsProvider := d.getDNS()
if dnsProvider == nil {
return
}
if err := dnsProvider.DeleteRecord(ctx, fqdn); err != nil {
msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err)
slog.Warn(msg)
if deployID != "" {
d.logDeploy(deployID, msg, "warn")
}
return
}
// Remove local tracking.
if err := d.store.DeleteDNSRecord(fqdn); err != nil {
slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err)
}
logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn)
slog.Info(logMsg)
if deployID != "" {
d.logDeploy(deployID, logMsg, "info")
}
}
// truncateID safely truncates a Docker ID to 12 characters for display.
func truncateID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
// resolveProjectWorkloadID returns the workload ID paired with a project.
// Backfill-on-boot guarantees the row exists, so this is essentially a lookup.
// On miss (defensive), it logs and returns empty so the caller can decide.
func (d *Deployer) resolveProjectWorkloadID(projectID string) string {
w, err := d.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
if err != nil {
slog.Warn("resolve project workload", "project_id", projectID, "error", err)
return ""
}
return w.ID
}
-49
View File
@@ -1,49 +0,0 @@
package deployer
import (
"fmt"
"github.com/alexei/tinyforge/internal/store"
)
// validatePromoteFrom checks that a tag is running in the promote_from stage
// before allowing it to be deployed to the target stage.
// Returns nil if no promote_from is configured or if the tag is eligible.
func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error {
if stage.PromoteFrom == "" {
return nil
}
// Look up the source stage by name within the same project.
stages, err := d.store.GetStagesByProjectID(stage.ProjectID)
if err != nil {
return fmt.Errorf("get stages for project: %w", err)
}
var sourceStage *store.Stage
for _, s := range stages {
if s.Name == stage.PromoteFrom {
sCopy := s
sourceStage = &sCopy
break
}
}
if sourceStage == nil {
return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom)
}
// Check if the tag is running in the source stage.
containers, err := d.store.ListContainersByStageID(sourceStage.ID)
if err != nil {
return fmt.Errorf("get containers for source stage: %w", err)
}
for _, c := range containers {
if c.ImageTag == imageTag && (c.State == "running" || c.State == "stopped") {
return nil // Tag found in source stage, promotion is allowed.
}
}
return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom)
}
-89
View File
@@ -1,89 +0,0 @@
package deployer
import (
"testing"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// TestResolveDeployTarget locks the stage→project→global precedence. The
// most-specific tier with a non-empty URL wins, and the secret travels
// with the URL that sourced it (so a stage can sign even when project and
// global are unsigned). A regression here misroutes notifications and
// silently leaks events to the wrong receiver — worth catching.
func TestResolveDeployTarget(t *testing.T) {
cases := []struct {
name string
stage store.Stage
project store.Project
settings store.Settings
wantURL string
wantSec string
wantTier notify.Tier
}{
{
name: "stage wins when set",
stage: store.Stage{NotificationURL: "https://stage.example/wh", NotificationSecret: "stage-key"},
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://stage.example/wh",
wantSec: "stage-key",
wantTier: notify.TierStage,
},
{
name: "stage URL empty → project wins",
stage: store.Stage{NotificationURL: "", NotificationSecret: "stage-key"}, // secret without URL ignored
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://project.example/wh",
wantSec: "project-key",
wantTier: notify.TierProject,
},
{
name: "stage and project empty → global wins",
stage: store.Stage{},
project: store.Project{},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://global.example/wh",
wantSec: "global-key",
wantTier: notify.TierSettings,
},
{
name: "all empty → returns settings tier with empty URL (caller skips)",
stage: store.Stage{},
project: store.Project{},
settings: store.Settings{},
wantURL: "",
wantSec: "",
wantTier: notify.TierSettings,
},
{
name: "stage signs even when global is unsigned",
stage: store.Stage{
NotificationURL: "https://stage.example/wh",
NotificationSecret: "stage-only-key",
},
project: store.Project{},
settings: store.Settings{NotificationURL: "https://global.example/wh"},
wantURL: "https://stage.example/wh",
wantSec: "stage-only-key",
wantTier: notify.TierStage,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotSec, gotTier := resolveDeployTarget(tc.stage, tc.project, 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)
}
})
}
}
-63
View File
@@ -1,63 +0,0 @@
package deployer
import (
"context"
"fmt"
"log/slog"
)
// rollback cleans up a failed deployment by removing the container,
// deleting the proxy route, and updating the instance status.
// Errors during rollback are logged but do not prevent other cleanup steps.
func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, proxyRouteID string, instanceID string) {
d.logDeploy(deployID, "Rolling back failed deployment", "warn")
// Remove the container if it was created.
if containerID != "" {
if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil {
slog.Warn("rollback: remove container", "container_id", containerID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error")
} else {
d.logDeploy(deployID, "Rollback: container removed", "info")
}
}
// Delete the proxy route if it was created.
if proxyRouteID != "" {
if err := d.proxy.DeleteRoute(ctx, proxyRouteID); err != nil {
slog.Warn("rollback: delete proxy route", "route_id", proxyRouteID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy route: %v", err), "error")
} else {
d.logDeploy(deployID, "Rollback: proxy route deleted", "info")
}
}
// Clean up DNS record if the container had a subdomain. instanceID is
// the container row ID (same UUID either way) — read from containers.
if instanceID != "" {
c, err := d.store.GetContainerByID(instanceID)
if err == nil && c.Subdomain != "" {
settings, settingsErr := d.store.GetSettings()
if settingsErr != nil {
slog.Warn("rollback: failed to get settings for DNS cleanup", "error", settingsErr)
} else if settings.Domain != "" {
fqdn := c.Subdomain + "." + settings.Domain
d.removeDNS(ctx, fqdn, deployID)
}
}
}
// Mark the container row as failed if it was created.
if instanceID != "" {
if err := d.store.UpdateContainerState(instanceID, "failed"); err != nil {
slog.Warn("rollback: update container state", "id", instanceID, "error", err)
}
}
// Mark deploy as rolled back.
if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil {
slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err)
}
d.logDeploy(deployID, "Rollback complete", "info")
}
-84
View File
@@ -1,84 +0,0 @@
package deployer
import (
"regexp"
"strings"
)
// maxSubdomainLen is the maximum length of a single DNS label (RFC 1035).
const maxSubdomainLen = 63
// invalidDNSChars matches characters not allowed in a DNS label.
var invalidDNSChars = regexp.MustCompile(`[^a-z0-9-]`)
// GenerateSubdomain builds a subdomain string from the given pattern and parameters.
// The pattern may contain {stage}, {project}, and {tag} placeholders.
// If the stage has a custom subdomain override, that value is used instead of the pattern.
func GenerateSubdomain(pattern, project, stage, tag, stageSubdomain string) string {
if stageSubdomain != "" {
return SanitizeDNSLabel(stageSubdomain)
}
result := pattern
result = strings.ReplaceAll(result, "{stage}", stage)
result = strings.ReplaceAll(result, "{project}", project)
result = strings.ReplaceAll(result, "{tag}", tag)
return SanitizeDNSLabel(result)
}
// GenerateTaggedSubdomain builds a subdomain that includes the tag for multi-instance support.
// It appends "-{sanitized_tag}" to the base subdomain.
func GenerateTaggedSubdomain(pattern, project, stage, tag, stageSubdomain string) string {
base := GenerateSubdomain(pattern, project, stage, "", stageSubdomain)
sanitizedTag := SanitizeDNSLabel(tag)
if sanitizedTag == "" {
return base
}
combined := base + "-" + sanitizedTag
return truncateDNSLabel(combined)
}
// SanitizeDNSLabel converts an arbitrary string into a valid DNS label.
// It lowercases, replaces dots and invalid characters with hyphens,
// collapses consecutive hyphens, trims leading/trailing hyphens, and truncates.
func SanitizeDNSLabel(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, ".", "-")
s = invalidDNSChars.ReplaceAllString(s, "-")
s = collapseHyphens(s)
s = strings.Trim(s, "-")
return truncateDNSLabel(s)
}
// collapseHyphens replaces consecutive hyphens with a single hyphen.
func collapseHyphens(s string) string {
prev := false
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '-' {
if !prev {
b.WriteRune(r)
}
prev = true
} else {
b.WriteRune(r)
prev = false
}
}
return b.String()
}
// truncateDNSLabel truncates a label to maxSubdomainLen characters,
// ensuring it does not end with a hyphen after truncation.
func truncateDNSLabel(s string) string {
if len(s) <= maxSubdomainLen {
return s
}
s = s[:maxSubdomainLen]
return strings.TrimRight(s, "-")
}
+25 -107
View File
@@ -1,21 +1,16 @@
// Package reconciler keeps the normalized containers index in sync with the
// Docker daemon. It runs on a tick (and one-shot at boot) — for every
// Tinyforge-managed container in `docker ps`, it dispatches to a workload by
// labels and writes a Container row through ReconcileContainer (which only
// touches Docker-derived fields on conflict, never deployer-owned columns
// like subdomain / proxy_route_id / npm_proxy_id / image_tag / stage_id).
// Rows whose Docker container ID is no longer present are flipped to
// state='missing'.
// Tinyforge-managed container in `docker ps`, it resolves a workload by the
// canonical workload-id label and writes a Container row through
// ReconcileContainer (which only touches Docker-derived fields on conflict,
// never deployer-owned columns like subdomain / proxy_route_id /
// npm_proxy_id / image_tag / stage_id). Rows whose Docker container ID is no
// longer present are flipped to state='missing'.
//
// Dispatch precedence (a container with multiple matching labels is dispatched
// by the first match in this order):
// 1. tinyforge.workload.id label (canonical, new)
// 2. tinyforge.static-site label (legacy site — joins via static_sites)
// 3. com.docker.compose.project (stack — joins via Stack.ComposeProjectName)
//
// The legacy tinyforge.instance-id path was removed when the deployer was
// rewritten to use Container natively — every Tinyforge-managed project
// container now carries the workload labels at create time.
// Only the tinyforge.workload.id label is honored after the hard cutover —
// every Source plugin labels its containers with the workload identity at
// create time. The legacy tinyforge.static-site / compose-project paths
// were dropped along with the static_sites / stacks tables.
package reconciler
import (
@@ -23,7 +18,6 @@ import (
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
@@ -118,12 +112,8 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error {
}
seen := make(map[string]struct{}, len(items)) // container row IDs we touched
// Build a per-pass cache of compose project name → stack ID so we don't
// hit the DB once per compose container.
stackByCompose := map[string]store.Stack{}
for _, item := range items {
rowID := r.upsertFromItem(item, stackByCompose)
rowID := r.upsertFromItem(item)
if rowID != "" {
seen[rowID] = struct{}{}
}
@@ -221,16 +211,13 @@ func (r *Reconciler) loop(ctx context.Context) {
// upsertFromItem dispatches one container to its workload and writes the
// Container row. Returns the row ID on success or "" if no dispatch matched.
func (r *Reconciler) upsertFromItem(item docker.ReconcileItem, stackCache map[string]store.Stack) string {
// After the hard cutover only the canonical tinyforge.workload.id label
// path is honored — every Source plugin labels its containers with the
// workload identity at create time.
func (r *Reconciler) upsertFromItem(item docker.ReconcileItem) string {
if id := item.Labels[docker.LabelWorkloadID]; id != "" {
return r.upsertByWorkloadLabel(item, id)
}
if siteID := item.Labels["tinyforge.static-site"]; siteID != "" {
return r.upsertBySiteLabel(item, siteID)
}
if cp := item.Labels["com.docker.compose.project"]; cp != "" && strings.HasPrefix(cp, "tinyforge-") {
return r.upsertByComposeProject(item, cp, stackCache)
}
return ""
}
@@ -300,9 +287,11 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID
return ""
}
// Site/stack reach this branch only when their kind-specific dispatcher
// hasn't run yet (e.g. boot tick before site row is registered). The
// site/stack dispatchers below own their own deterministic IDs.
// Site/stack reach this branch only when their plugin hasn't yet
// upserted the row (e.g. a boot tick that races the first deploy).
// The deterministic ID computed here matches what the static and
// compose plugins write in their state-save paths, so a subsequent
// plugin write upserts in place rather than creating a sibling row.
rowID := workloadIDRow(workloadID, kind, role, item.ID)
port := 0
if len(item.Ports) > 0 {
@@ -326,79 +315,6 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID
return rowID
}
func (r *Reconciler) upsertBySiteLabel(item docker.ReconcileItem, siteID string) string {
w, err := r.store.GetWorkloadByRef(store.WorkloadKindSite, siteID)
if err != nil {
return ""
}
rowID := w.ID + ":site"
port := 0
if len(item.Ports) > 0 {
port = int(item.Ports[0])
}
if err := r.store.ReconcileContainer(store.Container{
ID: rowID,
WorkloadID: w.ID,
WorkloadKind: string(store.WorkloadKindSite),
Role: "",
ContainerID: item.ID,
ImageRef: item.Image,
Host: "local",
State: normalizeState(item.State),
Port: port,
LastSeenAt: store.Now(),
}); err != nil {
slog.Warn("reconciler: reconcile by site label", "container_id", item.ID, "error", err)
return ""
}
return rowID
}
func (r *Reconciler) upsertByComposeProject(item docker.ReconcileItem, composeProject string, cache map[string]store.Stack) string {
stack, ok := cache[composeProject]
if !ok {
st, err := r.store.GetStackByComposeProjectName(composeProject)
if err != nil {
cache[composeProject] = store.Stack{} // negative cache for the rest of the pass
return ""
}
stack = st
cache[composeProject] = st
}
if stack.ID == "" {
return ""
}
w, err := r.store.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
if err != nil {
return ""
}
role := item.Labels["com.docker.compose.service"]
if role == "" {
role = item.Name
}
rowID := w.ID + ":" + role
port := 0
if len(item.Ports) > 0 {
port = int(item.Ports[0])
}
if err := r.store.ReconcileContainer(store.Container{
ID: rowID,
WorkloadID: w.ID,
WorkloadKind: string(store.WorkloadKindStack),
Role: role,
ContainerID: item.ID,
ImageRef: item.Image,
Host: "local",
State: normalizeState(item.State),
Port: port,
LastSeenAt: store.Now(),
}); err != nil {
slog.Warn("reconciler: reconcile by compose project", "container_id", item.ID, "error", err)
return ""
}
return rowID
}
// markMissingRows flips state to 'missing' for any container row whose Docker
// container ID was not seen in this pass. Uses ListMissingSweepRows to scan
// only rows that are bound to a real container and not already missing.
@@ -419,9 +335,11 @@ func (r *Reconciler) markMissingRows(seen map[string]struct{}) {
}
// workloadIDRow picks the row ID for a non-project workload-labelled
// container that has no existing row. Stack rows use workloadID:role; sites
// use workloadID:site. Project rows are never invented here — see
// upsertByWorkloadLabel for the rationale.
// container that has no existing row. Sites use `<workloadID>:site`
// (matches the static plugin's `containerRowID` helper). Stack
// services use `<workloadID>:<service-role>` (matches the compose
// plugin). Project rows are never invented here — the deployer
// pre-creates per-instance UUID rows so the reconciler must wait.
func workloadIDRow(workloadID, kind, role, containerID string) string {
if kind == string(store.WorkloadKindSite) {
return workloadID + ":site"
+21 -71
View File
@@ -28,17 +28,23 @@ func newTestStore(t *testing.T) *store.Store {
return s
}
// makeWorkload inserts a workload row with the given kind so reconciler
// dispatch can resolve it by ID.
func makeWorkload(t *testing.T, st *store.Store, name, kind string) store.Workload {
t.Helper()
w, err := st.CreateWorkload(store.Workload{
Kind: kind, RefID: name + "-ref", Name: name,
})
if err != nil {
t.Fatalf("CreateWorkload: %v", err)
}
return w
}
func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
st := newTestStore(t)
// Set up a stack workload (no project/site interaction).
stack, err := st.CreateStack(store.Stack{
Name: "wf-stack", ComposeProjectName: "tinyforge-wf-stack",
})
if err != nil {
t.Fatalf("CreateStack: %v", err)
}
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
w := makeWorkload(t, st, "wf-stack", "stack")
// One container with the canonical workload labels stamped.
fake := &fakeDocker{items: []docker.ReconcileItem{{
@@ -76,51 +82,10 @@ func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
}
}
func TestReconcileComposeOnlyStackContainer(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "compose-stack", ComposeProjectName: "tinyforge-compose-stack",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
// Pre-existing compose container — only carries compose's own labels,
// no tinyforge.* labels at all.
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-xyz",
Name: "tinyforge-compose-stack-worker-1",
Image: "redis:7",
State: "running",
Labels: map[string]string{
"com.docker.compose.project": "tinyforge-compose-stack",
"com.docker.compose.service": "worker",
},
}}}
r := New(st, fake, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
rows, _ := st.ListContainersByWorkload(w.ID)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Role != "worker" {
t.Fatalf("role from compose label wrong: %q", rows[0].Role)
}
if rows[0].ContainerID != "docker-xyz" {
t.Fatalf("container_id not bound: %q", rows[0].ContainerID)
}
}
func TestReconcileMarksMissingRows(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "missing-stack", ComposeProjectName: "tinyforge-missing-stack",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
w := makeWorkload(t, st, "missing-stack", "stack")
// Pre-existing row with a real container_id that no longer exists.
if err := st.UpsertContainer(store.Container{
@@ -145,10 +110,7 @@ func TestReconcileMarksMissingRows(t *testing.T) {
func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "pending", ComposeProjectName: "tinyforge-pending",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
w := makeWorkload(t, st, "pending", "stack")
// A row with empty container_id (deployer placeholder, awaiting docker
// create). Reconciler must not mark this as missing.
@@ -171,9 +133,8 @@ func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
}
func TestReconcileIgnoresUnmanagedContainers(t *testing.T) {
// A container without any tinyforge or compose labels would not even be
// returned by ListAllForReconciler in production; but the dispatch must
// be a no-op even if a stray item slips through.
// A container without the canonical workload label is ignored even if
// it carries other labels — only tinyforge.workload.id is honored.
st := newTestStore(t)
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-foreign", Labels: map[string]string{"app": "other"},
@@ -197,11 +158,7 @@ func TestReconcileDoesNotClobberDeployerFields(t *testing.T) {
// Project workload — exercises the path most affected by the regression
// (proxies, blue-green slots, image-tag-based stale detection).
project, err := st.CreateProject(store.Project{Name: "p", Image: "nginx"})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID)
w := makeWorkload(t, st, "p", "project")
// Deployer wrote the row with proxy / subdomain / image_tag / stage_id.
deployerRow := store.Container{
@@ -277,11 +234,7 @@ func TestReconcileRejectsForgedWorkloadLabel(t *testing.T) {
// authoritative writer and inventing rows races with MaxInstances > 1 deploys.
func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) {
st := newTestStore(t)
project, err := st.CreateProject(store.Project{Name: "p2", Image: "nginx"})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID)
w := makeWorkload(t, st, "p2", "project")
// Reconciler sees a real container with project labels but no deployer
// row exists yet (race during deploy).
@@ -306,10 +259,7 @@ func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) {
func TestReconcileNormalizesState(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "norm", ComposeProjectName: "tinyforge-norm",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
w := makeWorkload(t, st, "norm", "stack")
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-1",
-189
View File
@@ -1,189 +0,0 @@
package registry
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
"github.com/robfig/cron/v3"
)
// Poller periodically checks registries for new image tags and triggers
// deployments for stages with auto_deploy enabled.
type Poller struct {
store *store.Store
deployer DeployTriggerer
encKey [32]byte
cron *cron.Cron
mu sync.Mutex
entryID cron.EntryID
running bool
}
// NewPoller creates a new Poller instance.
func NewPoller(st *store.Store, deployer DeployTriggerer, encKey [32]byte) *Poller {
return &Poller{
store: st,
deployer: deployer,
encKey: encKey,
cron: cron.New(),
}
}
// Start begins the polling scheduler with the given interval string (e.g., "5m", "1h").
// If the poller is already running, it stops and restarts with the new interval.
func (p *Poller) Start(interval string) error {
p.mu.Lock()
defer p.mu.Unlock()
duration, err := time.ParseDuration(interval)
if err != nil {
return fmt.Errorf("parse polling interval %q: %w", interval, err)
}
// Stop existing schedule if running.
if p.running {
p.cron.Remove(p.entryID)
}
// Convert duration to a cron schedule: @every <duration>.
spec := fmt.Sprintf("@every %s", duration.String())
entryID, err := p.cron.AddFunc(spec, func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if pollErr := p.poll(ctx); pollErr != nil {
slog.Warn("poller: poll error", "error", pollErr)
}
})
if err != nil {
return fmt.Errorf("schedule poller: %w", err)
}
p.entryID = entryID
if !p.running {
p.cron.Start()
}
p.running = true
slog.Info("poller started", "interval", duration.String())
return nil
}
// Stop gracefully shuts down the poller.
func (p *Poller) Stop() {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
ctx := p.cron.Stop()
<-ctx.Done()
p.running = false
slog.Info("poller stopped")
}
}
// poll performs a single polling cycle: iterates over all projects and their
// stages, checks for new tags, and triggers deploys where appropriate.
func (p *Poller) poll(ctx context.Context) error {
projects, err := p.store.GetAllProjects()
if err != nil {
return fmt.Errorf("get projects: %w", err)
}
for _, project := range projects {
if err := p.pollProject(ctx, project); err != nil {
slog.Warn("poller: project error", "project", project.Name, "id", project.ID, "error", err)
}
}
return nil
}
// pollProject checks all stages of a single project for new tags.
func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
if project.Registry == "" {
return nil
}
reg, err := p.store.GetRegistryByName(project.Registry)
if err != nil {
return fmt.Errorf("get registry %s: %w", project.Registry, err)
}
token, err := crypto.Decrypt(p.encKey, reg.Token)
if err != nil {
token = reg.Token
}
client, err := NewClient(reg.Type, reg.URL, token)
if err != nil {
return fmt.Errorf("create registry client: %w", err)
}
tags, err := client.ListTags(ctx, project.Image)
if err != nil {
return fmt.Errorf("list tags for %s: %w", project.Image, err)
}
stages, err := p.store.GetStagesByProjectID(project.ID)
if err != nil {
return fmt.Errorf("get stages for project %s: %w", project.ID, err)
}
for _, stage := range stages {
if err := p.pollStage(ctx, project, stage, tags); err != nil {
slog.Warn("poller: stage error", "project", project.Name, "stage", stage.Name, "error", err)
}
}
return nil
}
// pollStage checks a single stage for new tags and triggers deploy if needed.
func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error {
latest, err := LatestTag(allTags, stage.TagPattern)
if err != nil {
return fmt.Errorf("match tags for stage %s: %w", stage.Name, err)
}
if latest == "" {
return nil
}
state, err := p.store.GetPollState(stage.ID)
if err != nil {
return p.store.UpsertPollState(store.PollState{
StageID: stage.ID,
LastTag: latest,
LastPolled: store.Now(),
})
}
defer func() {
if err := p.store.UpsertPollState(store.PollState{
StageID: stage.ID,
LastTag: latest,
LastPolled: store.Now(),
}); err != nil {
slog.Warn("poller: failed to update poll state", "stage_id", stage.ID, "error", err)
}
}()
if state.LastTag == latest {
return nil
}
slog.Info("poller: new tag detected", "tag", latest, "project", project.Name, "stage", stage.Name, "previous", state.LastTag)
if !stage.AutoDeploy {
slog.Info("poller: auto_deploy disabled, skipping", "stage", stage.Name)
return nil
}
if err := p.deployer.TriggerDeploy(ctx, project.ID, stage.ID, latest); err != nil {
return fmt.Errorf("trigger deploy for tag %s: %w", latest, err)
}
return nil
}
-7
View File
@@ -29,13 +29,6 @@ type Client interface {
ListImages(ctx context.Context, owner string) ([]RegistryImage, error)
}
// DeployTriggerer is called by the poller when a new tag is detected for a
// stage with auto_deploy enabled. This decouples the registry package from the
// deployer implementation.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
}
// MatchTags filters a list of tags, returning only those that match the given
// glob pattern. Pattern matching uses path.Match semantics (*, ?, []).
// Returns an error if the pattern is malformed.
-405
View File
@@ -1,405 +0,0 @@
package stack
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// Manager orchestrates the stack deployment pipeline: validate YAML, persist
// a revision, write YAML to disk, run `docker compose up`, update status.
type Manager struct {
store *store.Store
compose *Compose
eventBus *events.Bus
workDir string // where per-stack YAML files are written
}
// NewManager constructs a stack Manager. workDir is the directory where
// per-stack YAML files are written; it is created if missing.
func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) {
if workDir == "" {
workDir = filepath.Join(os.TempDir(), "tinyforge-stacks")
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return nil, fmt.Errorf("create stack workdir: %w", err)
}
return &Manager{
store: st,
compose: compose,
eventBus: eventBus,
workDir: workDir,
}, nil
}
// Available reports whether the underlying `docker compose` CLI is usable.
func (m *Manager) Available(ctx context.Context) error {
return m.compose.Available(ctx)
}
// Create inserts a new stack + its initial revision. Does NOT deploy.
func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) {
if strings.TrimSpace(name) == "" {
return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required")
}
spec, err := Parse(yamlText)
if err != nil {
return store.Stack{}, store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.Stack{}, store.StackRevision{}, err
}
st := store.Stack{
Name: name,
Description: description,
ComposeProjectName: composeProjectName(name),
Status: "stopped",
}
st, err = m.store.CreateStack(st)
if err != nil {
return store.Stack{}, store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: st.ID,
YAML: yamlText,
Author: author,
})
if err != nil {
// Best-effort cleanup of the stack row.
_ = m.store.DeleteStack(st.ID)
return store.Stack{}, store.StackRevision{}, err
}
return st, rev, nil
}
// Deploy brings up the stack for the given revision. Updates stack + revision
// status transitions: deploying → running | failed. Blocking.
func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
rev, err := m.store.GetStackRevisionByID(revisionID)
if err != nil {
return err
}
if rev.StackID != stackID {
return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID)
}
deploy, err := m.store.CreateStackDeploy(store.StackDeploy{
StackID: stackID,
RevisionID: revisionID,
Status: "deploying",
})
if err != nil {
return err
}
_ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID)
m.setStatus(st, "deploying", "")
yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML)
if err != nil {
m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err))
return err
}
out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath)
if upErr != nil {
m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out))
return upErr
}
// Success.
deploy.Status = "success"
deploy.Log = out
deploy.FinishedAt = store.Now()
_ = m.store.UpdateStackDeploy(deploy)
_ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID)
_ = m.store.SetStackCurrentRevision(st.ID, rev.ID)
m.setStatus(st, "running", "")
m.syncContainerRows(ctx, st, yamlPath)
return nil
}
// NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it.
func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
spec, err := Parse(yamlText)
if err != nil {
return store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: stackID,
YAML: yamlText,
Author: author,
})
if err != nil {
return store.StackRevision{}, err
}
if err := m.Deploy(ctx, stackID, rev.ID); err != nil {
return rev, err
}
return rev, nil
}
// NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine.
// Returns the created revision immediately.
func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
spec, err := Parse(yamlText)
if err != nil {
return store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: stackID,
YAML: yamlText,
Author: author,
})
if err != nil {
return store.StackRevision{}, err
}
go func(stackID, revID string) {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := m.Deploy(bgCtx, stackID, revID); err != nil {
slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err)
}
}(stackID, rev.ID)
return rev, nil
}
// RollbackAsync creates a copy-revision from a target and deploys asynchronously.
func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
target, err := m.store.GetStackRevisionByID(targetRevisionID)
if err != nil {
return store.StackRevision{}, err
}
if target.StackID != stackID {
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
}
return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
}
// Rollback creates a new revision whose YAML is copied from the given prior
// revision, then deploys it. Keeps history append-only.
func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
target, err := m.store.GetStackRevisionByID(targetRevisionID)
if err != nil {
return store.StackRevision{}, err
}
if target.StackID != stackID {
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
}
return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
}
// Stop runs `docker compose stop` without removing containers.
func (m *Manager) Stop(ctx context.Context, stackID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil {
return err
}
m.setStatus(st, "stopped", "")
m.markStackContainersState(stackID, "stopped")
return nil
}
// Start runs `docker compose start` on existing containers.
func (m *Manager) Start(ctx context.Context, stackID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil {
return err
}
m.setStatus(st, "running", "")
m.markStackContainersState(stackID, "running")
return nil
}
// Delete tears down the stack and removes the DB row. If removeVolumes is
// true, named volumes are also deleted (`compose down -v`). Destructive.
func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil {
// Log but continue — DB row must not be orphaned.
slog.Warn("stack: compose down failed", "stack", st.Name, "error", err)
}
// Best-effort YAML cleanup.
_ = os.RemoveAll(filepath.Join(m.workDir, st.ID))
return m.store.DeleteStack(stackID)
}
// Services returns current service state for a stack.
func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return nil, err
}
yamlPath := ""
if st.CurrentRevisionID != "" {
if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil {
yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML)
}
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath)
}
// Logs returns the last `tail` log lines for a service (or all services if empty).
func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return "", err
}
if tail <= 0 {
tail = 200
}
return m.compose.Logs(ctx, st.ComposeProjectName, service, tail)
}
// --- internals ---
// syncContainerRows upserts one Container row per compose service for this
// stack so the global container index stays in sync after every deploy. The
// Docker container ID is left empty here — the reconciler resolves it from
// `docker ps` via the `com.docker.compose.project` label. Best-effort: a
// failure here is logged but does not affect deploy outcome.
func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) {
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID)
if err != nil {
slog.Warn("stack: resolve workload", "stack", st.ID, "error", err)
return
}
psCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath)
if err != nil {
slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err)
return
}
for _, svc := range services {
state := svc.State
if state == "" {
state = svc.Status
}
m.upsertStackContainer(w.ID, svc, state)
}
}
// upsertStackContainer writes a Container row for one compose service. The
// row ID is deterministic — `<workloadID>:<service>` — so re-deploys update
// the same row instead of accumulating rows.
func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) {
role := svc.Service
if role == "" {
role = svc.Name
}
if err := m.store.UpsertContainer(store.Container{
ID: workloadID + ":" + role,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindStack),
Role: role,
ContainerID: "", // reconciler fills in from docker ps
Host: "local",
State: state,
LastSeenAt: store.Now(),
}); err != nil {
slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err)
}
}
// markStackContainersState bulk-updates the state of every container row for
// this stack (used by Stop/Start which don't go through compose ps).
func (m *Manager) markStackContainersState(stackID, state string) {
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID)
if err != nil {
return
}
rows, err := m.store.ListContainersByWorkload(w.ID)
if err != nil {
slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err)
return
}
for _, r := range rows {
if err := m.store.UpdateContainerState(r.ID, state); err != nil {
slog.Warn("stack: update container state", "container_row", r.ID, "error", err)
}
}
}
func (m *Manager) setStatus(st store.Stack, status, errMsg string) {
_ = m.store.UpdateStackStatus(st.ID, status, errMsg)
if m.eventBus != nil {
m.eventBus.Publish(events.Event{
Type: events.EventStackStatus,
Payload: events.StackStatusPayload{
StackID: st.ID,
Name: st.Name,
Status: status,
Error: errMsg,
},
})
}
}
func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) {
d.Status = "failed"
d.Error = errMsg
d.FinishedAt = store.Now()
_ = m.store.UpdateStackDeploy(d)
_ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID)
m.setStatus(st, "failed", errMsg)
}
// writeYAML writes yaml to <workDir>/<stackID>/rev-<n>.yml and returns the path.
func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) {
dir := filepath.Join(m.workDir, stackID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision))
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
return "", err
}
return path, nil
}
// composeProjectName sanitises a user-provided stack name into something
// `docker compose -p` will accept: lowercase, digits, dashes only.
func composeProjectName(name string) string {
name = strings.ToLower(name)
name = nonProjectChars.ReplaceAllString(name, "-")
name = strings.Trim(name, "-")
if name == "" {
name = "stack"
}
return "tinyforge-" + name
}
var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`)
func itoa(n int) string { return fmt.Sprintf("%d", n) }
-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)
}
})
}
}
+16 -47
View File
@@ -187,23 +187,22 @@ func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) {
return c, nil
}
// ListProxyRoutes returns proxy-enabled project containers joined with
// project + stage names. Reads from the normalized containers index and
// joins through stage_id so a stage rename does not orphan the row's view.
//
// Source is reported as "instance" for back-compat with the Proxies page
// filter (the frontend keys off the literal string).
// ListProxyRoutes returns proxy-enabled containers joined with their
// owning workload's name. The legacy stages join is gone — Role is used
// as the StageName fallback so the Proxies page still reads naturally
// for project-style workloads. Source is reported as "instance" for
// back-compat with the Proxies page filter (the frontend keys off the
// literal string).
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(`
SELECT c.id, p.id, p.name, s.id, s.name,
SELECT c.id, w.id, w.name,
c.image_tag, c.subdomain, c.container_id, c.port,
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at,
c.role, c.stage_id
FROM containers c
JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project'
JOIN projects p ON p.id = w.ref_id
JOIN stages s ON s.id = c.stage_id OR (c.stage_id = '' AND s.project_id = p.id AND s.name = c.role)
JOIN workloads w ON w.id = c.workload_id
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
ORDER BY p.name, s.name, c.created_at DESC`,
ORDER BY w.name, c.role, c.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query proxy routes: %w", err)
@@ -213,14 +212,18 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var role, stageID string
if err := rows.Scan(
&r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName,
&r.InstanceID, &r.ProjectID, &r.ProjectName,
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
&role, &stageID,
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
r.StageID = stageID
r.StageName = role
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
@@ -229,40 +232,6 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
return routes, rows.Err()
}
// ListContainersByStageID returns project containers for the given stage,
// newest first. Resolves via stage_id with a fallback to the legacy
// (stage.name = container.role) join for rows written before the stage_id
// column was populated. Replaces GetInstancesByStageID.
func (s *Store) ListContainersByStageID(stageID string) ([]Container, error) {
rows, err := s.db.Query(`
SELECT `+prefixCols(containerColumns, "c.")+`
FROM containers c
LEFT JOIN stages s ON s.id = ?
WHERE c.stage_id = ?
OR (c.stage_id = '' AND s.id IS NOT NULL
AND c.role = s.name
AND EXISTS (
SELECT 1 FROM workloads w
WHERE w.id = c.workload_id
AND w.kind = 'project'
AND w.ref_id = s.project_id))
ORDER BY c.created_at DESC`, stageID, stageID)
if err != nil {
return nil, fmt.Errorf("query containers by stage: %w", err)
}
defer rows.Close()
out := []Container{}
for rows.Next() {
c, err := scanContainer(rows)
if err != nil {
return nil, fmt.Errorf("scan container: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// ListContainersByWorkload returns all containers for a given workload, newest first.
func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) {
rows, err := s.db.Query(
-212
View File
@@ -1,212 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// CreateDeploy inserts a new deploy record.
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
d.ID = uuid.New().String()
d.StartedAt = Now()
if d.Status == "" {
d.Status = "pending"
}
_, err := s.db.Exec(
`INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error,
)
if err != nil {
return Deploy{}, fmt.Errorf("insert deploy: %w", err)
}
return d, nil
}
// GetDeployByID returns a single deploy by its ID.
func (s *Store) GetDeployByID(id string) (Deploy, error) {
var d Deploy
err := s.db.QueryRow(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE id = ?`, id,
).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error)
if errors.Is(err, sql.ErrNoRows) {
return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
if err != nil {
return Deploy{}, fmt.Errorf("query deploy: %w", err)
}
return d, nil
}
// GetDeploysByProjectID returns all deploys for a project, newest first.
func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// GetRecentDeploys returns the most recent deploys across all projects.
func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys ORDER BY started_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, fmt.Errorf("query recent deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
ts := Now()
var finishedAt string
if IsTerminalDeployStatus(status) {
finishedAt = ts
}
result, err := s.db.Exec(
`UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`,
status, deployErr, finishedAt, id,
)
if err != nil {
return fmt.Errorf("update deploy status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
return nil
}
// SetDeployInstanceID links a deploy to the instance it created.
func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error {
result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID)
if err != nil {
return fmt.Errorf("set deploy instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound)
}
return nil
}
// AppendDeployLog adds a log entry for a deploy.
func (s *Store) AppendDeployLog(deployID string, message string, level string) error {
if level == "" {
level = "info"
}
_, err := s.db.Exec(
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
deployID, message, level, Now(),
)
if err != nil {
return fmt.Errorf("append deploy log: %w", err)
}
return nil
}
// GetDeployLogs returns all log entries for a deploy, ordered chronologically.
func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
rows, err := s.db.Query(
`SELECT id, deploy_id, message, level, created_at
FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID,
)
if err != nil {
return nil, fmt.Errorf("query deploy logs: %w", err)
}
defer rows.Close()
logs := []DeployLog{}
for rows.Next() {
var l DeployLog
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
return nil, fmt.Errorf("scan deploy log: %w", err)
}
logs = append(logs, l)
}
return logs, rows.Err()
}
// scanDeploys is a helper that scans deploy rows from a cursor.
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
deploys := []Deploy{}
for rows.Next() {
var d Deploy
if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil {
return nil, fmt.Errorf("scan deploy: %w", err)
}
deploys = append(deploys, d)
}
return deploys, rows.Err()
}
// IsTerminalDeployStatus returns true if the status indicates the deploy is finished.
func IsTerminalDeployStatus(status string) bool {
switch status {
case "success", "failed", "rolled_back":
return true
default:
return false
}
}
// GetDeploys returns deploys with optional filtering by project and stage, with pagination.
func (s *Store) GetDeploys(projectID, stageID string, limit, offset int) ([]Deploy, error) {
query := `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error FROM deploys`
var args []any
var conditions []string
if projectID != "" {
conditions = append(conditions, "project_id = ?")
args = append(args, projectID)
}
if stageID != "" {
conditions = append(conditions, "stage_id = ?")
args = append(args, stageID)
}
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// CleanupOldDeploys removes deploy records and their logs older than the given
// number of days. Returns the number of deploys removed.
func (s *Store) CleanupOldDeploys(retentionDays int) (int64, error) {
cutoff := fmt.Sprintf("-%d days", retentionDays)
result, err := s.db.Exec(
`DELETE FROM deploys WHERE started_at < datetime('now', ?)`, cutoff,
)
if err != nil {
return 0, fmt.Errorf("cleanup old deploys: %w", err)
}
n, _ := result.RowsAffected()
return n, nil
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"crypto/rand"
"encoding/hex"
"github.com/google/uuid"
)
// rowScanner is the subset of *sql.Row / *sql.Rows used by row scanners
// across this package. Kept package-private — callers should not need to
// implement it themselves.
type rowScanner interface {
Scan(dest ...any) error
}
// BoolToInt converts a Go bool to the 0/1 INTEGER convention SQLite uses
// for boolean columns across this schema.
func BoolToInt(b bool) int {
if b {
return 1
}
return 0
}
// GenerateWebhookSecret returns a 256-bit hex-encoded random token.
// Exported so the api layer can share one implementation — keeping
// two copies invited drift (one panicked, one fell back to UUID).
// crypto/rand directly rather than uuid.New() so the intent ("secret
// token, not identifier") is explicit and the entropy is unambiguous.
func GenerateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand is documented to never fail on supported platforms;
// fall back to a UUID rather than panicking.
return uuid.New().String()
}
return hex.EncodeToString(b)
}
// generateWebhookSecret is the in-package alias kept for the existing
// CRUD call sites that don't reach across packages. New callers in
// other packages should use GenerateWebhookSecret directly.
func generateWebhookSecret() string { return GenerateWebhookSecret() }
+13 -174
View File
@@ -1,45 +1,5 @@
package store
// Project represents a deployable application.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
WebhookSecret string `json:"-"` // per-project webhook secret (URL identifier); never serialized
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stage represents a deployment stage within a project (e.g. dev, rel, prod).
type Stage struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy bool `json:"auto_deploy"`
MaxInstances int `json:"max_instances"`
Confirm bool `json:"confirm"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Registry represents a container image registry.
type Registry struct {
ID string `json:"id"`
@@ -142,10 +102,15 @@ type DNSRecord struct {
UpdatedAt string `json:"updated_at"`
}
// ProxyRoute is a proxy-enabled container row joined with its project + stage
// names, shaped for the Proxies page. Source is "instance" for project
// containers and "static_site" for site rows — the names are historical
// (the table itself was renamed to containers in the workload refactor).
// ProxyRoute shapes one proxy-enabled container row for the Proxies
// page. The legacy field names (ProjectID, ProjectName, StageID,
// StageName, InstanceID) are retained verbatim for the existing
// frontend contract — after the workload-first cutover they map to:
// ProjectID/Name → workload id / workload name
// StageID/Name → containers.stage_id / containers.role
// InstanceID → container row id
// Source → "instance" for image/compose, "static_site" for static
// Renaming would require a coordinated frontend change; deferred.
type ProxyRoute struct {
Source string `json:"source"`
InstanceID string `json:"instance_id"`
@@ -164,39 +129,6 @@ type ProxyRoute struct {
CreatedAt string `json:"created_at"`
}
// Deploy represents a deployment attempt.
type Deploy struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
StageID string `json:"stage_id"`
InstanceID string `json:"instance_id"`
ImageTag string `json:"image_tag"`
Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Error string `json:"error"`
}
// DeployLog is a single log entry for a deploy.
type DeployLog struct {
ID int64 `json:"id"`
DeployID string `json:"deploy_id"`
Message string `json:"message"`
Level string `json:"level"` // info, warn, error
CreatedAt string `json:"created_at"`
}
// StageEnv represents a per-stage environment variable override.
type StageEnv struct {
ID string `json:"id"`
StageID string `json:"stage_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadVolume is the plugin-shape equivalent of legacy Volume: a
// per-workload mount declaration. The Scope enum matches the existing
// VolumeScope contract so the legacy resolver can be reused once its
@@ -256,101 +188,6 @@ func IsValidVolumeScope(s string) bool {
return false
}
// Volume represents a volume mount configuration for a project.
type Volume struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat
Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral
Name string `json:"name"` // required for project_named and named scopes
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StaticSite represents a static site deployed from a Git repository folder.
type StaticSite struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect
GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages"
AccessToken string `json:"access_token"` // encrypted; optional for public repos
Domain string `json:"domain"` // full domain for proxy
Mode string `json:"mode"` // "static" or "deno"
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual"
TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync
ContainerID string `json:"container_id"`
ProxyRouteID string `json:"proxy_route_id"`
Status string `json:"status"` // idle, syncing, deployed, failed
LastSyncAt string `json:"last_sync_at"`
LastCommitSHA string `json:"last_commit_sha"`
Error string `json:"error"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
WebhookSecret string `json:"-"` // per-site webhook secret (URL identifier); never serialized
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend.
type StaticSiteSecret struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stack represents a docker-compose stack managed as a single deployable unit.
type Stack struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ComposeProjectName string `json:"compose_project_name"` // `-p` arg for docker compose
Status string `json:"status"` // stopped, deploying, running, failed
Error string `json:"error"`
CurrentRevisionID string `json:"current_revision_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StackRevision is an append-only record of a YAML version for a stack.
// Rollback = insert a new revision whose YAML is copied from an older one.
type StackRevision struct {
ID string `json:"id"`
StackID string `json:"stack_id"`
Revision int `json:"revision"` // monotonic per stack
YAML string `json:"yaml"`
Author string `json:"author"`
DeployID string `json:"deploy_id"`
Status string `json:"status"` // pending, success, failed
CreatedAt string `json:"created_at"`
}
// StackDeploy records a deployment attempt of a specific revision.
type StackDeploy struct {
ID string `json:"id"`
StackID string `json:"stack_id"`
RevisionID string `json:"revision_id"`
Status string `json:"status"` // pending, deploying, success, failed, rolled_back
Log string `json:"log"`
Error string `json:"error"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
}
// EventLog represents a persistent event log entry.
type EventLog struct {
ID int64 `json:"id"`
@@ -437,8 +274,10 @@ const (
LogScanSeverityError = "error"
)
// WorkloadKind enumerates the kinds of things that own containers.
// Each kind has a corresponding row in projects/stacks/static_sites referenced via Workload.RefID.
// WorkloadKind enumerates the legacy discriminator values written into
// containers.workload_kind and workloads.kind. After the hard cutover the
// backing project / stack / static_site tables are gone — these constants
// are just strings used to filter the unified containers index in the UI.
type WorkloadKind string
const (
-75
View File
@@ -1,75 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// PollState tracks the last polled tag for a stage, enabling the poller to
// detect new tags since the previous poll cycle.
type PollState struct {
StageID string `json:"stage_id"`
LastTag string `json:"last_tag"`
LastPolled string `json:"last_polled"`
}
// GetPollState returns the poll state for a given stage.
func (s *Store) GetPollState(stageID string) (PollState, error) {
var ps PollState
err := s.db.QueryRow(
`SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`,
stageID,
).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled)
if errors.Is(err, sql.ErrNoRows) {
return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound)
}
if err != nil {
return PollState{}, fmt.Errorf("query poll state: %w", err)
}
return ps, nil
}
// UpsertPollState inserts or updates the poll state for a stage.
func (s *Store) UpsertPollState(ps PollState) error {
_, err := s.db.Exec(
`INSERT INTO poll_states (stage_id, last_tag, last_polled)
VALUES (?, ?, ?)
ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`,
ps.StageID, ps.LastTag, ps.LastPolled,
)
if err != nil {
return fmt.Errorf("upsert poll state: %w", err)
}
return nil
}
// DeletePollState removes the poll state for a stage.
func (s *Store) DeletePollState(stageID string) error {
_, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID)
if err != nil {
return fmt.Errorf("delete poll state: %w", err)
}
return nil
}
// GetAllPollStates returns all poll states, ordered by last_polled descending.
func (s *Store) GetAllPollStates() ([]PollState, error) {
rows, err := s.db.Query(
`SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`,
)
if err != nil {
return nil, fmt.Errorf("query poll states: %w", err)
}
defer rows.Close()
states := []PollState{}
for rows.Next() {
var ps PollState
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
return nil, fmt.Errorf("scan poll state: %w", err)
}
states = append(states, ps)
}
return states, rows.Err()
}
-342
View File
@@ -1,342 +0,0 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"github.com/google/uuid"
)
// minWebhookSecretLength is the smallest user-supplied webhook secret accepted
// at insert time. Auto-generated secrets are 64 hex chars (256 bits); a
// 32-char floor still leaves > 128 bits of brute-force resistance for hex
// alphabets and rejects obvious typos / placeholder strings.
const minWebhookSecretLength = 32
// generateWebhookSecret returns a 256-bit hex-encoded random token. We use
// crypto/rand directly rather than uuid.New() so the intent ("secret token,
// not identifier") is explicit and the entropy is unambiguous.
func generateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand is documented to never fail on supported platforms;
// fall back to a UUID rather than panicking.
return uuid.New().String()
}
return hex.EncodeToString(b)
}
// projectCols is the canonical column list for projects queries.
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
npm_access_list_id, webhook_secret, webhook_signing_secret, webhook_require_signature,
notification_url, notification_secret, created_at, updated_at`
// rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject.
type rowScanner interface {
Scan(dest ...any) error
}
// scanProject reads one row in projectCols order. webhook_require_signature
// is stored as INTEGER and converted to bool here.
func scanProject(r rowScanner) (Project, error) {
var p Project
var requireSig int
if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig,
&p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return Project{}, err
}
p.WebhookRequireSignature = requireSig != 0
return p, nil
}
// CreateProject inserts a new project and returns it. A webhook secret is
// generated automatically if one is not already set on the input. Project
// row + matching workload row are written in a single transaction.
func (s *Store) CreateProject(p Project) (Project, error) {
p.ID = uuid.New().String()
p.CreatedAt = Now()
p.UpdatedAt = p.CreatedAt
if p.WebhookSecret == "" {
p.WebhookSecret = generateWebhookSecret()
} else if len(p.WebhookSecret) < minWebhookSecretLength {
return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
requireSig := 0
if p.WebhookRequireSignature {
requireSig = 1
}
tx, err := s.db.Begin()
if err != nil {
return Project{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO projects (`+projectCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.WebhookSecret, p.WebhookSigningSecret, requireSig,
p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
); err != nil {
return Project{}, fmt.Errorf("insert project: %w", err)
}
if err := SyncProjectWorkloadTx(tx, p); err != nil {
return Project{}, err
}
if err := tx.Commit(); err != nil {
return Project{}, fmt.Errorf("commit: %w", err)
}
return p, nil
}
// GetProjectByID returns a single project by its ID.
func (s *Store) GetProjectByID(id string) (Project, error) {
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if err != nil {
return Project{}, fmt.Errorf("query project: %w", err)
}
return p, nil
}
// GetProjectByWebhookSecret looks up a project by its webhook secret.
// Returns ErrNotFound if no project has this secret (including empty).
func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
if secret == "" {
return Project{}, ErrNotFound
}
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret)
p, err := scanProject(row)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("query project by webhook secret: %w", err)
}
return p, nil
}
// GetAllProjects returns every project ordered by name.
func (s *Store) GetAllProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT ` + projectCols + ` FROM projects ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectsByImage returns all projects using the given image, newest first.
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
rows, err := s.db.Query(
`SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
)
if err != nil {
return nil, fmt.Errorf("query projects by image: %w", err)
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// updateProjectAndSyncWorkloadTx performs the parent UPDATE + workload sync in
// a single transaction. Used by every Set*Secret / UpdateProject path so the
// project row and the workload row never desync after a partial failure.
// updateSQL must be a parameterized UPDATE on `projects` ending with `WHERE id=?`;
// args are the parameter values in order, with the project ID last.
func (s *Store) updateProjectAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("update project: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
// Re-read the row inside the transaction so the workload sync sees the
// canonical values (the caller may have only updated one column).
row := tx.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row)
if err != nil {
return fmt.Errorf("reread project for workload sync: %w", err)
}
if err := SyncProjectWorkloadTx(tx, p); err != nil {
return err
}
return tx.Commit()
}
// UpdateProject updates an existing project's mutable fields. Webhook secret
// and notification_secret are intentionally not updated here — use the
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = Now()
return s.updateProjectAndSyncWorkloadTx(p.ID,
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?,
npm_access_list_id=?, notification_url=?, updated_at=?
WHERE id=?`,
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
)
}
// SetProjectWebhookSecret assigns a webhook secret to a project.
// Pass an empty string to disable webhook access for the project.
func (s *Store) SetProjectWebhookSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetProjectWebhookSigningSecret assigns the HMAC signing secret used to
// verify inbound webhook payloads. Pass an empty string to clear it (which
// also implicitly disables signature enforcement on the next request).
func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetProjectWebhookRequireSignature toggles whether unsigned (or
// invalidly-signed) webhook requests are rejected with 401.
func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error {
v := 0
if require {
v = 1
}
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
}
// EnsureProjectWebhookSecret returns the current webhook secret for a project,
// generating one on the fly if the stored value is empty (lazy backfill for
// projects created before the per-project webhook migration).
func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
project, err := s.GetProjectByID(id)
if err != nil {
return "", err
}
if project.WebhookSecret != "" {
return project.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetProjectWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this project (notifications
// still send unsigned, falling through to the parent tier's secret if any).
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
// secret, generating one lazily if missing. Used when an operator first opens
// the outgoing-webhook panel for a project that predates this feature.
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
project, err := s.GetProjectByID(id)
if err != nil {
return "", err
}
if project.NotificationSecret != "" {
return project.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetProjectNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
// Workload row + container index entries are removed too so the global views
// don't show ghost rows after a project is gone. Atomic: the project, its
// container index entries, and its workload row all live or die together.
func (s *Store) DeleteProject(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
// Resolve the workload before deleting the project so we have the
// workload ID for the cascade.
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindProject), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup project workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete project containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete project workload: %w", err)
}
}
return tx.Commit()
}
-173
View File
@@ -1,173 +0,0 @@
package store
import "testing"
// TestListProxyRoutesJoinShape verifies the new containers-based join produces
// the same ProxyRoute shape the /api/proxies frontend has consumed since this
// query was instances-based. Without this test, a missing column or a wrong
// join condition would silently break the Proxies page.
func TestListProxyRoutesJoinShape(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
stage, err := s.CreateStage(Stage{
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
})
if err != nil {
t.Fatalf("CreateStage: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err != nil {
t.Fatalf("workload: %v", err)
}
// Container with both subdomain and proxy_route_id populated — the rule
// the WHERE clause filters on.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-abc",
ImageTag: "v1",
State: "running",
Port: 8080,
Subdomain: "wf-prod",
ProxyRouteID: "route-1",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
// Container without subdomain — must be filtered OUT.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-def",
ImageTag: "v2",
State: "running",
}); err != nil {
t.Fatalf("CreateContainer 2: %v", err)
}
routes, err := s.ListProxyRoutes("example.test")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d (filter wrong?)", len(routes))
}
r := routes[0]
if r.Source != "instance" {
t.Errorf("Source: got %q, want 'instance' (back-compat)", r.Source)
}
if r.ProjectID != p.ID {
t.Errorf("ProjectID: got %q, want %q", r.ProjectID, p.ID)
}
if r.ProjectName != "wf" {
t.Errorf("ProjectName: got %q, want 'wf'", r.ProjectName)
}
if r.StageID != stage.ID {
t.Errorf("StageID: got %q, want %q", r.StageID, stage.ID)
}
if r.StageName != "prod" {
t.Errorf("StageName: got %q, want 'prod'", r.StageName)
}
if r.ImageTag != "v1" {
t.Errorf("ImageTag: got %q, want 'v1'", r.ImageTag)
}
if r.Subdomain != "wf-prod" {
t.Errorf("Subdomain: got %q, want 'wf-prod'", r.Subdomain)
}
if r.Domain != "wf-prod.example.test" {
t.Errorf("Domain: got %q, want 'wf-prod.example.test'", r.Domain)
}
if r.ContainerID != "docker-abc" {
t.Errorf("ContainerID: got %q, want 'docker-abc'", r.ContainerID)
}
if r.Port != 8080 {
t.Errorf("Port: got %d, want 8080", r.Port)
}
if r.ProxyRouteID != "route-1" {
t.Errorf("ProxyRouteID: got %q, want 'route-1'", r.ProxyRouteID)
}
if r.Status != "running" {
t.Errorf("Status (state): got %q, want 'running'", r.Status)
}
}
func TestListProxyRoutesNpmOnly(t *testing.T) {
// NPM-only routes (npm_proxy_id > 0, proxy_route_id == "") must still be
// returned — that's the original WHERE-clause OR branch.
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "npm-only", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
stage, _ := s.CreateStage(Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-1",
Subdomain: "npm-only-dev",
NpmProxyID: 42,
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
routes, err := s.ListProxyRoutes("")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected 1 npm route, got %d", len(routes))
}
if routes[0].NpmProxyID != 42 {
t.Errorf("NpmProxyID: got %d, want 42", routes[0].NpmProxyID)
}
}
func TestListProxyRoutesIgnoresWrongRole(t *testing.T) {
// Belt-and-suspenders: a container whose role doesn't match a stage name
// would orphan the JOIN. Verify the row falls out cleanly (LEFT JOIN
// would expose a real bug here).
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
_, _ = s.CreateStage(Stage{
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1,
})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: "ghost-stage", // intentionally not a real stage name
ContainerID: "docker-x",
Subdomain: "wf-ghost",
ProxyRouteID: "route-x",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
routes, err := s.ListProxyRoutes("")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 0 {
t.Fatalf("orphan-role row leaked into result: got %d", len(routes))
}
}
+21
View File
@@ -101,6 +101,27 @@ func (s *Store) UpdateSettings(st Settings) error {
return nil
}
// EnsureSettingsNotificationSecret returns the current global notification
// secret, lazily generating + persisting one if none is set. Lets the
// settings UI render a stable secret on first load for any install that
// predates the signing feature.
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
var secret string
if err := s.db.QueryRow(
`SELECT notification_secret FROM settings WHERE id = 1`,
).Scan(&secret); err != nil {
return "", fmt.Errorf("get settings notification secret: %w", err)
}
if secret != "" {
return secret, nil
}
secret = generateWebhookSecret()
if err := s.SetSettingsNotificationSecret(secret); err != nil {
return "", err
}
return secret, nil
}
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
// signing secret on the singleton settings row. Pass an empty string to
// disable signing globally (notifications still send, just without HMAC).
-398
View File
@@ -1,398 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const stackCols = `id, name, description, compose_project_name, status, error,
current_revision_id, created_at, updated_at`
// CreateStack inserts a new stack and returns it. Stack row + matching
// workload row are written in a single transaction so a partial failure
// leaves no orphan.
func (s *Store) CreateStack(st Stack) (Stack, error) {
st.ID = uuid.New().String()
st.CreatedAt = Now()
st.UpdatedAt = st.CreatedAt
if st.Status == "" {
st.Status = "stopped"
}
tx, err := s.db.Begin()
if err != nil {
return Stack{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO stacks (`+stackCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
); err != nil {
return Stack{}, fmt.Errorf("insert stack: %w", err)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return Stack{}, err
}
if err := tx.Commit(); err != nil {
return Stack{}, fmt.Errorf("commit: %w", err)
}
return st, nil
}
// GetStackByID returns a single stack by its ID.
func (s *Store) GetStackByID(id string) (Stack, error) {
st, err := scanStackRow(s.db.QueryRow(
`SELECT `+stackCols+` FROM stacks WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
if err != nil {
return Stack{}, fmt.Errorf("query stack: %w", err)
}
return st, nil
}
// GetStackByComposeProjectName looks up a stack by its compose project name.
// Compose project names are unique per the stacks table schema, so this is an
// O(1) index lookup. Used by the reconciler to resolve compose-managed
// containers without scanning every stack.
func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) {
if name == "" {
return Stack{}, ErrNotFound
}
st, err := scanStackRow(s.db.QueryRow(
`SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name,
))
if errors.Is(err, sql.ErrNoRows) {
return Stack{}, ErrNotFound
}
if err != nil {
return Stack{}, fmt.Errorf("query stack by compose project: %w", err)
}
return st, nil
}
// GetAllStacks returns every stack ordered by name.
func (s *Store) GetAllStacks() ([]Stack, error) {
rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("query stacks: %w", err)
}
defer rows.Close()
out := []Stack{}
for rows.Next() {
st, err := scanStackRows(rows)
if err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// UpdateStack updates the mutable metadata fields (name, description).
// Atomic: stack row UPDATE and workload row sync share a transaction so the
// workload row's name never lags after a rename.
func (s *Store) UpdateStack(st Stack) error {
st.UpdatedAt = Now()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(
`UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`,
st.Name, st.Description, st.UpdatedAt, st.ID,
)
if err != nil {
return fmt.Errorf("update stack: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return err
}
return tx.Commit()
}
// UpdateStackStatus updates the deployment status + error fields.
func (s *Store) UpdateStackStatus(id, status, errMsg string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`,
status, errMsg, now, id,
)
if err != nil {
return fmt.Errorf("update stack status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
return nil
}
// SetStackCurrentRevision updates the current_revision_id pointer.
func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`,
revisionID, now, id,
)
if err != nil {
return fmt.Errorf("update stack revision pointer: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
// Stack + workload + container index rows are dropped atomically.
func (s *Store) DeleteStack(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindStack), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup stack workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stack: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack workload: %w", err)
}
}
return tx.Commit()
}
func scanStackRow(row *sql.Row) (Stack, error) {
var st Stack
err := row.Scan(
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
)
return st, err
}
func scanStackRows(rows *sql.Rows) (Stack, error) {
var st Stack
err := rows.Scan(
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
)
if err != nil {
return Stack{}, fmt.Errorf("scan stack: %w", err)
}
return st, nil
}
// --- Stack revisions ---
const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at`
// CreateStackRevision inserts a new revision with the next monotonic revision number.
func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) {
r.ID = uuid.New().String()
r.CreatedAt = Now()
if r.Status == "" {
r.Status = "pending"
}
tx, err := s.db.Begin()
if err != nil {
return StackRevision{}, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var next int
if err := tx.QueryRow(
`SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`,
r.StackID,
).Scan(&next); err != nil {
return StackRevision{}, fmt.Errorf("next revision: %w", err)
}
r.Revision = next
if _, err := tx.Exec(
`INSERT INTO stack_revisions (`+stackRevisionCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt,
); err != nil {
return StackRevision{}, fmt.Errorf("insert revision: %w", err)
}
if err := tx.Commit(); err != nil {
return StackRevision{}, fmt.Errorf("commit revision: %w", err)
}
return r, nil
}
// GetStackRevisionByID returns a single revision by ID.
func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) {
r, err := scanStackRevisionRow(s.db.QueryRow(
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound)
}
if err != nil {
return StackRevision{}, fmt.Errorf("query revision: %w", err)
}
return r, nil
}
// GetStackRevisionsByStackID returns revisions newest-first.
func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) {
rows, err := s.db.Query(
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ?
ORDER BY revision DESC`,
stackID,
)
if err != nil {
return nil, fmt.Errorf("query revisions: %w", err)
}
defer rows.Close()
out := []StackRevision{}
for rows.Next() {
r, err := scanStackRevisionRows(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// UpdateStackRevisionStatus updates status + deploy_id linkage.
func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error {
result, err := s.db.Exec(
`UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`,
status, deployID, id,
)
if err != nil {
return fmt.Errorf("update revision status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("revision %s: %w", id, ErrNotFound)
}
return nil
}
func scanStackRevisionRow(row *sql.Row) (StackRevision, error) {
var r StackRevision
err := row.Scan(
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
)
return r, err
}
func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) {
var r StackRevision
err := rows.Scan(
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
)
if err != nil {
return StackRevision{}, fmt.Errorf("scan revision: %w", err)
}
return r, nil
}
// --- Stack deploys ---
const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at`
// CreateStackDeploy inserts a new deploy record.
func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) {
d.ID = uuid.New().String()
d.StartedAt = Now()
if d.Status == "" {
d.Status = "pending"
}
_, err := s.db.Exec(
`INSERT INTO stack_deploys (`+stackDeployCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt,
)
if err != nil {
return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err)
}
return d, nil
}
// GetStackDeployByID returns a single deploy by ID.
func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) {
d, err := scanStackDeployRow(s.db.QueryRow(
`SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound)
}
if err != nil {
return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err)
}
return d, nil
}
// UpdateStackDeploy updates status, log, error, finished_at.
func (s *Store) UpdateStackDeploy(d StackDeploy) error {
result, err := s.db.Exec(
`UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`,
d.Status, d.Log, d.Error, d.FinishedAt, d.ID,
)
if err != nil {
return fmt.Errorf("update stack deploy: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound)
}
return nil
}
func scanStackDeployRow(row *sql.Row) (StackDeploy, error) {
var d StackDeploy
err := row.Scan(
&d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt,
)
return d, err
}
-112
View File
@@ -1,112 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStageEnv inserts a new stage environment variable override.
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
env.ID = uuid.New().String()
env.CreatedAt = Now()
env.UpdatedAt = env.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
env.ID, env.StageID, env.Key, env.Value, BoolToInt(env.Encrypted),
env.CreatedAt, env.UpdatedAt,
)
if err != nil {
return StageEnv{}, fmt.Errorf("insert stage env: %w", err)
}
return env, nil
}
// GetStageEnvByStageID returns all environment variable overrides for a stage.
func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) {
rows, err := s.db.Query(
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID,
)
if err != nil {
return nil, fmt.Errorf("query stage env: %w", err)
}
defer rows.Close()
envs := []StageEnv{}
for rows.Next() {
env, err := scanStageEnv(rows)
if err != nil {
return nil, err
}
envs = append(envs, env)
}
return envs, rows.Err()
}
// GetStageEnvByID returns a single stage env override by ID.
func (s *Store) GetStageEnvByID(id string) (StageEnv, error) {
var env StageEnv
var encrypted int
err := s.db.QueryRow(
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
FROM stage_env WHERE id = ?`, id,
).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
&env.CreatedAt, &env.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound)
}
if err != nil {
return StageEnv{}, fmt.Errorf("query stage env: %w", err)
}
env.Encrypted = encrypted != 0
return env, nil
}
// UpdateStageEnv updates an existing stage environment variable override.
func (s *Store) UpdateStageEnv(env StageEnv) error {
env.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
env.Key, env.Value, BoolToInt(env.Encrypted), env.UpdatedAt, env.ID,
)
if err != nil {
return fmt.Errorf("update stage env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound)
}
return nil
}
// DeleteStageEnv removes a stage env override by ID.
func (s *Store) DeleteStageEnv(id string) error {
result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stage env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage env %s: %w", id, ErrNotFound)
}
return nil
}
// scanStageEnv scans a stage env row from a *sql.Rows cursor.
func scanStageEnv(rows *sql.Rows) (StageEnv, error) {
var env StageEnv
var encrypted int
err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
&env.CreatedAt, &env.UpdatedAt)
if err != nil {
return StageEnv{}, fmt.Errorf("scan stage env: %w", err)
}
env.Encrypted = encrypted != 0
return env, nil
}
-168
View File
@@ -1,168 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String()
st.CreatedAt = Now()
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.NotificationSecret,
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err)
}
return st, nil
}
// GetStagesByProjectID returns all stages for a given project.
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
rows, err := s.db.Query(
`SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query stages: %w", err)
}
defer rows.Close()
stages := []Stage{}
for rows.Next() {
st, err := scanStage(rows)
if err != nil {
return nil, err
}
stages = append(stages, st)
}
return stages, rows.Err()
}
// GetStageByID returns a single stage by its ID.
func (s *Store) GetStageByID(id string) (Stage, error) {
var st Stage
var autoDeploy, confirm, enableProxy int
err := s.db.QueryRow(
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
if err != nil {
return Stage{}, fmt.Errorf("query stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil
}
// UpdateStage updates an existing stage's mutable fields.
func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=?
WHERE id=?`,
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
)
// notification_secret is intentionally not updated here — use the
// dedicated SetStageNotificationSecret rotation helper.
if err != nil {
return fmt.Errorf("update stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound)
}
return nil
}
// DeleteStage removes a stage by ID. Cascading deletes handle child instances.
func (s *Store) DeleteStage(id string) error {
result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this stage (notifications
// still send unsigned, falling through to project/global resolution).
func (s *Store) SetStageNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set stage notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
stage, err := s.GetStageByID(id)
if err != nil {
return "", err
}
if stage.NotificationSecret != "" {
return stage.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStageNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// BoolToInt converts a bool to an integer for SQLite storage.
func BoolToInt(b bool) int {
if b {
return 1
}
return 0
}
// scanStage scans a stage row from a *sql.Rows cursor.
func scanStage(rows *sql.Rows) (Stage, error) {
var st Stage
var autoDeploy, confirm, enableProxy int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil
}
-112
View File
@@ -1,112 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStaticSiteSecret inserts a new secret for a static site.
func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) {
secret.ID = uuid.New().String()
secret.CreatedAt = Now()
secret.UpdatedAt = secret.CreatedAt
_, err := s.db.Exec(
`INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
secret.ID, secret.SiteID, secret.Key, secret.Value,
BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt,
)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err)
}
return secret, nil
}
// GetStaticSiteSecretsBySiteID returns all secrets for a static site.
func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) {
rows, err := s.db.Query(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID,
)
if err != nil {
return nil, fmt.Errorf("query static site secrets: %w", err)
}
defer rows.Close()
secrets := []StaticSiteSecret{}
for rows.Next() {
secret, err := scanStaticSiteSecret(rows)
if err != nil {
return nil, err
}
secrets = append(secrets, secret)
}
return secrets, rows.Err()
}
// GetStaticSiteSecretByID returns a single secret by ID.
func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := s.db.QueryRow(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE id = ?`, id,
).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
// UpdateStaticSiteSecret updates an existing secret.
func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error {
secret.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID,
)
if err != nil {
return fmt.Errorf("update static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound)
}
return nil
}
// DeleteStaticSiteSecret removes a secret by ID.
func (s *Store) DeleteStaticSiteSecret(id string) error {
result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
return nil
}
// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor.
func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
-502
View File
@@ -1,502 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// staticSiteCols is the column list for static_sites queries.
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
storage_enabled, storage_limit_mb,
webhook_secret, webhook_signing_secret, webhook_require_signature,
notification_url, notification_secret,
created_at, updated_at`
// UpsertStaticSiteWithID inserts or replaces a static site, keeping the
// caller-supplied ID. Used by the plugin static-source Backend adapter
// to keep a phantom row keyed on the workload ID so staticsite.Manager
// (which reads from this table) can serve plugin-native workloads
// without being refactored. Skips workload-row sync since the caller
// already owns the workload row.
func (s *Store) UpsertStaticSiteWithID(site StaticSite) error {
if site.ID == "" {
return fmt.Errorf("UpsertStaticSiteWithID: id is required")
}
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
}
if site.SyncTrigger == "" {
site.SyncTrigger = "manual"
}
if site.Mode == "" {
site.Mode = "static"
}
if site.Branch == "" {
site.Branch = "main"
}
if site.Status == "" {
site.Status = "idle"
}
now := Now()
site.UpdatedAt = now
if site.CreatedAt == "" {
site.CreatedAt = now
}
_, err := s.db.Exec(
`INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return fmt.Errorf("upsert static site: %w", err)
}
return nil
}
// CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input. Site row
// + matching workload row are written in a single transaction.
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
site.ID = uuid.New().String()
site.CreatedAt = Now()
site.UpdatedAt = site.CreatedAt
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
} else if len(site.WebhookSecret) < minWebhookSecretLength {
return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
tx, err := s.db.Begin()
if err != nil {
return StaticSite{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
); err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
}
if err := SyncStaticSiteWorkloadTx(tx, site); err != nil {
return StaticSite{}, err
}
if err := tx.Commit(); err != nil {
return StaticSite{}, fmt.Errorf("commit: %w", err)
}
return site, nil
}
// GetStaticSiteByID returns a single static site by its ID.
func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) {
site, err := scanStaticSiteRow(s.db.QueryRow(
`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSite{}, fmt.Errorf("query static site: %w", err)
}
return site, nil
}
// GetAllStaticSites returns every static site ordered by name.
func (s *Store) GetAllStaticSites() ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static sites: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// GetStaticSitesByRepo returns all static sites for a given repo owner/name.
func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT `+staticSiteCols+`
FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ?
ORDER BY name`,
giteaURL, owner, name,
)
if err != nil {
return nil, fmt.Errorf("query static sites by repo: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// updateStaticSiteAndSyncWorkloadTx wraps a parameterized UPDATE on
// static_sites with the workload sync, all inside a single transaction.
// updateSQL must end with `WHERE id=?`; args end with the site ID.
func (s *Store) updateStaticSiteAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("update static site: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
row := tx.QueryRow(`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id)
current, err := scanStaticSiteRowFromQuery(row)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
if err := SyncStaticSiteWorkloadTx(tx, current); err != nil {
return err
}
return tx.Commit()
}
// scanStaticSiteRowFromQuery is a thin wrapper around scanStaticSiteRow that
// accepts a *sql.Row from either s.db or a transaction. Kept private so the
// public surface stays narrow.
func scanStaticSiteRowFromQuery(row *sql.Row) (StaticSite, error) {
return scanStaticSiteRow(row)
}
// UpdateStaticSite updates an existing static site's configuration fields.
// notification_secret is intentionally not updated here — use the dedicated
// SetStaticSiteNotificationSecret rotation helper.
func (s *Store) UpdateStaticSite(site StaticSite) error {
site.UpdatedAt = Now()
return s.updateStaticSiteAndSyncWorkloadTx(site.ID,
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
notification_url=?, updated_at=?
WHERE id=?`,
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.NotificationURL, site.UpdatedAt, site.ID,
)
}
// UpdateStaticSiteStatus updates the deployment status fields.
func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=?
WHERE id=?`,
status, commitSHA, now, errMsg, now, id,
)
if err != nil {
return fmt.Errorf("update static site status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment.
func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`,
containerID, proxyRouteID, now, id,
)
if err != nil {
return fmt.Errorf("update static site container: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
// shaped to match the unified ProxyRoute model used by the Proxies page.
// Sites without an active proxy route are skipped.
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
FROM static_sites
WHERE proxy_route_id != ''
ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static site proxy routes: %w", err)
}
defer rows.Close()
suffix := ""
if domain != "" {
suffix = "." + strings.ToLower(domain)
}
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var mode, provider, fullDomain string
if err := rows.Scan(
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan static site proxy route: %w", err)
}
r.Source = "static_site"
r.StageName = mode
r.ImageTag = provider
r.Domain = fullDomain
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
} else {
r.Subdomain = fullDomain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// DeleteStaticSite removes a static site by ID. Cascading deletes handle
// secrets. Site + workload + container index rows are dropped atomically.
func (s *Store) DeleteStaticSite(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindSite), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup site workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete static site containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete static site workload: %w", err)
}
}
return tx.Commit()
}
// scanStaticSiteRow scans a static site from a *sql.Row.
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled, requireSig int
err := row.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, err
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled, requireSig int
err := rows.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret.
// Pass an empty string to clear it (also implicitly disables enforcement).
func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or
// invalidly-signed) inbound webhook requests are rejected with 401.
func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) error {
v := 0
if require {
v = 1
}
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
}
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
// signing secret. Empty string disables HMAC signing for this site
// (notifications still send unsigned, falling through to global resolution).
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
// signing secret, generating one lazily if missing.
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
site, err := s.GetStaticSiteByID(id)
if err != nil {
return "", err
}
if site.NotificationSecret != "" {
return site.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
st, err := s.GetSettings()
if err != nil {
return "", err
}
if st.NotificationSecret != "" {
return st.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetSettingsNotificationSecret(secret); err != nil {
return "", err
}
return secret, nil
}
// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret.
// Returns ErrNotFound if no site has this secret (including empty).
func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) {
if secret == "" {
return StaticSite{}, ErrNotFound
}
site, err := scanStaticSiteRow(s.db.QueryRow(
`SELECT `+staticSiteCols+` FROM static_sites WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return StaticSite{}, ErrNotFound
}
if err != nil {
return StaticSite{}, fmt.Errorf("query static site by webhook secret: %w", err)
}
return site, nil
}
// SetStaticSiteWebhookSecret assigns a webhook secret to a static site.
// Pass an empty string to disable webhook access for the site.
func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site,
// generating one on the fly if the stored value is empty (lazy backfill).
func (s *Store) EnsureStaticSiteWebhookSecret(id string) (string, error) {
site, err := s.GetStaticSiteByID(id)
if err != nil {
return "", err
}
if site.WebhookSecret != "" {
return site.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
+39 -269
View File
@@ -97,97 +97,44 @@ func (s *Store) migrate() error {
}
// runMigrations applies additive schema changes that cannot be expressed
// with CREATE TABLE IF NOT EXISTS.
// with CREATE TABLE IF NOT EXISTS, plus the hard-cutover drops that
// remove every legacy project/stage/stack/static_site/deploy table.
func (s *Store) runMigrations() error {
migrations := []string{
// Add owner column to registries (2026-03-28).
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
// Add base_volume_path to settings (2026-03-28).
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
// Settings column adds that survive the cutover. SQLite is tolerant
// of "duplicate column" errors at the apply step, so re-running on
// a fully-migrated DB is a no-op.
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
// Add enable_proxy to stages (2026-03-29). Default true for backwards compat.
`ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`,
// Add ssl_certificate_id to settings (2026-03-29).
`ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`,
// Add stale_threshold_days to settings (2026-03-30).
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
// Add last_alive_at to instances for stale container detection (2026-03-30).
`ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`,
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
// Add DNS management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
// Add backup management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
// Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04).
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
// Add Traefik provider settings (2026-04-04).
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
// NPM remote mode: forward to server_ip instead of container name.
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
// Resource limits per stage.
`ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`,
`ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`,
// NPM access list support (global default + per-project override).
`ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
// Separate public IP for DNS A records.
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
// Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled.
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
// Add provider column to static_sites (2026-04-11).
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
// Add persistent storage columns to static_sites (2026-04-12).
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
// Per-project + per-site webhook secrets (2026-04-23). Global
// settings.webhook_secret is deprecated; its column is retained to
// avoid a destructive migration on SQLite.
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
// Resource metrics collection (2026-04-24). Interval in seconds,
// retention in hours. 0 in either disables collection.
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
// tokens (matches the inbound webhook_secret pattern). Empty = no
// signing; existing rows stay unsigned on upgrade for back-compat.
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
// Auto-backup before deploy (2026-05-07). When enabled, the deployer
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
// so a corrupted deploy is recoverable without data loss.
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
// Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret
// is the HMAC-SHA256 key separate from the URL secret so a leaked URL
// alone is not sufficient to forge a valid request. require_signature
// rejects unsigned requests when set (defense-in-depth opt-in).
`ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
// Webhook delivery audit log (2026-05-07). Persists every inbound
// webhook request (project or site) with its outcome so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
// Registries — owner column.
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
// Webhook delivery audit log persists every inbound webhook
// request so operators can debug "why didn't my deploy fire?"
// without grepping daemon logs.
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
@@ -203,19 +150,36 @@ func (s *Store) runMigrations() error {
)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
// Add stage_id to containers (2026-05-09). Backfill via the deployer
// re-write path; the LEFT JOIN in ListContainersByStageID falls back
// to (project_id, role=stage_name) so legacy rows still resolve.
// Containers — stage_id is now an opaque string set by the source
// plugin (image plugin uses it for the deploy-target tag). No FK
// semantics: the legacy `stages` table this column once joined to
// is gone; the column is just a free-form discriminator the
// proxies / dashboard views read to disambiguate sibling rows.
`ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`,
// Workload-first refactor columns (2026-05-10). Land additively so
// the legacy kind/ref_id columns continue to serve existing
// project/stack/site rows during cutover.
// Workload-first refactor columns. Land additively so old databases
// (which have a bare workloads table) pick them up on the next boot.
`ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE
// IF EXISTS is a no-op once the table is gone. Operators upgrading
// from a pre-cutover build will lose any project / stack / static
// site rows; the upgrade notes call this out explicitly.
`DROP TABLE IF EXISTS deploy_logs`,
`DROP TABLE IF EXISTS deploys`,
`DROP TABLE IF EXISTS stage_env`,
`DROP TABLE IF EXISTS stages`,
`DROP TABLE IF EXISTS poll_states`,
`DROP TABLE IF EXISTS volumes`,
`DROP TABLE IF EXISTS static_site_secrets`,
`DROP TABLE IF EXISTS static_sites`,
`DROP TABLE IF EXISTS stack_deploys`,
`DROP TABLE IF EXISTS stack_revisions`,
`DROP TABLE IF EXISTS stacks`,
`DROP TABLE IF EXISTS projects`,
}
// Workload refactor tables (2026-05-09). Workload is the unifying primitive
@@ -369,46 +333,6 @@ func (s *Store) runMigrations() error {
}
}
stackTables := []string{
`CREATE TABLE IF NOT EXISTS stacks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
compose_project_name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'stopped',
error TEXT NOT NULL DEFAULT '',
current_revision_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS stack_revisions (
id TEXT PRIMARY KEY,
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,
yaml TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
deploy_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(stack_id, revision)
)`,
`CREATE TABLE IF NOT EXISTS stack_deploys (
id TEXT PRIMARY KEY,
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
revision_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
log TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT NOT NULL DEFAULT ''
)`,
}
for _, t := range stackTables {
if _, err := s.db.Exec(t); err != nil {
return fmt.Errorf("create stack table: %w", err)
}
}
// Observability: event_triggers — consume EventLog entries off the
// bus and dispatch webhook actions. Schema kept flat (comma-list
// filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md.
@@ -469,34 +393,18 @@ func (s *Store) runMigrations() error {
}
}
// Create indexes on foreign key columns for query performance.
// Create indexes on foreign key columns for query performance. Only
// indexes targeting tables that still exist after the hard cutover.
indexes := []string{
// instances table dropped 2026-05-09 (workload refactor) — no indexes
// needed; containers replaces it with idx_containers_workload below.
`CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`,
`CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
`CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`,
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
// Drop the legacy instances table — containers is the canonical index
// after the workload refactor (2026-05-09). Idempotent: SQLite's
// DROP TABLE IF EXISTS is a no-op on databases that already shed it.
`DROP TABLE IF EXISTS instances`,
// Workload refactor indexes (2026-05-09).
// Workload refactor indexes.
`CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`,
`CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`,
@@ -508,7 +416,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
// Trigger-split indexes (2026-05-16).
// Trigger-split indexes.
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
@@ -520,19 +428,6 @@ func (s *Store) runMigrations() error {
}
}
// Data migration: copy mode→scope for volumes that have scope still empty.
// shared→project, isolated→instance. Log errors but don't fail startup.
dataMigrations := []struct{ query, desc string }{
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`, "migrate shared→project"},
{`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`, "migrate isolated→instance"},
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`, "migrate empty→project"},
}
for _, dm := range dataMigrations {
if _, err := s.db.Exec(dm.query); err != nil {
fmt.Printf("volume scope migration warning (%s): %v\n", dm.desc, err)
}
}
if err := s.backfillTriggersFromWorkloads(); err != nil {
slog.Warn("trigger backfill", "error", err)
}
@@ -658,42 +553,6 @@ func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config,
}
const schema = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
registry TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 0,
healthcheck TEXT NOT NULL DEFAULT '',
env TEXT NOT NULL DEFAULT '{}',
volumes TEXT NOT NULL DEFAULT '{}',
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS stages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tag_pattern TEXT NOT NULL DEFAULT '*',
auto_deploy INTEGER NOT NULL DEFAULT 0,
max_instances INTEGER NOT NULL DEFAULT 1,
confirm INTEGER NOT NULL DEFAULT 0,
enable_proxy INTEGER NOT NULL DEFAULT 1,
promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
cpu_limit REAL NOT NULL DEFAULT 0,
memory_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS registries (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
@@ -730,36 +589,6 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The instances table was removed in the workload refactor (2026-05-09).
-- Container state lives in the containers table; see runMigrations for the
-- current schema. The DROP TABLE migration runs unconditionally on boot.
CREATE TABLE IF NOT EXISTS deploys (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS deploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE,
message TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS poll_states (
stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE,
last_tag TEXT NOT NULL DEFAULT '',
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -785,27 +614,6 @@ INSERT OR IGNORE INTO settings (id) VALUES (1);
-- Seed the auth_settings row if it does not exist.
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS stage_env (
id TEXT PRIMARY KEY,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(stage_id, key)
);
CREATE TABLE IF NOT EXISTS volumes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
source TEXT NOT NULL,
target TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'shared',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
@@ -845,44 +653,6 @@ CREATE TABLE IF NOT EXISTS backups (
backup_type TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL DEFAULT '',
gitea_url TEXT NOT NULL DEFAULT '',
repo_owner TEXT NOT NULL DEFAULT '',
repo_name TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT 'main',
folder_path TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'static',
render_markdown INTEGER NOT NULL DEFAULT 0,
sync_trigger TEXT NOT NULL DEFAULT 'manual',
tag_pattern TEXT NOT NULL DEFAULT '',
container_id TEXT NOT NULL DEFAULT '',
proxy_route_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle',
last_sync_at TEXT NOT NULL DEFAULT '',
last_commit_sha TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_site_secrets (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(site_id, key)
);
`
// Now returns the current time formatted for SQLite storage.
-236
View File
@@ -14,62 +14,6 @@ func newTestStore(t *testing.T) *Store {
return s
}
func TestCreateAndGetProject(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "test-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if p.ID == "" {
t.Fatal("project ID should be set")
}
got, err := s.GetProjectByID(p.ID)
if err != nil {
t.Fatalf("GetProjectByID: %v", err)
}
if got.Name != "test-project" {
t.Fatalf("got name %q, want %q", got.Name, "test-project")
}
}
func TestGetAllProjects(t *testing.T) {
s := newTestStore(t)
s.CreateProject(Project{Name: "bravo", Image: "img", Env: "{}", Volumes: "{}"})
s.CreateProject(Project{Name: "alpha", Image: "img", Env: "{}", Volumes: "{}"})
projects, err := s.GetAllProjects()
if err != nil {
t.Fatalf("GetAllProjects: %v", err)
}
if len(projects) != 2 {
t.Fatalf("expected 2 projects, got %d", len(projects))
}
// Should be ordered by name
if projects[0].Name != "alpha" {
t.Fatalf("expected first project 'alpha', got %q", projects[0].Name)
}
}
func TestDeleteProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "del-me", Image: "img", Env: "{}", Volumes: "{}"})
err := s.DeleteProject(p.ID)
if err != nil {
t.Fatalf("DeleteProject: %v", err)
}
_, err = s.GetProjectByID(p.ID)
if err == nil {
t.Fatal("expected error getting deleted project")
}
}
func TestCreateAndGetUser(t *testing.T) {
s := newTestStore(t)
@@ -110,80 +54,6 @@ func TestUserCount(t *testing.T) {
}
}
func TestCreateStageAndDeploy(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, err := s.CreateStage(Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 2,
})
if err != nil {
t.Fatalf("CreateStage: %v", err)
}
d, err := s.CreateDeploy(Deploy{
ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1.0",
})
if err != nil {
t.Fatalf("CreateDeploy: %v", err)
}
if d.Status != "pending" {
t.Fatalf("expected pending status, got %q", d.Status)
}
err = s.UpdateDeployStatus(d.ID, "success", "")
if err != nil {
t.Fatalf("UpdateDeployStatus: %v", err)
}
got, _ := s.GetDeployByID(d.ID)
if got.Status != "success" {
t.Fatalf("expected success, got %q", got.Status)
}
}
func TestGetDeploysByProjectID(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
for i := 0; i < 5; i++ {
_, err := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
if err != nil {
t.Fatalf("CreateDeploy %d: %v", i, err)
}
}
deploys, err := s.GetDeploysByProjectID(p.ID)
if err != nil {
t.Fatalf("GetDeploysByProjectID: %v", err)
}
if len(deploys) != 5 {
t.Fatalf("expected 5 deploys, got %d", len(deploys))
}
}
func TestGetRecentDeploys(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
for i := 0; i < 5; i++ {
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
}
// Limit to 2
deploys, err := s.GetRecentDeploys(2)
if err != nil {
t.Fatalf("GetRecentDeploys: %v", err)
}
if len(deploys) != 2 {
t.Fatalf("expected 2 deploys with limit, got %d", len(deploys))
}
}
func TestDeleteUser(t *testing.T) {
s := newTestStore(t)
@@ -199,27 +69,6 @@ func TestDeleteUser(t *testing.T) {
}
}
func TestUpdateProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "original", Image: "nginx", Env: "{}", Volumes: "{}"})
p.Name = "updated"
p.Image = "alpine"
err := s.UpdateProject(p)
if err != nil {
t.Fatalf("UpdateProject: %v", err)
}
got, _ := s.GetProjectByID(p.ID)
if got.Name != "updated" {
t.Fatalf("expected name 'updated', got %q", got.Name)
}
if got.Image != "alpine" {
t.Fatalf("expected image 'alpine', got %q", got.Image)
}
}
func TestUpdateUser(t *testing.T) {
s := newTestStore(t)
@@ -241,88 +90,3 @@ func TestUpdateUser(t *testing.T) {
}
}
func TestDeployLogs(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
d, _ := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
err := s.AppendDeployLog(d.ID, "pulling image", "info")
if err != nil {
t.Fatalf("AppendDeployLog: %v", err)
}
err = s.AppendDeployLog(d.ID, "something failed", "error")
if err != nil {
t.Fatalf("AppendDeployLog: %v", err)
}
logs, err := s.GetDeployLogs(d.ID)
if err != nil {
t.Fatalf("GetDeployLogs: %v", err)
}
if len(logs) != 2 {
t.Fatalf("expected 2 logs, got %d", len(logs))
}
if logs[0].Message != "pulling image" {
t.Fatalf("expected first log 'pulling image', got %q", logs[0].Message)
}
if logs[1].Level != "error" {
t.Fatalf("expected second log level 'error', got %q", logs[1].Level)
}
}
func TestGetStagesByProjectID(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
s.CreateStage(Stage{ProjectID: p.ID, Name: "prod", TagPattern: "v*"})
s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
stages, err := s.GetStagesByProjectID(p.ID)
if err != nil {
t.Fatalf("GetStagesByProjectID: %v", err)
}
if len(stages) != 2 {
t.Fatalf("expected 2 stages, got %d", len(stages))
}
// Ordered by name
if stages[0].Name != "dev" {
t.Fatalf("expected first stage 'dev', got %q", stages[0].Name)
}
}
func TestIsTerminalDeployStatus(t *testing.T) {
terminals := []string{"success", "failed", "rolled_back"}
for _, s := range terminals {
if !IsTerminalDeployStatus(s) {
t.Fatalf("expected %q to be terminal", s)
}
}
nonTerminals := []string{"pending", "pulling", "starting", "configuring_proxy", "health_checking"}
for _, s := range nonTerminals {
if IsTerminalDeployStatus(s) {
t.Fatalf("expected %q to be non-terminal", s)
}
}
}
func TestCascadeDeleteProjectRemovesStagesAndDeploys(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
err := s.DeleteProject(p.ID)
if err != nil {
t.Fatalf("DeleteProject: %v", err)
}
// Stage should be gone
_, err = s.GetStageByID(stage.ID)
if err == nil {
t.Fatal("expected stage to be deleted by cascade")
}
}
-119
View File
@@ -1,119 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// volumeColumns is the canonical column list for volume queries.
const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at`
// CreateVolume inserts a new volume configuration for a project.
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
vol.ID = uuid.New().String()
vol.CreatedAt = Now()
vol.UpdatedAt = vol.CreatedAt
// Default scope for backward compatibility.
if vol.Scope == "" {
switch vol.Mode {
case "isolated":
vol.Scope = "instance"
default:
vol.Scope = "project"
}
}
_, err := s.db.Exec(
`INSERT INTO volumes (`+volumeColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode,
vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt,
)
if err != nil {
return Volume{}, fmt.Errorf("insert volume: %w", err)
}
return vol, nil
}
// GetVolumesByProjectID returns all volume configurations for a project.
func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
rows, err := s.db.Query(
`SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query volumes: %w", err)
}
defer rows.Close()
vols := []Volume{}
for rows.Next() {
vol, err := scanVolume(rows)
if err != nil {
return nil, err
}
vols = append(vols, vol)
}
return vols, rows.Err()
}
// GetVolumeByID returns a single volume by its ID.
func (s *Store) GetVolumeByID(id string) (Volume, error) {
var vol Volume
err := s.db.QueryRow(
`SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id,
).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound)
}
if err != nil {
return Volume{}, fmt.Errorf("query volume: %w", err)
}
return vol, nil
}
// UpdateVolume updates an existing volume configuration.
func (s *Store) UpdateVolume(vol Volume) error {
vol.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=?
WHERE id=?`,
vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID,
)
if err != nil {
return fmt.Errorf("update volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound)
}
return nil
}
// DeleteVolume removes a volume configuration by ID.
func (s *Store) DeleteVolume(id string) error {
result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("volume %s: %w", id, ErrNotFound)
}
return nil
}
// scanVolume scans a volume row from a *sql.Rows cursor.
func scanVolume(rows *sql.Rows) (Volume, error) {
var vol Volume
err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
if err != nil {
return Volume{}, fmt.Errorf("scan volume: %w", err)
}
return vol, nil
}
+4 -4
View File
@@ -8,7 +8,7 @@ import (
// handler decides what to do so the row reflects the final outcome.
type WebhookDelivery struct {
ID int64 `json:"id"`
TargetType string `json:"target_type"` // "project" or "site"
TargetType string `json:"target_type"` // "trigger" today; legacy rows may carry "project" or "site"
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
ReceivedAt string `json:"received_at"`
@@ -38,9 +38,9 @@ func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error {
return nil
}
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a
// specific target. Used by the per-entity panel on the project / site detail
// pages.
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for
// a specific target. Used by the trigger detail panel after the legacy
// project / site detail pages were removed.
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
if limit <= 0 || limit > 200 {
limit = 50
-150
View File
@@ -1,150 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so
// CRUD callers can pass in either a transaction or the raw DB handle. Keeps
// the sync logic atomic with the parent row when wrapped in a Begin/Commit.
type dbExec interface {
Exec(query string, args ...any) (sql.Result, error)
QueryRow(query string, args ...any) *sql.Row
}
// syncWorkloadTx is the shared upsert path used by every kind-specific
// sync helper. Caller passes the kind, ref, and the projection of fields
// that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE
// constraint to decide INSERT vs UPDATE.
func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error {
now := Now()
requireInt := 0
if requireSig {
requireInt = 1
}
var existingID string
err := ex.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(kind), refID,
).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
_, err := ex.Exec(
`INSERT INTO workloads (id, kind, ref_id, name, app_id,
notification_url, notification_secret,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at)
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`,
uuid.New().String(), string(kind), refID, name,
notifURL, notifSecret, hookSecret, signSecret, requireInt,
now, now,
)
if err != nil {
return fmt.Errorf("insert %s workload: %w", kind, err)
}
return nil
}
if err != nil {
return fmt.Errorf("lookup %s workload: %w", kind, err)
}
_, err = ex.Exec(
`UPDATE workloads SET name=?,
notification_url=?, notification_secret=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID,
)
if err != nil {
return fmt.Errorf("update %s workload: %w", kind, err)
}
return nil
}
// SyncProjectWorkloadTx upserts the workload row paired with a project inside
// the caller's transaction. Used by CreateProject / UpdateProject /
// SetProject*Secret so the parent UPDATE and the workload sync share atomicity.
func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error {
return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkloadTx upserts the workload row paired with a stack inside the
// caller's transaction. Stacks don't carry notification or webhook config yet.
func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error {
return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkloadTx upserts the workload row paired with a static site
// inside the caller's transaction.
func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error {
return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// SyncProjectWorkload is the non-transactional convenience used by
// BackfillWorkloads (a boot-time, single-row, idempotent recovery pass).
// CRUD paths must use SyncProjectWorkloadTx instead, with their parent
// UPDATE inside the same transaction.
func (s *Store) SyncProjectWorkload(p Project) error {
return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStackWorkload(st Stack) error {
return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStaticSiteWorkload(site StaticSite) error {
return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// BackfillWorkloads scans every project / stack / static_site row and ensures
// each has a matching workload row. Called once at boot before HTTP starts so
// any pre-Workload-refactor data is upgraded transparently. Idempotent.
func (s *Store) BackfillWorkloads() error {
projects, err := s.GetAllProjects()
if err != nil {
return fmt.Errorf("backfill: list projects: %w", err)
}
for _, p := range projects {
if err := s.SyncProjectWorkload(p); err != nil {
return fmt.Errorf("backfill project %s: %w", p.ID, err)
}
}
stacks, err := s.GetAllStacks()
if err != nil {
return fmt.Errorf("backfill: list stacks: %w", err)
}
for _, st := range stacks {
if err := s.SyncStackWorkload(st); err != nil {
return fmt.Errorf("backfill stack %s: %w", st.ID, err)
}
}
sites, err := s.GetAllStaticSites()
if err != nil {
return fmt.Errorf("backfill: list static sites: %w", err)
}
for _, site := range sites {
if err := s.SyncStaticSiteWorkload(site); err != nil {
return fmt.Errorf("backfill static site %s: %w", site.ID, err)
}
}
return nil
}
-190
View File
@@ -1,190 +0,0 @@
package store
import (
"errors"
"testing"
)
func TestCreateProjectAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "wf-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
NotificationURL: "https://example.test/hook",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err != nil {
t.Fatalf("workload should exist after CreateProject: %v", err)
}
if w.Name != "wf-project" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
if w.WebhookSecret == "" {
t.Fatal("webhook secret should be carried into workload row")
}
if w.NotificationURL != "https://example.test/hook" {
t.Fatalf("notification url not synced: got %q", w.NotificationURL)
}
}
func TestUpdateProjectSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "before", Image: "i", Env: "{}", Volumes: "{}",
})
p.Name = "after"
p.NotificationURL = "https://new.test/hook"
if err := s.UpdateProject(p); err != nil {
t.Fatalf("UpdateProject: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
if w.NotificationURL != "https://new.test/hook" {
t.Fatalf("workload notification url not updated: got %q", w.NotificationURL)
}
}
func TestDeleteProjectCascadesWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "doomed", Image: "i", Env: "{}", Volumes: "{}"})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
// Add a container under this workload to verify cascade.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID, WorkloadKind: "project", State: "running",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
if err := s.DeleteProject(p.ID); err != nil {
t.Fatalf("DeleteProject: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
containers, _ := s.ListContainersByWorkload(w.ID)
if len(containers) != 0 {
t.Fatalf("containers should be deleted, got %d", len(containers))
}
}
func TestSetProjectWebhookSecretSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "n", Image: "i", Env: "{}", Volumes: "{}"})
newSecret := "new-secret-value-with-enough-entropy-1234"
if err := s.SetProjectWebhookSecret(p.ID, newSecret); err != nil {
t.Fatalf("SetProjectWebhookSecret: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.WebhookSecret != newSecret {
t.Fatalf("workload webhook secret not synced: got %q", w.WebhookSecret)
}
}
func TestCreateStackAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
st, err := s.CreateStack(Stack{Name: "wf-stack", ComposeProjectName: "wf-stack"})
if err != nil {
t.Fatalf("CreateStack: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err != nil {
t.Fatalf("workload should exist after CreateStack: %v", err)
}
if w.Name != "wf-stack" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
}
func TestUpdateStackSyncsWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "before", ComposeProjectName: "before-cp"})
st.Name = "after"
if err := s.UpdateStack(st); err != nil {
t.Fatalf("UpdateStack: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
}
func TestDeleteStackCascadesWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "doomed-stack", ComposeProjectName: "doomed-cp"})
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err := s.DeleteStack(st.ID); err != nil {
t.Fatalf("DeleteStack: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
}
func TestBackfillWorkloadsIdempotent(t *testing.T) {
s := newTestStore(t)
// Create rows directly via the store (which already auto-syncs), then run
// the backfill twice — it must be a no-op the second time and not error.
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
st, _ := s.CreateStack(Stack{Name: "s1", ComposeProjectName: "s1-cp"})
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("first backfill: %v", err)
}
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("second backfill (should be idempotent): %v", err)
}
all, _ := s.ListWorkloads("")
// Expect exactly 2: one project workload, one stack workload, no duplicates.
if len(all) != 2 {
t.Fatalf("expected 2 workloads after backfill, got %d", len(all))
}
// Confirm both refs are findable.
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("project workload not found: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID); err != nil {
t.Fatalf("stack workload not found: %v", err)
}
}
func TestBackfillRecoversFromMissingWorkloads(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
// Simulate the legacy state: a project exists but its workload row is gone
// (e.g. the rollout from before the refactor). Backfill must restore it.
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
_ = s.DeleteWorkload(w.ID)
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("backfill: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("workload should be restored: %v", err)
}
}
+6 -52
View File
@@ -93,24 +93,6 @@ func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, err
return w, nil
}
// GetWorkloadByWebhookSecret looks up a workload by its inbound webhook URL secret.
// Returns ErrNotFound when no match — used by the webhook router.
func (s *Store) GetWorkloadByWebhookSecret(secret string) (Workload, error) {
if secret == "" {
return Workload{}, fmt.Errorf("empty secret: %w", ErrNotFound)
}
w, err := scanWorkload(s.db.QueryRow(
`SELECT `+workloadColumns+` FROM workloads WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return Workload{}, ErrNotFound
}
if err != nil {
return Workload{}, fmt.Errorf("query workload by webhook secret: %w", err)
}
return w, nil
}
// ListWorkloads returns all workloads, optionally filtered by kind. Pass
// empty string to get every workload regardless of kind.
func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
@@ -231,40 +213,12 @@ func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) {
return out, rows.Err()
}
// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass
// empty to disable inbound webhooks for this workload.
func (s *Store) SetWorkloadWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update workload webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureWorkloadWebhookSecret returns the current secret, generating one
// lazily for workloads that predate the column. Mirrors the project /
// site equivalents.
func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) {
w, err := s.GetWorkloadByID(id)
if err != nil {
return "", err
}
if w.WebhookSecret != "" {
return w.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetWorkloadWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// Workload-level webhook secret accessors (Get/Set/Ensure) were dropped
// in the hard legacy cutover: the inbound `/api/webhook/workloads/...`
// route is gone. The trigger-split refactor's boot backfill still reads
// the `workloads.webhook_secret` column directly via SQL to lift any
// pre-cutover embedded secret onto its standalone Trigger row, then the
// column is effectively dead.
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
// Idempotent — returns nil if no row exists, since the kind-specific Delete
+3 -22
View File
@@ -84,28 +84,9 @@ func TestUpdateWorkload(t *testing.T) {
}
}
func TestGetWorkloadByWebhookSecret(t *testing.T) {
s := newTestStore(t)
w, _ := s.CreateWorkload(Workload{
Kind: "project", RefID: "p1", Name: "n", WebhookSecret: "deadbeef",
})
got, err := s.GetWorkloadByWebhookSecret("deadbeef")
if err != nil {
t.Fatalf("GetWorkloadByWebhookSecret: %v", err)
}
if got.ID != w.ID {
t.Fatalf("got workload %s, want %s", got.ID, w.ID)
}
if _, err := s.GetWorkloadByWebhookSecret(""); !errors.Is(err, ErrNotFound) {
t.Fatalf("empty secret should be NotFound, got %v", err)
}
if _, err := s.GetWorkloadByWebhookSecret("nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("unknown secret should be NotFound, got %v", err)
}
}
// GetWorkloadByWebhookSecret was deleted with the legacy
// `/api/webhook/workloads/{secret}` route in the hard cutover; the
// inbound webhook surface is now first-class Triggers.
func TestListWorkloads(t *testing.T) {
s := newTestStore(t)
-52
View File
@@ -10,58 +10,6 @@ import (
"github.com/alexei/tinyforge/internal/store"
)
// ResolveParams holds the parameters needed to resolve a volume's host path.
type ResolveParams struct {
BasePath string
ProjectName string
StageName string // required for instance and stage scopes
ImageTag string // required for instance scope
AllowedVolumePaths string // JSON array of allowed absolute paths (from settings)
}
// ResolvePath returns the absolute host path for a volume based on its scope.
// Returns an error for ephemeral volumes (no host path) or missing parameters.
func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
scope := vol.Scope
if scope == "" {
switch vol.Mode {
case "isolated":
scope = "instance"
default:
scope = "project"
}
}
if scope == "ephemeral" {
return "", fmt.Errorf("ephemeral volumes have no host path")
}
if scope == "absolute" {
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
}
switch scope {
case "instance":
if params.StageName == "" || params.ImageTag == "" {
return "", fmt.Errorf("instance scope requires stage and tag parameters")
}
return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil
case "stage":
if params.StageName == "" {
return "", fmt.Errorf("stage scope requires stage parameter")
}
return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil
case "project":
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
case "project_named":
return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil
case "named":
return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil
default:
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
}
}
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
if source == "" {
+33 -458
View File
@@ -6,13 +6,10 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
@@ -125,28 +122,12 @@ func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified
return hmac.Equal(provided, expected), true
}
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
// webhooks. Above this limit, requests are rejected with 503.
const maxSiteConcurrentSyncs = 4
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
// /api routes already wrap the body with MaxBytesReader, but the webhook
// router relies on its own limit so changes to the parent middleware can't
// silently increase the cap.
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
// DeployTriggerer is called when a webhook determines a deploy should happen.
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
}
// SiteSyncTriggerer is called when a static-site webhook determines a sync
// should happen. The manager handles the actual git-pull + redeploy.
type SiteSyncTriggerer interface {
Deploy(ctx context.Context, siteID string, force bool) error
}
// PluginDispatcher is what the plugin-workload webhook handler needs from
// the deployer: the canonical Source-dispatch entry point plus access to
// the same Deps bundle so Trigger.Match can read store / crypto.
@@ -155,23 +136,10 @@ type PluginDispatcher interface {
PluginDeps() pluginDeps
}
// Payload is the expected JSON body for a project webhook request.
type Payload struct {
// Image is the full image reference including tag, e.g.
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
Image string `json:"image"`
}
// SitePayload is the expected JSON body for a static-site webhook request.
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
// matters for branch filtering. Body is optional — an empty body triggers
// a sync using the site's configured branch.
type SitePayload struct {
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
}
// ParsedImage holds the components extracted from a full image reference string.
type ParsedImage struct {
// parsedImage holds the components extracted from a full image reference
// string. Package-private — the only callers are buildInboundEvent and the
// vendor parsers in this package.
type parsedImage struct {
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
Registry string
// Owner is the namespace/org, e.g. "alexei".
@@ -182,28 +150,28 @@ type ParsedImage struct {
Tag string
}
// FullName returns "owner/name" (the image path without registry and tag).
func (p ParsedImage) FullName() string {
// fullName returns "owner/name" (the image path without registry and tag).
func (p parsedImage) fullName() string {
if p.Owner != "" {
return p.Owner + "/" + p.Name
}
return p.Name
}
// ParseImageRef splits a full image reference into its components.
// parseImageRef splits a full image reference into its components.
// Accepted formats:
//
// registry.example.com/owner/name:tag
// registry.example.com/owner/name
// owner/name:tag
// name:tag
func ParseImageRef(ref string) (ParsedImage, error) {
func parseImageRef(ref string) (parsedImage, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return ParsedImage{}, fmt.Errorf("empty image reference")
return parsedImage{}, fmt.Errorf("empty image reference")
}
var parsed ParsedImage
var parsed parsedImage
// Split off tag.
if idx := strings.LastIndex(ref, ":"); idx != -1 {
@@ -232,81 +200,45 @@ func ParseImageRef(ref string) (ParsedImage, error) {
}
if parsed.Name == "" {
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
return parsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
}
return parsed, nil
}
// Handler is the HTTP handler for webhook requests.
// Handler is the HTTP handler for webhook requests. After the legacy
// project / site webhook routes were dropped, the only inbound path is
// the trigger fan-out — every project / site / stack webhook was lifted
// into a first-class Trigger row by the boot backfill.
type Handler struct {
store *store.Store
deployer DeployTriggerer
sites SiteSyncTriggerer
plugins PluginDispatcher // optional; nil disables /workloads/{secret}
// Site sync coordination — webhooks fire syncs in the background; Drain
// blocks until those goroutines finish, so a graceful shutdown does not
// kill an in-flight git fetch + container rebuild.
siteSyncCtx context.Context
siteSyncCancel context.CancelFunc
siteSyncWG sync.WaitGroup
siteSyncSem chan struct{}
store *store.Store
plugins PluginDispatcher // optional; nil disables /triggers/{secret}
}
// NewHandler creates a new webhook Handler. The sites triggerer is optional
// and may be nil (site webhooks will return 404).
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
ctx, cancel := context.WithCancel(context.Background())
return &Handler{
store: st,
deployer: deployer,
sites: sites,
siteSyncCtx: ctx,
siteSyncCancel: cancel,
siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs),
}
}
// SetSiteSyncTriggerer injects the static-site manager after construction.
// The site manager depends on the store + docker client, which are wired up
// in the same startup path as the handler; this setter lets callers defer the
// dependency if needed.
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
h.sites = s
// NewHandler creates a new webhook Handler bound to a store.
func NewHandler(st *store.Store) *Handler {
return &Handler{store: st}
}
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
// is called the /workloads/{secret} route returns 503 — preventing partial
// is called the /triggers/{secret} route returns 503 — preventing partial
// initialization from silently dropping deploys.
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
h.plugins = d
}
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
// Safe to call from a graceful-shutdown path.
func (h *Handler) Drain() {
h.siteSyncCancel()
h.siteSyncWG.Wait()
}
// Drain is a no-op kept for symmetry with the previous shutdown path.
// The trigger fan-out runs synchronously inside the request goroutine,
// so there is nothing to drain at the handler level.
func (h *Handler) Drain() {}
// Route returns a chi router with the webhook endpoints mounted.
//
// Routes:
//
// POST /{secret} — per-project deploy trigger (legacy)
// POST /sites/{secret} — per-site sync trigger (legacy)
// POST /triggers/{secret} — first-class trigger fan-out to all bound workloads
//
// The legacy POST /workloads/{secret} route was dropped in the
// trigger-split refactor. Existing inbound webhook secrets were lifted
// into trigger rows by the boot backfill, so the same secret value
// works at /triggers/{secret} after the upgrade.
// Route returns a chi router with the single inbound webhook endpoint
// mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret}
// routes were removed in the hard cutover; their secrets were lifted
// into Trigger rows on boot.
func (h *Handler) Route() chi.Router {
r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/triggers/{secret}", h.handleTriggerWebhook)
r.Post("/{secret}", h.handleWebhook)
return r
}
@@ -322,363 +254,6 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}
// handleWebhook processes an incoming project webhook request.
//
// URL: POST /api/webhook/{secret}
//
// The secret identifies exactly one project. Stage routing is delegated to
// the project's configured stages (tag_pattern match). Returns 404 for
// unknown secrets (no information leak).
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Build the audit record incrementally; record on every return path so
// users can debug "why didn't my deploy fire?" without grepping logs.
delivery := store.WebhookDelivery{
TargetType: "project",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
// Resolve the secret via the workload row only. The project's own
// webhook_secret column is the source of truth, but lookups go through
// workloads.webhook_secret which is kept in lock-step by the
// transactional sync in the project CRUD path. Reading from workloads
// alone closes the rotation-durability gap: any rotation that didn't
// commit also didn't update the workload row, so an old secret
// surfaces here as 404 rather than being silently accepted.
var (
project store.Project
err error
)
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
if wErr == nil && wl.Kind == string(store.WorkloadKindProject) {
project, err = h.store.GetProjectByID(wl.RefID)
} else {
err = store.ErrNotFound
}
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: project lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = project.ID
delivery.TargetName = project.Name
// Read body once so we can both verify HMAC and decode JSON.
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
// HMAC enforcement: a configured signing secret + the require_signature
// flag together produce a hard reject on missing/invalid signatures.
// When the flag is off we still verify any submitted signature so a
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted)
if project.WebhookRequireSignature && !verified {
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: bad signature", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
var payload Payload
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
if payload.Image == "" {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "missing image field"
respondWebhookError(w, http.StatusBadRequest, "missing image field")
return
}
parsed, err := ParseImageRef(payload.Image)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid image reference"
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return
}
if parsed.Tag == "" {
parsed.Tag = "latest"
}
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
slog.Warn("webhook: image mismatch",
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)
respondWebhookError(w, http.StatusBadRequest, delivery.Detail)
return
}
slog.Info("webhook: received push",
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "stage match failed"
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.Tag)
delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag",
})
return
}
if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name)
delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false,
"project": project.Name, "stage": stage.Name,
})
return
}
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
slog.Error("webhook: deploy trigger failed", "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "deploy trigger failed: " + err.Error()
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return
}
slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
})
}
// handleSiteWebhook processes an incoming static-site webhook request.
//
// URL: POST /api/webhook/sites/{secret}
//
// The secret identifies exactly one static site. If the payload includes a
// ref (Git push event), it must match the site's configured branch (when the
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "site",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.sites == nil {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "static site manager not configured"
http.NotFound(w, r)
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
// Workload-only lookup, mirroring the project handler. Reading from
// workloads.webhook_secret keeps rotation-durability honest — a
// rotation that didn't commit doesn't update the workload row, so the
// stale secret returns 404 instead of being silently accepted.
var (
site store.StaticSite
err error
)
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
if wErr == nil && wl.Kind == string(store.WorkloadKindSite) {
site, err = h.store.GetStaticSiteByID(wl.RefID)
} else {
err = store.ErrNotFound
}
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: site lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = site.ID
delivery.TargetName = site.Name
if site.SyncTrigger == "manual" {
slog.Info("webhook: site sync_trigger=manual, skipping",
"site", site.Name)
delivery.Detail = "sync_trigger=manual"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual",
})
return
}
var payload SitePayload
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
if site.WebhookRequireSignature && !verified {
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: site bad signature", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
}
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
slog.Info("webhook: site ref does not match configured branch/tag",
"site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger)
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern",
})
return
}
select {
case h.siteSyncSem <- struct{}{}:
default:
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "site sync queue full"
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
return
}
h.siteSyncWG.Add(1)
go func(siteID, siteName string) {
defer h.siteSyncWG.Done()
defer func() { <-h.siteSyncSem }()
if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil {
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
}
}(site.ID, site.Name)
_ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
delivery.Outcome = outcomeDeploy
if payload.Ref != "" {
delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref)
} else {
delivery.Detail = "no ref filter"
}
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name,
})
}
// buildInboundEvent normalizes the incoming webhook body into the
// plugin.InboundEvent shape. The dispatch order is:
//
@@ -730,14 +305,14 @@ func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, e
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
}
if probe.Image != "" {
parsed, err := ParseImageRef(probe.Image)
parsed, err := parseImageRef(probe.Image)
if err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
}
evt.Kind = "image-push"
evt.Image = &plugin.ImagePushEvent{
Registry: parsed.Registry,
Repo: parsed.FullName(),
Repo: parsed.fullName(),
Tag: parsed.Tag,
}
return evt, nil
@@ -776,8 +351,8 @@ func toPluginWorkload(w store.Workload) plugin.Workload {
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
-457
View File
@@ -1,457 +0,0 @@
package webhook_test
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
)
// signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header.
func signBody(secret, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
// doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header.
func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if signingSecret != "" {
req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body))
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
resp := w.Result()
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(b)
}
// fakeDeployer records the last trigger for assertion.
type fakeDeployer struct {
mu sync.Mutex
calls int
lastProj string
lastStg string
lastTag string
err error
}
func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.lastProj = projectID
f.lastStg = stageID
f.lastTag = tag
return f.err
}
// fakeSiteTriggerer records Deploy calls.
type fakeSiteTriggerer struct {
mu sync.Mutex
calls int
done chan struct{}
}
func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error {
f.mu.Lock()
f.calls++
ch := f.done
f.mu.Unlock()
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
return nil
}
func newRouter(t *testing.T, h *webhook.Handler) chi.Router {
t.Helper()
r := chi.NewRouter()
r.Mount("/api/webhook", h.Route())
return r
}
func newStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.New(":memory:")
if err != nil {
t.Fatalf("create store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
resp := w.Result()
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(b)
}
func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) {
t.Parallel()
st := newStore(t)
h := webhook.NewHandler(st, &fakeDeployer{}, nil)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
}
func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) {
t.Parallel()
st := newStore(t)
p, err := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
stage, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1,
})
if err != nil {
t.Fatalf("create stage: %v", err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
path := "/api/webhook/" + p.WebhookSecret
resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
}
if dep.calls != 1 {
t.Fatalf("expected 1 deploy call, got %d", dep.calls)
}
if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" {
t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s",
dep.lastProj, dep.lastStg, dep.lastTag)
}
}
func TestProjectWebhook_ImageMismatchRejected(t *testing.T) {
t.Parallel()
st := newStore(t)
p, err := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatalf("create stage: %v", err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
`{"image":"otheruser/other:dev"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode)
}
if dep.calls != 0 {
t.Errorf("deploy should not have been triggered on image mismatch")
}
}
func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) {
t.Parallel()
st := newStore(t)
p, err := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatalf("create stage: %v", err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
`{"image":"alexei/app:dev-abc"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
}
if dep.calls != 0 {
t.Errorf("expected no deploy call, got %d", dep.calls)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if parsed["deploy"] != false {
t.Errorf("expected deploy=false, got %v", parsed["deploy"])
}
}
func TestProjectWebhook_AutoDeployDisabled(t *testing.T) {
t.Parallel()
st := newStore(t)
p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"})
_, _ = st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1,
})
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
`{"image":"alexei/app:dev-1"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if dep.calls != 0 {
t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls)
}
}
func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) {
t.Parallel()
st := newStore(t)
h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{})
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}")
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
}
func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) {
t.Parallel()
st := newStore(t)
site, err := st.CreateStaticSite(store.StaticSite{
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
Branch: "main", SyncTrigger: "manual", Status: "idle",
})
if err != nil {
t.Fatalf("create site: %v", err)
}
ft := &fakeSiteTriggerer{}
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost,
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ft.calls != 0 {
t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls)
}
}
func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) {
t.Parallel()
st := newStore(t)
site, err := st.CreateStaticSite(store.StaticSite{
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
Branch: "main", SyncTrigger: "push", Status: "idle",
})
if err != nil {
t.Fatalf("create site: %v", err)
}
ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)}
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
r := newRouter(t, h)
resp, body := doJSON(t, r, http.MethodPost,
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
}
// Sync runs in a goroutine — wait for the signal.
<-ft.done
ft.mu.Lock()
calls := ft.calls
ft.mu.Unlock()
if calls != 1 {
t.Errorf("expected 1 sync call, got %d", calls)
}
}
func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) {
t.Parallel()
st := newStore(t)
site, _ := st.CreateStaticSite(store.StaticSite{
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
Branch: "main", SyncTrigger: "push", Status: "idle",
})
ft := &fakeSiteTriggerer{}
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost,
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ft.calls != 0 {
t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls)
}
}
// HMAC enforcement scenarios.
func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) {
t.Parallel()
st := newStore(t)
p, _ := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatal(err)
}
const sig = "deadbeef-signing-secret-1234567890abcdef"
if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil {
t.Fatal(err)
}
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
t.Fatal(err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
body := `{"image":"alexei/app:dev-abc"}`
resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg)
}
if dep.calls != 1 {
t.Errorf("valid signed deploy should fire once, got %d", dep.calls)
}
}
func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) {
t.Parallel()
st := newStore(t)
p, _ := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatal(err)
}
if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil {
t.Fatal(err)
}
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
t.Fatal(err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode)
}
if dep.calls != 0 {
t.Errorf("deploy must not fire when required signature is missing")
}
}
func TestProjectWebhook_HMACPresentButWrong(t *testing.T) {
t.Parallel()
st := newStore(t)
p, _ := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatal(err)
}
if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil {
t.Fatal(err)
}
// Note: require_signature stays false — but a wrong sig must still 401.
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
`{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong signature must 401, got %d", resp.StatusCode)
}
if dep.calls != 0 {
t.Errorf("deploy must not fire on wrong signature")
}
}
func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) {
// require_signature=false AND signing_secret="": unsigned requests pass.
t.Parallel()
st := newStore(t)
p, _ := st.CreateProject(store.Project{
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
})
if _, err := st.CreateStage(store.Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
}); err != nil {
t.Fatal(err)
}
dep := &fakeDeployer{}
h := webhook.NewHandler(st, dep, nil)
r := newRouter(t, h)
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode)
}
if dep.calls != 1 {
t.Errorf("expected 1 deploy, got %d", dep.calls)
}
}
-94
View File
@@ -1,94 +0,0 @@
package webhook
import (
"fmt"
"log/slog"
"path"
"strings"
"github.com/alexei/tinyforge/internal/store"
)
// matchStage finds the first stage of a project whose tag pattern matches the
// given tag. Uses path.Match for glob-style matching (same as the registry poller).
func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) {
stages, err := st.GetStagesByProjectID(projectID)
if err != nil {
return store.Stage{}, false, fmt.Errorf("get stages: %w", err)
}
for _, stage := range stages {
pattern := stage.TagPattern
if pattern == "" {
pattern = "*"
}
matched, err := path.Match(pattern, tag)
if err != nil {
slog.Warn("webhook: invalid tag pattern, skipping stage",
"project", projectID, "stage", stage.Name, "pattern", pattern, "error", err)
continue
}
if matched {
return stage, true, nil
}
}
return store.Stage{}, false, nil
}
// imageMatches reports whether an incoming image reference matches the
// project's stored image. The registry hostname is matched case-insensitively
// (per RFC: registry hostnames are case-insensitive); the path/owner/name are
// matched exactly.
func imageMatches(projectImage, incomingImage string) bool {
if projectImage == incomingImage {
return true
}
pIdx := strings.IndexByte(projectImage, '/')
iIdx := strings.IndexByte(incomingImage, '/')
if pIdx <= 0 || iIdx <= 0 {
return false
}
pHost, pPath := projectImage[:pIdx], projectImage[pIdx:]
iHost, iPath := incomingImage[:iIdx], incomingImage[iIdx:]
return strings.EqualFold(pHost, iHost) && pPath == iPath
}
// siteRefMatches reports whether a Git ref (e.g. "refs/heads/main" or
// "refs/tags/v1.2.3") targets the site's configured branch or tag pattern.
//
// For sync_trigger = "push": the ref must be a heads/<branch> ref whose
// branch name equals site.Branch.
// For sync_trigger = "tag": the ref must be a tags/<tag> ref whose tag name
// matches site.TagPattern via glob semantics.
// Unknown triggers return false (caller should have filtered these out).
func siteRefMatches(site store.StaticSite, ref string) bool {
switch site.SyncTrigger {
case "push":
branch, ok := strings.CutPrefix(ref, "refs/heads/")
if !ok {
return false
}
if site.Branch == "" {
return true
}
return branch == site.Branch
case "tag":
tag, ok := strings.CutPrefix(ref, "refs/tags/")
if !ok {
return false
}
pattern := site.TagPattern
if pattern == "" {
pattern = "*"
}
matched, err := path.Match(pattern, tag)
if err != nil {
return false
}
return matched
default:
return false
}
}
-98
View File
@@ -1,98 +0,0 @@
package webhook
import (
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func TestSiteRefMatches_Push(t *testing.T) {
t.Parallel()
site := store.StaticSite{SyncTrigger: "push", Branch: "main"}
cases := []struct {
ref string
want bool
}{
{"refs/heads/main", true},
{"refs/heads/develop", false},
{"refs/tags/v1.0.0", false},
{"", false},
{"main", false},
}
for _, tc := range cases {
if got := siteRefMatches(site, tc.ref); got != tc.want {
t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want)
}
}
}
func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) {
t.Parallel()
// When Branch is unset, any heads ref should match — tolerates the sites
// table having blank Branch values from legacy rows.
site := store.StaticSite{SyncTrigger: "push"}
if !siteRefMatches(site, "refs/heads/whatever") {
t.Error("expected empty Branch to accept any heads ref")
}
if siteRefMatches(site, "refs/tags/v1") {
t.Error("empty Branch must still reject tag refs")
}
}
func TestSiteRefMatches_Tag(t *testing.T) {
t.Parallel()
site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"}
cases := []struct {
ref string
want bool
}{
{"refs/tags/v1.0.0", true},
{"refs/tags/v2", true},
{"refs/tags/hotfix", false},
{"refs/heads/main", false},
}
for _, tc := range cases {
if got := siteRefMatches(site, tc.ref); got != tc.want {
t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want)
}
}
}
func TestSiteRefMatches_ManualIsIgnored(t *testing.T) {
t.Parallel()
site := store.StaticSite{SyncTrigger: "manual", Branch: "main"}
if siteRefMatches(site, "refs/heads/main") {
t.Error("manual trigger must never match any ref — caller short-circuits")
}
}
func TestParseImageRef(t *testing.T) {
t.Parallel()
cases := []struct {
in string
wantFull string
wantTag string
}{
{"registry.example.com/alexei/app:v1", "alexei/app", "v1"},
{"alexei/app:dev", "alexei/app", "dev"},
{"app", "app", ""},
}
for _, tc := range cases {
got, err := ParseImageRef(tc.in)
if err != nil {
t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err)
continue
}
if got.FullName() != tc.wantFull || got.Tag != tc.wantTag {
t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q",
tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag)
}
}
}
func TestParseImageRef_Empty(t *testing.T) {
t.Parallel()
if _, err := ParseImageRef(""); err == nil {
t.Error("expected error for empty image ref")
}
}