feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 "••••••••"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, "-")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user