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

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

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

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

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

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

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

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
This commit is contained in:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
-236
View File
@@ -1,236 +0,0 @@
package api
import (
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
)
// listDeploys handles GET /api/deploys.
func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
offsetStr := r.URL.Query().Get("offset")
offset := 0
if offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
offset = parsed
}
}
projectID := r.URL.Query().Get("project_id")
stageID := r.URL.Query().Get("stage_id")
deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset)
if err != nil {
slog.Error("failed to list deploys", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, deploys)
}
// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go.
// The new handler supports both SSE streaming and JSON fallback via Accept header.
// inspectRequest is the expected JSON body for POST /api/deploy/inspect.
type inspectRequest struct {
Image string `json:"image"`
}
// inspectResponse is the response body for POST /api/deploy/inspect.
type inspectResponse struct {
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
}
// inspectImage handles POST /api/deploy/inspect.
// Pulls the image and inspects it for EXPOSE ports and healthcheck config.
func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
var req inspectRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
ctx := r.Context()
// Pull the image first so it's available locally for inspection.
// Split image:tag for the pull call.
imageRef, tag := splitImageTag(req.Image)
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
// Try to inspect anyway in case the image is already local.
}
info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil {
slog.Error("failed to inspect image", "image", req.Image, "error", err)
errMsg := "Failed to inspect image. "
if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") {
errMsg += "Docker is not available on this machine. Enter port and project name manually."
} else {
errMsg += "Image may not exist or registry requires authentication."
}
respondError(w, http.StatusBadGateway, errMsg)
return
}
port := docker.ExtractPort(info.ExposedPorts)
respondJSON(w, http.StatusOK, inspectResponse{
Image: req.Image,
Port: port,
Healthcheck: info.Healthcheck,
})
}
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
type quickDeployRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately)
}
// quickDeploy handles POST /api/deploy/quick.
// Creates a project, a default stage, and triggers a deploy in one call.
func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
var req quickDeployRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
if req.Tag == "" {
imageRef, tag := splitImageTag(req.Image)
if tag != "" {
req.Image = imageRef
req.Tag = tag
} else {
req.Tag = "latest"
}
}
if req.Name == "" {
// Derive name from image (without tag).
parts := strings.Split(req.Image, "/")
req.Name = parts[len(parts)-1]
}
// Check for existing projects with the same image.
if !req.Force {
existing, err := s.store.GetProjectsByImage(req.Image)
if err != nil {
slog.Error("failed to check existing projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if len(existing) > 0 {
respondJSON(w, http.StatusConflict, map[string]any{
"message": "A project with this image already exists",
"existing_projects": existing,
})
return
}
}
// Create project.
project, err := s.store.CreateProject(store.Project{
Name: req.Name,
Image: req.Image,
Registry: req.Registry,
Port: req.Port,
Env: "{}",
Volumes: "{}",
})
if err != nil {
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Create default stage.
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
shouldDeploy := true
if req.AutoDeploy != nil {
shouldDeploy = *req.AutoDeploy
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: project.ID,
Name: "dev",
TagPattern: "*",
AutoDeploy: shouldDeploy,
MaxInstances: 1,
EnableProxy: enableProxy,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Only trigger deploy if auto_deploy is enabled.
var deployID string
if shouldDeploy {
deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
if err != nil {
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
}
status := "created"
if shouldDeploy {
status = "deploying"
}
respondJSON(w, http.StatusAccepted, map[string]any{
"project": project,
"stage": stage,
"tag": req.Tag,
"deploy_id": deployID,
"status": status,
})
}
// splitImageTag splits "image:tag" into image and tag parts.
// Returns the full string and empty tag if no colon separator is found.
func splitImageTag(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx != -1 {
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
return ref[:idx], afterColon
}
}
return ref, ""
}
+35 -40
View File
@@ -190,27 +190,34 @@ func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers.
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS
// consumers. Sourced from the containers index now that legacy project/stage
// tables are gone — the workload's name + the container's role + tag is what
// operators see in the UI.
func (s *Server) buildConsumerNameMap() map[string]string {
names := make(map[string]string)
// Instance consumers: "instance:id" -> "project/stage:tag"
projects, _ := s.store.GetAllProjects()
projectNames := make(map[string]string, len(projects))
for _, p := range projects {
projectNames[p.ID] = p.Name
containers, err := s.store.ListContainers(store.ContainerFilter{})
if err != nil {
return names
}
for _, p := range projects {
stages, _ := s.store.GetStagesByProjectID(p.ID)
for _, st := range stages {
rows, _ := s.store.ListContainersByStageID(st.ID)
for _, c := range rows {
names["instance:"+c.ID] = p.Name + "/" + st.Name + ":" + c.ImageTag
workloadNames := make(map[string]string)
for _, c := range containers {
wname, ok := workloadNames[c.WorkloadID]
if !ok {
if w, err := s.store.GetWorkloadByID(c.WorkloadID); err == nil {
wname = w.Name
}
workloadNames[c.WorkloadID] = wname
}
label := wname
if c.Role != "" {
label = label + "/" + c.Role
}
if c.ImageTag != "" {
label = label + ":" + c.ImageTag
}
names["instance:"+c.ID] = label
}
return names
}
@@ -343,38 +350,26 @@ func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) {
})
}
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers.
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID"
// for every running container that has a proxy route configured. Sourced
// directly from the containers index — the workload-first cutover dropped
// the per-stage enable_proxy toggle in favour of "if a proxy route ID
// exists, the workload wanted a route."
func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]string, error) {
expected := make(map[string]string)
// Instances with proxy enabled.
projects, err := s.store.GetAllProjects()
containers, err := s.store.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("get projects: %w", err)
return nil, fmt.Errorf("list containers: %w", err)
}
for _, p := range projects {
stages, err := s.store.GetStagesByProjectID(p.ID)
if err != nil {
slog.Warn("dns: failed to get stages", "project_id", p.ID, "error", err)
for _, c := range containers {
if c.Subdomain == "" || c.State != "running" {
continue
}
for _, st := range stages {
if !st.EnableProxy {
continue
}
rows, err := s.store.ListContainersByStageID(st.ID)
if err != nil {
slog.Warn("dns: failed to get containers", "stage_id", st.ID, "error", err)
continue
}
for _, c := range rows {
if c.NpmProxyID > 0 && c.Subdomain != "" && c.State == "running" {
fqdn := c.Subdomain + "." + settings.Domain
expected[fqdn] = "instance:" + c.ID
}
}
if c.NpmProxyID == 0 && c.ProxyRouteID == "" {
continue
}
fqdn := c.Subdomain + "." + settings.Domain
expected[fqdn] = "instance:" + c.ID
}
return expected, nil
}
+59 -128
View File
@@ -3,7 +3,6 @@ package api
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -14,17 +13,15 @@ import (
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// Limits and constants for the log endpoints.
const (
defaultLogTail = 200
maxLogTail = 5000
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
defaultLogTail = 200
maxLogTail = 5000
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
logHeartbeatPeriod = 20 * time.Second
)
@@ -37,82 +34,8 @@ var (
ctlBytePattern = regexp.MustCompile(`[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]`)
)
// listProjectImages handles GET /api/projects/{id}/images.
// Returns all local Docker images matching the project's image reference.
func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if s.docker == nil || project.Image == "" {
respondJSON(w, http.StatusOK, []any{})
return
}
images, err := s.docker.ListImagesByRef(r.Context(), project.Image)
if err != nil {
slog.Warn("list project images", "project", project.Name, "error", err)
respondJSON(w, http.StatusOK, []any{})
return
}
respondJSON(w, http.StatusOK, images)
}
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
// Streams container logs via SSE. {iid} is the container row ID. Ownership is
// verified by joining through workload + stage so an attacker cannot stream
// logs for a foreign container by guessing IDs under the wrong project URL.
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
containerRowID := chi.URLParam(r, "iid")
c, err := s.store.GetContainerByID(containerRowID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return
}
slog.Error("failed to get container", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
wl, err := s.store.GetWorkloadByID(c.WorkloadID)
if err != nil {
respondNotFound(w, "container")
return
}
stage, err := s.store.GetStageByID(stageID)
if err != nil || stage.ProjectID != projectID {
respondNotFound(w, "container")
return
}
if wl.Kind != string(store.WorkloadKindProject) || wl.RefID != projectID || c.Role != stage.Name {
respondNotFound(w, "container")
return
}
if c.ContainerID == "" {
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
return
}
s.streamLogsForContainer(w, r, c.ContainerID)
}
// streamLogsForContainer streams logs for an arbitrary container ID using the
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site)
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (workload-container)
// should validate ownership and then delegate here.
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
if s.docker == nil {
@@ -255,11 +178,7 @@ func sanitizeDockerLogLine(line string) string {
// by any container, computed in a single DB pass against the normalized
// containers index. Returning an error (rather than swallowing) prevents
// prune logic from treating a transient DB failure as "nothing is active".
func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string]bool, error) {
// `projects` is unused now — kept in the signature for back-compat with
// callers that already happen to have the slice. The image_ref column
// holds the full "image:tag" string written by the deployer.
_ = projects
func buildActiveImagesSet(st *store.Store) (map[string]bool, error) {
containers, err := st.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
@@ -274,8 +193,43 @@ func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string
return active, nil
}
// unusedImageStats handles GET /api/docker/unused-images.
// Returns the total size of unused project images and whether the threshold is exceeded.
// workloadImageBases returns the set of "image" strings (no tag) that
// some workload currently mounts to, derived from container.image_ref.
// This replaces the legacy "list all projects → projects[].Image" view
// after the workload-first cutover.
func workloadImageBases(st *store.Store) (map[string]bool, error) {
containers, err := st.ListContainers(store.ContainerFilter{})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
bases := make(map[string]bool, len(containers))
for _, c := range containers {
if c.ImageRef == "" {
continue
}
ref, _ := splitImageTag(c.ImageRef)
if ref != "" {
bases[ref] = true
}
}
return bases, nil
}
// splitImageTag splits "image:tag" into image and tag parts. Returns the
// full string and empty tag if no colon separator is found. Inlined here
// because the legacy deploys.go that owned it was removed.
func splitImageTag(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx != -1 {
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
return ref[:idx], afterColon
}
}
return ref, ""
}
// unusedImageStats handles GET /api/docker/unused-images. Returns the total
// size of unused workload images and whether the threshold is exceeded.
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondJSON(w, http.StatusOK, map[string]any{
@@ -291,32 +245,25 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
return
}
projects, err := s.store.GetAllProjects()
imageBases, err := workloadImageBases(s.store)
if err != nil {
slog.Error("unused images: list projects", "error", err)
slog.Error("unused images: list workload images", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Build set of active image refs in one DB pass instead of N×K queries.
// A flaky read here previously masqueraded as "no images are active",
// which on the prune endpoint would have deleted *running* images.
activeImages, err := buildActiveImagesSet(s.store, projects)
activeImages, err := buildActiveImagesSet(s.store)
if err != nil {
slog.Error("unused images: build active set", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Sum unused image sizes.
ctx := r.Context()
var totalSize int64
var count int
for _, p := range projects {
if p.Image == "" {
continue
}
images, err := s.docker.ListImagesByRef(ctx, p.Image)
for base := range imageBases {
images, err := s.docker.ListImagesByRef(ctx, base)
if err != nil {
continue
}
@@ -339,69 +286,53 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
})
}
// pruneImages handles POST /api/docker/prune-images.
// Only removes images that belong to Tinyforge projects (not all system images).
// pruneImages handles POST /api/docker/prune-images. Only removes images that
// some workload references (via container.image_ref), never arbitrary host
// images.
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
// Collect all image references from our projects.
projects, err := s.store.GetAllProjects()
imageBases, err := workloadImageBases(s.store)
if err != nil {
slog.Error("prune: failed to list projects", "error", err)
slog.Error("prune: list workload images", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Build a set of image refs used by active instances. Bail out on error
// — silently treating a DB blip as "no active images" would prune
// images currently in use by running containers.
activeImages, err := buildActiveImagesSet(s.store, projects)
activeImages, err := buildActiveImagesSet(s.store)
if err != nil {
slog.Error("prune: build active set", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Collect all unique image bases from projects (without tags).
projectImages := make(map[string]bool)
for _, p := range projects {
if p.Image != "" {
projectImages[p.Image] = true
}
}
if len(projectImages) == 0 {
if len(imageBases) == 0 {
respondJSON(w, http.StatusOK, map[string]any{
"images_removed": 0,
"space_reclaimed_mb": 0,
"message": "No project images to clean up",
"message": "No workload images to clean up",
})
return
}
// List all local Docker images and find ones matching our projects but not actively used.
ctx := r.Context()
removed := 0
var reclaimedBytes int64
for imageBase := range projectImages {
// List all tags for this image.
images, err := s.docker.ListImagesByRef(ctx, imageBase)
for base := range imageBases {
images, err := s.docker.ListImagesByRef(ctx, base)
if err != nil {
slog.Warn("prune: list images", "image", imageBase, "error", err)
slog.Warn("prune: list images", "image", base, "error", err)
continue
}
for _, img := range images {
// Skip images that are actively used by running instances.
if activeImages[img.Ref] {
continue
}
// Remove unused image.
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
continue
+4 -8
View File
@@ -239,17 +239,13 @@ func (s *Server) proxyHealth(ctx context.Context) map[string]any {
return out
}
// managedRouteCount returns the number of proxy routes Tinyforge manages
// (Docker instances + static sites combined). The domain argument doesn't
// managedRouteCount returns the number of proxy routes Tinyforge manages,
// reading from the unified containers index. The domain argument doesn't
// affect the count so we pass an empty string to skip FQDN rendering.
func (s *Server) managedRouteCount() (int, error) {
instanceRoutes, err := s.store.ListProxyRoutes("")
routes, err := s.store.ListProxyRoutes("")
if err != nil {
return 0, err
}
siteRoutes, err := s.store.ListStaticSiteProxyRoutes("")
if err != nil {
return 0, err
}
return len(instanceRoutes) + len(siteRoutes), nil
return len(routes), nil
}
-293
View File
@@ -1,293 +0,0 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
// Reads the normalized container index — the legacy `instances` table is gone.
// JSON shape stays Container-shaped (id, container_id, image_tag, subdomain,
// state, port, etc.), so the frontend type may show some renamed fields
// (status→state, last_alive_at→last_seen_at).
func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
containers, err := s.store.ListContainersByStageID(stageID)
if err != nil {
slog.Error("failed to list containers", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Reconcile container state with Docker's actual state — covers the
// case where a container was killed externally between deployer writes
// and the next reconciler tick.
ctx := r.Context()
for i, c := range containers {
if c.ContainerID == "" || c.State == "removing" {
continue
}
running, err := s.docker.IsContainerRunning(ctx, c.ContainerID)
if err != nil {
continue
}
actual := "stopped"
if running {
actual = "running"
}
if c.State != actual {
containers[i].State = actual
_ = s.store.UpdateContainerState(c.ID, actual)
}
}
respondJSON(w, http.StatusOK, containers)
}
// deployRequest is the expected JSON body for triggering a deploy.
type deployRequest struct {
ImageTag string `json:"image_tag"`
}
// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances.
func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req deployRequest
if !decodeJSON(w, r, &req) {
return
}
if req.ImageTag == "" {
respondError(w, http.StatusBadRequest, "image_tag is required")
return
}
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
if err != nil {
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusAccepted, map[string]string{
"status": "deploying",
"deploy_id": deployID,
"project_id": projectID,
"stage_id": stageID,
"image_tag": req.ImageTag,
})
}
// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}.
// {iid} is the container row ID (same UUID as the legacy instance ID).
// Verifies that the container belongs to the project + stage in the URL —
// without this check, a stale URL could delete an unrelated stack/site row.
func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
c, ok := s.resolveAndAuthorizeInstance(w, r)
if !ok {
return
}
id := c.ID
// Remove the Docker container if it has one.
if c.ContainerID != "" {
if err := s.docker.RemoveContainer(r.Context(), c.ContainerID, true); err != nil {
slog.Error("remove container", "container_id", c.ContainerID, "error", err)
}
}
// Delete proxy route if it has one.
if c.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(r.Context(), c.ProxyRouteID); err != nil {
slog.Warn("delete proxy route on container removal", "route_id", c.ProxyRouteID, "error", err)
}
}
// Delete container row.
if err := s.store.DeleteContainer(id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete container")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop.
func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "stop")
}
// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start.
func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "start")
}
// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart.
func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) {
s.controlInstance(w, r, "restart")
}
// controlInstance performs a stop/start/restart action on a container.
// The container's ownership of the URL-provided project + stage is verified
// before any Docker call — see resolveAndAuthorizeInstance for rationale.
func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) {
c, ok := s.resolveAndAuthorizeInstance(w, r)
if !ok {
return
}
id := c.ID
if c.ContainerID == "" {
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
return
}
ctx := r.Context()
var controlErr error
var newState string
switch action {
case "stop":
controlErr = s.docker.StopContainer(ctx, c.ContainerID, 10)
newState = "stopped"
case "start":
controlErr = s.docker.StartContainer(ctx, c.ContainerID)
newState = "running"
case "restart":
controlErr = s.docker.RestartContainer(ctx, c.ContainerID, 10)
newState = "running"
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action))
return
}
if controlErr != nil {
slog.Error("failed to control container", "action", action, "id", id, "error", controlErr)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if err := s.store.UpdateContainerState(id, newState); err != nil {
slog.Error("update container state", "id", id, "state", newState, "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{
"instance_id": id,
"action": action,
"status": newState,
})
}
// DeployTriggerer is the interface for triggering deployments. The legacy
// project/stage methods continue to drive image-tag CI promotions; the
// plugin methods (DispatchPlugin / DispatchTeardown / DispatchReconcile)
// route through the unified Source registry. Both surfaces are kept on
// one interface so the API layer holds a single deployer reference and
// the type assertion in hooks.go / workloads_plugin.go is replaced with
// compile-time checking.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error
DispatchTeardown(ctx context.Context, w plugin.Workload) error
DispatchReconcile(ctx context.Context, w plugin.Workload) error
PluginDeps() plugin.Deps
}
// resolveAndAuthorizeInstance loads the container row identified by {iid} and
// verifies it actually belongs to the project + stage in the URL path.
// Without this, a stale or hand-crafted URL like
//
// DELETE /api/projects/<projectA>/stages/<stageA>/instances/<rowOfStackB>
//
// would happily delete an unrelated stack/site container — admin-only doesn't
// excuse the cross-project bypass. Returns the container on success or
// nothing (with the response already written) on failure.
func (s *Server) resolveAndAuthorizeInstance(w http.ResponseWriter, r *http.Request) (store.Container, bool) {
projectID := chi.URLParam(r, "id")
stageName := ""
if stageID := chi.URLParam(r, "stage"); stageID != "" {
st, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return store.Container{}, false
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return store.Container{}, false
}
if st.ProjectID != projectID {
respondNotFound(w, "stage")
return store.Container{}, false
}
stageName = st.Name
}
id := chi.URLParam(r, "iid")
c, err := s.store.GetContainerByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return store.Container{}, false
}
slog.Error("failed to get container", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return store.Container{}, false
}
w2, err := s.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
if err != nil {
respondNotFound(w, "container")
return store.Container{}, false
}
if c.WorkloadID != w2.ID {
respondNotFound(w, "container")
return store.Container{}, false
}
if stageName != "" && c.Role != stageName {
respondNotFound(w, "container")
return store.Container{}, false
}
return c, true
}
+5 -25
View File
@@ -30,14 +30,12 @@ func logging(next http.Handler) http.Handler {
}
// redactPath strips secrets from URL paths that carry them in segments.
// Only the canonical /api/webhook/triggers/{secret} surface remains after
// the hard cutover.
func redactPath(path string) string {
const projectPrefix = "/api/webhook/"
const sitePrefix = "/api/webhook/sites/"
switch {
case strings.HasPrefix(path, sitePrefix):
return sitePrefix + "***"
case strings.HasPrefix(path, projectPrefix):
return projectPrefix + "***"
const triggerPrefix = "/api/webhook/triggers/"
if strings.HasPrefix(path, triggerPrefix) {
return triggerPrefix + "***"
}
return path
}
@@ -161,24 +159,6 @@ func jsonContentType(next http.Handler) http.Handler {
})
}
// deprecated marks responses with RFC-8594-style headers so API consumers
// can detect that an endpoint is on its way out. The Workload-first
// refactor is migrating away from /api/projects, /api/stages,
// /api/static_sites, and /api/stacks toward /api/workloads; this signals
// it to integrators without breaking them. Date is the operator-facing
// sunset hint, not a hard switch.
func deprecated(replacement string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
if replacement != "" {
w.Header().Set("Link", `<`+replacement+`>; rel="successor-version"`)
}
next.ServeHTTP(w, r)
})
}
}
// rateLimitMiddleware wraps a handler with per-IP rate limiting using the
// supplied limiter. Requests over the limit get 429.
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
+5 -302
View File
@@ -1,24 +1,17 @@
package api
// Outgoing-webhook signing-secret + send-test endpoints. There are four
// tiers — settings, project, stage, site — each exposing the same three
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
// event. Returning a 200 from "send test" doesn't mean the receiver
// processed the event correctly — only that it answered with 2xx. The UI
// surfaces the receiver's status code + body preview so operators can
// distinguish "wired" from "wired and accepted".
// Outgoing-webhook signing-secret + send-test endpoints. After the hard
// cutover only the settings tier survives at the API surface; per-workload
// notification settings live on the workload row itself and are accessed
// via the workload endpoints.
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// notificationSecretResponse is what the GET / regenerate endpoints return.
@@ -92,8 +85,7 @@ func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *ht
// settingsNotificationTest handles POST /api/settings/notification-test.
// Sends a synthetic test event to the global webhook URL using the global
// secret. No tier resolution — that's the whole point: each tier's test
// button proves *that* tier is wired correctly.
// secret.
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -112,292 +104,3 @@ func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request
)
respondJSON(w, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// Project tier
// ---------------------------------------------------------------------------
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureProjectNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("get project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
slog.Error("regenerate project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("project notification secret rotated", "project", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveProjectTestTarget(project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
// resolution but without a stage in scope. Used by the project-level test
// button so the operator sees exactly what a project-only event would do.
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Stage tier
// ---------------------------------------------------------------------------
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
secret, err := s.store.EnsureStageNotificationSecret(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("get stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("stage notification secret rotated", "stage", stageID)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
stage, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
project, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
// Reuse the production resolver so the test button exercises the exact
// fall-through logic a real deploy would.
url, secret, tier := resolveDeployTarget(stage, project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, stage.Name),
)
respondJSON(w, http.StatusOK, result)
}
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
// than imported to avoid an api → deployer dependency cycle and to keep the
// test-endpoint code self-contained. If divergence becomes a risk we can
// move this into a shared internal/notify subpackage.
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
if stage.NotificationURL != "" {
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
}
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Static-site tier
// ---------------------------------------------------------------------------
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("get static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
slog.Error("regenerate static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("static site notification secret rotated", "site", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveSiteTestTarget(site, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(site.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
if site.NotificationURL != "" {
return site.NotificationURL, site.NotificationSecret, notify.TierSite
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
-225
View File
@@ -1,225 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// projectRequest is the expected JSON body for creating/updating a project.
type projectRequest struct {
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"`
Volumes string `json:"volumes"`
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
NotificationURL *string `json:"notification_url,omitempty"`
}
// listProjects handles GET /api/projects.
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.GetAllProjects()
if err != nil {
slog.Error("failed to list projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, projects)
}
// createProject handles POST /api/projects.
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
var req projectRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
if req.Env == "" {
req.Env = "{}"
}
if req.Volumes == "" {
req.Volumes = "{}"
}
npmAccessListID := 0
if req.NpmAccessListID != nil {
npmAccessListID = *req.NpmAccessListID
}
project, err := s.store.CreateProject(store.Project{
Name: req.Name,
Registry: req.Registry,
Image: req.Image,
Port: req.Port,
Healthcheck: req.Healthcheck,
Env: req.Env,
Volumes: req.Volumes,
NpmAccessListID: npmAccessListID,
})
if err != nil {
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project created: " + project.Name,
},
})
respondJSON(w, http.StatusCreated, project)
}
// getProject handles GET /api/projects/{id}.
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Also fetch stages for this project.
stages, err := s.store.GetStagesByProjectID(id)
if err != nil {
slog.Error("failed to get stages", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"project": project,
"stages": stages,
})
}
// updateProject handles PUT /api/projects/{id}.
func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req projectRequest
if !decodeJSON(w, r, &req) {
return
}
// Apply updates to existing project, preserving fields not provided.
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.Image != "" {
updated.Image = req.Image
}
updated.Registry = req.Registry
updated.Port = req.Port
updated.Healthcheck = req.Healthcheck
if req.Env != "" {
updated.Env = req.Env
}
if req.Volumes != "" {
updated.Volumes = req.Volumes
}
if req.NpmAccessListID != nil {
updated.NpmAccessListID = *req.NpmAccessListID
}
if req.NotificationURL != nil {
updated.NotificationURL = *req.NotificationURL
}
if err := s.store.UpdateProject(updated); err != nil {
slog.Error("failed to update project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
// deleteProject handles DELETE /api/projects/{id}.
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Clean up Docker containers and proxy routes before deleting the project.
ctx := r.Context()
stages, _ := s.store.GetStagesByProjectID(id)
for _, stage := range stages {
rows, _ := s.store.ListContainersByStageID(stage.ID)
for _, c := range rows {
if c.ContainerID != "" {
if err := s.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
slog.Warn("delete project: remove container", "container", c.ContainerID, "error", err)
}
}
if c.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(ctx, c.ProxyRouteID); err != nil {
slog.Warn("delete project: delete proxy route", "route", c.ProxyRouteID, "error", err)
}
}
}
}
if err := s.store.DeleteProject(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to delete project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project deleted: " + id,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+5 -13
View File
@@ -6,9 +6,9 @@ import (
"sort"
)
// listProxyRoutes handles GET /api/proxies.
// Returns proxy routes from both Docker instances and static sites,
// merged and sorted by domain.
// listProxyRoutes handles GET /api/proxies. Returns proxy routes derived
// from the containers index — the legacy static-site / project split is
// gone; any workload whose container carries a proxy route ID is listed.
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -17,21 +17,13 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
routes, err := s.store.ListProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list instance proxy routes", "error", err)
slog.Error("failed to list proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list static site proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
routes := append(instanceRoutes, siteRoutes...)
sort.SliceStable(routes, func(i, j int) bool {
if routes[i].Domain == routes[j].Domain {
return routes[i].ProjectName < routes[j].ProjectName
+38 -209
View File
@@ -16,43 +16,49 @@ import (
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/stack"
"github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// DNSProviderChangedFunc is called when DNS settings change so the caller can
// update the provider on the deployer.
type DNSProviderChangedFunc func(provider dns.Provider)
// PluginDispatcher is the subset of the deployer the API layer uses for the
// plugin-native dispatch surface (generic-hooks endpoint + workload teardown
// + future surfaces). Defined here so the API does not import the deployer
// package directly.
type PluginDispatcher interface {
webhook.PluginDispatcher
DispatchTeardown(ctx context.Context, w plugin.Workload) error
}
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
docker *docker.Client
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
proxyProvider proxy.Provider
deployer DeployTriggerer
deployer PluginDispatcher
notifier *notify.Notifier
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
localAuth *auth.LocalAuth
oidcProvider *auth.OIDCProvider
staleScanner *stale.Scanner
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
localAuth *auth.LocalAuth
oidcProvider *auth.OIDCProvider
staleScanner *stale.Scanner
dnsProviderMu sync.RWMutex
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
staticSiteManager *staticsite.Manager
stackManager *stack.Manager
backupEngine *backup.Engine
sseGate *sseGate
logScanReloader LogScanReloader
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
backupEngine *backup.Engine
sseGate *sseGate
logScanReloader LogScanReloader
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
}
@@ -63,7 +69,7 @@ func NewServer(
dockerClient *docker.Client,
npmClient *npm.Client,
proxyProvider proxy.Provider,
deployer DeployTriggerer,
deployer PluginDispatcher,
notifier *notify.Notifier,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
@@ -94,16 +100,6 @@ func NewServer(
return s
}
// SetStaticSiteManager sets the static site manager on the server.
func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
s.staticSiteManager = mgr
}
// SetStackManager sets the docker-compose stack manager on the server.
func (s *Server) SetStackManager(mgr *stack.Manager) {
s.stackManager = mgr
}
// SetStaleScanner sets the stale scanner on the server.
// Called after both the API server and scanner are initialized.
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
@@ -218,12 +214,7 @@ func (s *Server) Router() chi.Router {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.localAuth))
// Plugin registry inspection + unified ingress (Workload refactor).
// /hooks/kinds is informational and visible to any authenticated
// caller. /hooks/generic dispatches deploys and is admin-gated —
// vendor-specific webhooks (with their own per-target HMAC
// secrets) live under /webhook/* and remain the only ingress
// reachable by external CI systems until Phase 5 consolidates them.
// Plugin registry inspection + unified ingress.
r.Get("/hooks/kinds", s.listHookKinds)
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
@@ -234,134 +225,6 @@ func (s *Server) Router() chi.Router {
r.Post("/auth/logout", s.logout)
r.Get("/proxies", s.listProxyRoutes)
r.Get("/docker/unused-images", s.unusedImageStats)
// Legacy project/stage/site/stack endpoints carry a Deprecation
// header pointing at /api/workloads. Functional behavior is
// unchanged until the hard cutover removes them.
r.With(deprecated("/api/workloads")).Get("/projects", s.listProjects)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
r.Get("/stages/{stage}/env", s.listStageEnv)
r.Get("/stages/{stage}/instances", s.listInstances)
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory)
r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
r.Get("/images", s.listProjectImages)
r.Get("/volumes", s.listVolumes)
r.Get("/volumes/{volId}/browse", s.browseVolume)
r.Get("/volumes/{volId}/download", s.downloadVolume)
// Admin-only project mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject)
// Per-project webhook URL management.
r.Get("/webhook", s.getProjectWebhook)
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
// Inbound HMAC signing — secret rotation + enforcement toggle.
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
// Per-project outgoing-webhook signing & test.
r.Get("/notification-secret", s.getProjectNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
r.Post("/notification-test", s.projectNotificationTest)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Per-stage outgoing-webhook signing & test.
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
// Stage env override endpoints.
r.Post("/stages/{stage}/env", s.createStageEnv)
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
// Instance endpoints.
r.Post("/stages/{stage}/instances", s.deployInstance)
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
// Instance control endpoints.
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
// Volume endpoints.
r.Post("/volumes", s.createVolume)
r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume)
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
})
})
// Stacks (docker-compose).
r.With(deprecated("/api/workloads?kind=plugin&source_kind=compose")).Get("/stacks", s.listStacks)
r.Route("/stacks/{id}", func(r chi.Router) {
r.Get("/", s.getStack)
r.Get("/revisions", s.listStackRevisions)
r.Get("/revisions/{revId}", s.getStackRevision)
r.Get("/services", s.getStackServices)
r.Get("/logs", s.getStackLogs)
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateStack)
r.Delete("/", s.deleteStack)
r.Post("/revisions", s.createStackRevision)
r.Post("/rollback/{revId}", s.rollbackStack)
r.Post("/stop", s.stopStack)
r.Post("/start", s.startStack)
})
})
r.With(auth.AdminOnly).Post("/stacks", s.createStack)
// Static sites.
r.With(deprecated("/api/workloads?kind=plugin&source_kind=static")).Get("/sites", s.listStaticSites)
r.Route("/sites/{id}", func(r chi.Router) {
r.Get("/", s.getStaticSite)
r.Get("/secrets", s.listStaticSiteSecrets)
r.Get("/storage", s.getStaticSiteStorage)
r.Get("/logs", s.streamStaticSiteLogs)
r.Get("/stats", s.getStaticSiteStats)
r.Get("/stats/history", s.getStaticSiteStatsHistory)
// Admin-only mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateStaticSite)
r.Delete("/", s.deleteStaticSite)
r.Post("/deploy", s.deployStaticSite)
r.Post("/stop", s.stopStaticSite)
r.Post("/start", s.startStaticSite)
r.Get("/webhook", s.getStaticSiteWebhook)
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
// Per-site outgoing-webhook signing & test.
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
r.Post("/notification-test", s.staticSiteNotificationTest)
r.Post("/secrets", s.createStaticSiteSecret)
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
})
})
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
r.Get("/events", s.streamEvents)
r.Get("/events/log", s.listEventLog)
r.Get("/events/log/stats", s.getEventLogStats)
@@ -388,12 +251,9 @@ func (s *Server) Router() chi.Router {
// Stale container endpoints (read).
r.Get("/containers/stale", s.listStaleContainers)
// Workload-shaped endpoints (the unifying layer over project /
// stack / site). Read endpoints are open to any authenticated
// user; create / update / deploy mutate state and are admin-gated.
// Plugin-native workloads (source_kind + trigger_kind set) are
// created here; legacy project / stack / site mutations remain at
// their dedicated endpoints during the cutover.
// Workload-shaped endpoints the canonical surface after the
// hard cutover. Reads open to any authenticated user; mutations
// admin-gated.
r.Get("/workloads", s.listWorkloads)
r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload)
r.Route("/workloads/{id}", func(r chi.Router) {
@@ -405,22 +265,17 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
// Per-workload env vars (analog of legacy stage_env).
// Listing is open to authenticated readers; mutations are
// admin-gated. Encrypted values are write-only after store.
// Per-workload env vars. Listing open to authenticated readers;
// mutations admin-gated. Encrypted values are write-only after store.
r.Get("/env", s.listWorkloadEnv)
r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv)
r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv)
// Per-workload inbound webhook URL: rotate the secret + fetch
// the canonical URL. Mirrors the project / site webhook UX.
r.With(auth.AdminOnly).Get("/webhook", s.getWorkloadWebhook)
r.With(auth.AdminOnly).Post("/webhook/regenerate", s.regenerateWorkloadWebhook)
// Per-workload inbound webhook URL handlers were dropped in
// the hard legacy cutover; inbound webhooks are now first-
// class Triggers reachable via /api/triggers/{id}/webhook.
// Per-workload volume mounts (analog of legacy project volumes).
// Reads are open to authenticated users; mutations admin-gated.
// Source/target paths are validated for traversal safety here;
// host-path allow-listing happens at deploy time.
// Per-workload volume mounts.
r.Get("/volumes", s.listWorkloadVolumes)
r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume)
r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume)
@@ -432,8 +287,7 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
// Trigger bindings on this workload — the symmetric view
// of /triggers/{id}/bindings keyed on the workload side
// so the workload detail page is one round-trip.
// of /triggers/{id}/bindings keyed on the workload side.
r.Get("/triggers", s.listBindingsForWorkload)
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
})
@@ -453,10 +307,7 @@ func (s *Server) Router() chi.Router {
})
// First-class Triggers (redeploy signal sources). One trigger
// (registry / git / webhook / manual / schedule / log_scan)
// fans out to many workloads via workload_trigger_bindings.
// Reads are open to authenticated users; mutations + secret
// rotation are admin-gated.
r.Get("/triggers", s.listTriggers)
r.Get("/triggers/{id}", s.getTrigger)
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
@@ -472,10 +323,7 @@ func (s *Server) Router() chi.Router {
r.Delete("/bindings/{bid}", s.deleteBinding)
})
// Event triggers: filter+action rules over the event_log
// stream. Read endpoints are available to any authenticated
// user; mutations + test-dispatch are admin-gated since they
// can fire arbitrary outbound webhooks.
// Event triggers: filter+action rules over the event_log stream.
r.Get("/event-triggers", s.listEventTriggers)
r.Get("/event-triggers/{id}", s.getEventTrigger)
r.Group(func(r chi.Router) {
@@ -486,11 +334,7 @@ func (s *Server) Router() chi.Router {
r.Post("/event-triggers/{id}/test", s.testEventTrigger)
})
// Log-scan rules: regex patterns the scanner manager
// applies to container log lines. Read endpoints are
// available to any authenticated user; mutations are
// admin-gated since they can change global observability
// behavior across every workload.
// Log-scan rules.
r.Get("/log-scan-rules", s.listLogScanRules)
r.Get("/log-scan-rules/stats", s.getLogScanStats)
r.Get("/log-scan-rules/{id}", s.getLogScanRule)
@@ -512,7 +356,7 @@ func (s *Server) Router() chi.Router {
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Config export (reveals project/infra details).
// Config export (reveals registry/global details).
r.Get("/config/export", s.exportConfig)
// Event log management.
@@ -528,21 +372,6 @@ func (s *Server) Router() chi.Router {
r.Put("/auth/users/{uid}/password", s.changePassword)
r.Delete("/auth/users/{uid}", s.deleteUser)
// Project creation.
r.Post("/projects", s.createProject)
// Static site creation and tools.
r.Post("/sites", s.createStaticSite)
r.Post("/sites/test-connection", s.testStaticSiteConnection)
r.Post("/sites/branches", s.listStaticSiteBranches)
r.Post("/sites/tree", s.listStaticSiteTree)
r.Post("/sites/detect-provider", s.detectStaticSiteProvider)
r.Post("/sites/repos", s.listStaticSiteRepos)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry creation.
r.Post("/registries", s.createRegistry)
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"strconv"
"github.com/alexei/tinyforge/internal/store"
)
// generateWebhookSecret is a one-line bridge to store.GenerateWebhookSecret
// so the api handlers and the store CRUD share one secret-generation
// path — no panic-vs-UUID-fallback divergence.
func generateWebhookSecret() string { return store.GenerateWebhookSecret() }
// webhookURLResponse is the common payload returned by every webhook
// endpoint. Clients never see raw secrets except at issue/rotate time via
// these fields; the URL shape is "/api/webhook/..." so callers can prepend
// their own origin.
type webhookURLResponse struct {
WebhookURL string `json:"webhook_url"`
WebhookSecret string `json:"webhook_secret"`
HasSigningSecret bool `json:"has_signing_secret"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// signingSecretResponse is returned when a signing secret is issued or rotated.
type signingSecretResponse struct {
SigningSecret string `json:"signing_secret"`
}
// parseLimit clamps a query-string limit to [1, max], falling back to def.
func parseLimit(raw string, def, max int) int {
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > max {
return max
}
return n
}
-138
View File
@@ -2,147 +2,14 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// streamDeployLogs handles GET /api/deploys/{id}/logs.
// It supports both SSE streaming and JSON fallback based on the Accept header.
//
// SSE mode (Accept: text/event-stream):
//
// Streams deploy log events in real-time. Existing logs are sent first,
// then new logs are pushed as they arrive via the event bus.
//
// JSON mode (default):
//
// Returns all existing deploy logs as a JSON array.
func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
deployID := chi.URLParam(r, "id")
// Verify deploy exists.
deploy, err := s.store.GetDeployByID(deployID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "deploy")
return
}
slog.Error("failed to get deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// JSON fallback: return existing logs as array.
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/event-stream") {
logs, err := s.store.GetDeployLogs(deployID)
if err != nil {
slog.Error("failed to get deploy logs", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, logs)
return
}
// SSE mode.
release, ok := acquireSSESlot(w, s.sseGate)
if !ok {
return
}
defer release()
flusher, ok := w.(http.Flusher)
if !ok {
respondError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Send existing logs first.
existingLogs, err := s.store.GetDeployLogs(deployID)
if err != nil {
slog.Error("get existing deploy logs", "error", err)
} else {
for _, entry := range existingLogs {
writeSSE(w, flusher, events.Event{
Type: events.EventDeployLog,
Payload: events.DeployLogPayload{
DeployID: deployID,
Message: entry.Message,
Level: entry.Level,
},
})
}
}
// If deploy is already finished, send completion and close.
if isTerminalStatus(deploy.Status) {
writeSSE(w, flusher, events.Event{
Type: events.EventDeployStatus,
Payload: events.DeployStatusPayload{
DeployID: deployID,
ProjectID: deploy.ProjectID,
StageID: deploy.StageID,
ImageTag: deploy.ImageTag,
Status: deploy.Status,
Error: deploy.Error,
},
})
return
}
// Subscribe to new deploy log events for this deploy.
sub := s.eventBus.Subscribe(func(evt events.Event) bool {
switch payload := evt.Payload.(type) {
case events.DeployLogPayload:
return payload.DeployID == deployID
case events.DeployStatusPayload:
return payload.DeployID == deployID
default:
return false
}
})
defer s.eventBus.Unsubscribe(sub)
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case evt, ok := <-sub:
if !ok {
return
}
writeSSE(w, flusher, evt)
// Close stream when deploy reaches terminal status.
if evt.Type == events.EventDeployStatus {
if payload, ok := evt.Payload.(events.DeployStatusPayload); ok {
if isTerminalStatus(payload.Status) {
return
}
}
}
}
}
}
// streamEvents handles GET /api/events.
// It streams instance status changes and deploy status changes via SSE.
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
@@ -203,8 +70,3 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
// isTerminalStatus returns true if the deploy status is final.
func isTerminalStatus(status string) bool {
return store.IsTerminalDeployStatus(status)
}
-285
View File
@@ -1,285 +0,0 @@
package api
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) {
stacks, err := s.store.GetAllStacks()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list stacks")
return
}
respondJSON(w, http.StatusOK, stacks)
}
func (s *Server) getStack(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
st, err := s.store.GetStackByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stack")
return
}
respondJSON(w, http.StatusOK, st)
}
// ── Create ──────────────────────────────────────────────────────────
type createStackRequest struct {
Name string `json:"name"`
Description string `json:"description"`
YAML string `json:"yaml"`
Deploy bool `json:"deploy"` // if true, deploy immediately after create
}
func (s *Server) createStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available (docker compose missing?)")
return
}
var req createStackRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.YAML == "" {
respondError(w, http.StatusBadRequest, "name and yaml are required")
return
}
author := authorFromRequest(r)
ctx := r.Context()
st, rev, err := s.stackManager.Create(ctx, req.Name, req.Description, req.YAML, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Deploy {
// Deploy asynchronously so the client gets a fast response.
go func(stackID, revID string) {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
_ = s.stackManager.Deploy(bgCtx, stackID, revID)
}(st.ID, rev.ID)
}
respondJSON(w, http.StatusCreated, map[string]any{
"stack": st,
"revision": rev,
})
}
// ── Update (metadata only) ─────────────────────────────────────────
type updateStackRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) updateStack(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStackByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stack")
return
}
var req updateStackRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name != "" {
existing.Name = req.Name
}
existing.Description = req.Description
if err := s.store.UpdateStack(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update stack")
return
}
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
removeVolumes := r.URL.Query().Get("remove_volumes") == "true"
if err := s.stackManager.Delete(r.Context(), id, removeVolumes); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stack")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete stack: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Revisions ──────────────────────────────────────────────────────
func (s *Server) listStackRevisions(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
revs, err := s.store.GetStackRevisionsByStackID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list revisions")
return
}
respondJSON(w, http.StatusOK, revs)
}
func (s *Server) getStackRevision(w http.ResponseWriter, r *http.Request) {
revID := chi.URLParam(r, "revId")
rev, err := s.store.GetStackRevisionByID(revID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "revision")
return
}
respondError(w, http.StatusInternalServerError, "failed to get revision")
return
}
respondJSON(w, http.StatusOK, rev)
}
type newRevisionRequest struct {
YAML string `json:"yaml"`
}
func (s *Server) createStackRevision(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
var req newRevisionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.YAML == "" {
respondError(w, http.StatusBadRequest, "yaml is required")
return
}
author := authorFromRequest(r)
// Deploy asynchronously; return the revision immediately.
ctx := r.Context()
rev, err := s.stackManager.NewRevisionAndDeployAsync(ctx, id, req.YAML, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusAccepted, rev)
}
func (s *Server) rollbackStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
revID := chi.URLParam(r, "revId")
author := authorFromRequest(r)
rev, err := s.stackManager.RollbackAsync(r.Context(), id, revID, author)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusAccepted, rev)
}
// ── Control ──────────────────────────────────────────────────────
func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
if err := s.stackManager.Stop(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func (s *Server) startStack(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
if err := s.stackManager.Start(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "running"})
}
func (s *Server) getStackServices(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
services, err := s.stackManager.Services(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, services)
}
func (s *Server) getStackLogs(w http.ResponseWriter, r *http.Request) {
if s.stackManager == nil {
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
return
}
id := chi.URLParam(r, "id")
service := r.URL.Query().Get("service")
tail := 200
if t := r.URL.Query().Get("tail"); t != "" {
if n, err := strconv.Atoi(t); err == nil && n > 0 {
tail = n
}
}
logs, err := s.stackManager.Logs(r.Context(), id, service, tail)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, logs)
}
// authorFromRequest best-effort returns the username of the acting user.
// Falls back to "system" if no auth context is present.
func authorFromRequest(r *http.Request) string {
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
return claims.Username
}
return "system"
}
-186
View File
@@ -1,186 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// stageEnvRequest is the expected JSON body for creating/updating a stage env override.
type stageEnvRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted *bool `json:"encrypted"`
}
// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env.
func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
// Verify stage exists.
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
envs, err := s.store.GetStageEnvByStageID(stageID)
if err != nil {
slog.Error("failed to list stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted values in the response.
masked := make([]store.StageEnv, len(envs))
for i, env := range envs {
masked[i] = env
if env.Encrypted {
masked[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, masked)
}
// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env.
func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
// Verify stage exists.
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageEnvRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
encrypted := false
if req.Encrypted != nil {
encrypted = *req.Encrypted
}
value := req.Value
if encrypted && value != "" {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
}
env, err := s.store.CreateStageEnv(store.StageEnv{
StageID: stageID,
Key: req.Key,
Value: value,
Encrypted: encrypted,
})
if err != nil {
slog.Error("failed to create stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted value in the response.
if env.Encrypted {
env.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, env)
}
// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}.
func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envId")
existing, err := s.store.GetStageEnvByID(envID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage env")
return
}
slog.Error("failed to get stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageEnvRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Key != "" {
updated.Key = req.Key
}
if req.Encrypted != nil {
updated.Encrypted = *req.Encrypted
}
// Only update value if provided (allows updating key/encrypted without changing the value).
if req.Value != "" {
value := req.Value
if updated.Encrypted {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
}
updated.Value = value
}
if err := s.store.UpdateStageEnv(updated); err != nil {
slog.Error("failed to update stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask encrypted value in the response.
if updated.Encrypted {
updated.Value = "••••••••"
}
respondJSON(w, http.StatusOK, updated)
}
// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}.
func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envId")
if err := s.store.DeleteStageEnv(envID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage env")
return
}
slog.Error("failed to delete stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
-202
View File
@@ -1,202 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// stageRequest is the expected JSON body for creating/updating a stage.
type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
EnableProxy *bool `json:"enable_proxy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
CpuLimit *float64 `json:"cpu_limit"`
MemoryLimit *int `json:"memory_limit"`
}
// createStage handles POST /api/projects/{id}/stages.
func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
// Verify project exists.
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.TagPattern == "" {
req.TagPattern = "*"
}
autoDeploy := false
if req.AutoDeploy != nil {
autoDeploy = *req.AutoDeploy
}
maxInstances := 1
if req.MaxInstances != nil {
maxInstances = *req.MaxInstances
}
confirm := false
if req.Confirm != nil {
confirm = *req.Confirm
}
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
var cpuLimit float64
if req.CpuLimit != nil {
cpuLimit = *req.CpuLimit
}
var memoryLimit int
if req.MemoryLimit != nil {
memoryLimit = *req.MemoryLimit
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: projectID,
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
EnableProxy: enableProxy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
NotificationURL: req.NotificationURL,
CpuLimit: cpuLimit,
MemoryLimit: memoryLimit,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage created: " + stage.Name,
},
})
respondJSON(w, http.StatusCreated, stage)
}
// updateStage handles PUT /api/projects/{id}/stages/{stage}.
func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
existing, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
var req stageRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.TagPattern != "" {
updated.TagPattern = req.TagPattern
}
if req.AutoDeploy != nil {
updated.AutoDeploy = *req.AutoDeploy
}
if req.EnableProxy != nil {
updated.EnableProxy = *req.EnableProxy
}
if req.MaxInstances != nil {
updated.MaxInstances = *req.MaxInstances
}
if req.Confirm != nil {
updated.Confirm = *req.Confirm
}
updated.PromoteFrom = req.PromoteFrom
updated.Subdomain = req.Subdomain
updated.NotificationURL = req.NotificationURL
if req.CpuLimit != nil {
updated.CpuLimit = *req.CpuLimit
}
if req.MemoryLimit != nil {
updated.MemoryLimit = *req.MemoryLimit
}
if err := s.store.UpdateStage(updated); err != nil {
slog.Error("failed to update stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
// deleteStage handles DELETE /api/projects/{id}/stages/{stage}.
func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.DeleteStage(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("failed to delete stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage deleted: " + stageID,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID})
}
-662
View File
@@ -1,662 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
sites, err := s.store.GetAllStaticSites()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list static sites")
return
}
// Mask access tokens in response.
for i := range sites {
sites[i].AccessToken = maskToken(sites[i].AccessToken)
}
respondJSON(w, http.StatusOK, sites)
}
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
site.AccessToken = maskToken(site.AccessToken)
respondJSON(w, http.StatusOK, site)
}
// ── Create ──────────────────────────────────────────────────────────
type createStaticSiteRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
NotificationURL *string `json:"notification_url,omitempty"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
return
}
if req.Branch == "" {
req.Branch = "main"
}
if req.Mode == "" {
req.Mode = "static"
}
if req.SyncTrigger == "" {
req.SyncTrigger = "manual"
}
// Encrypt access token if provided.
encryptedToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
encryptedToken = encrypted
}
site := store.StaticSite{
Name: req.Name,
Provider: req.Provider,
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
RepoOwner: req.RepoOwner,
RepoName: req.RepoName,
Branch: req.Branch,
FolderPath: req.FolderPath,
AccessToken: encryptedToken,
Domain: req.Domain,
Mode: req.Mode,
RenderMarkdown: req.RenderMarkdown,
SyncTrigger: req.SyncTrigger,
TagPattern: req.TagPattern,
StorageEnabled: req.StorageEnabled,
StorageLimitMB: req.StorageLimitMB,
Status: "idle",
}
if req.NotificationURL != nil {
site.NotificationURL = *req.NotificationURL
}
created, err := s.store.CreateStaticSite(site)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
return
}
created.AccessToken = maskToken(created.AccessToken)
respondJSON(w, http.StatusCreated, created)
}
// ── Update ──────────────────────────────────────────────────────────
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
// Update fields.
if req.Name != "" {
existing.Name = req.Name
}
if req.Provider != "" {
existing.Provider = req.Provider
}
if req.GiteaURL != "" {
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
}
if req.RepoOwner != "" {
existing.RepoOwner = req.RepoOwner
}
if req.RepoName != "" {
existing.RepoName = req.RepoName
}
if req.Branch != "" {
existing.Branch = req.Branch
}
if req.FolderPath != "" {
existing.FolderPath = req.FolderPath
}
if req.Domain != "" {
existing.Domain = req.Domain
}
if req.Mode != "" {
existing.Mode = req.Mode
}
if req.SyncTrigger != "" {
existing.SyncTrigger = req.SyncTrigger
}
existing.RenderMarkdown = req.RenderMarkdown
existing.TagPattern = req.TagPattern
existing.StorageEnabled = req.StorageEnabled
existing.StorageLimitMB = req.StorageLimitMB
if req.NotificationURL != nil {
existing.NotificationURL = *req.NotificationURL
}
// Update access token only if a new one is provided.
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
existing.AccessToken = encrypted
}
if err := s.store.UpdateStaticSite(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
return
}
existing.AccessToken = maskToken(existing.AccessToken)
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Remove container and proxy route first.
if s.staticSiteManager != nil {
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
// Log but don't fail — still delete the DB record.
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
return
}
}
if err := s.store.DeleteStaticSite(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete static site")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Deploy ──────────────────────────────────────────────────────────
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
// Trigger deploy asynchronously with a detached context
// (the HTTP request context is canceled when the response is sent).
// Manual deploys always force a full rebuild + proxy regeneration.
go func() {
ctx := context.Background()
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
// Error is already stored in the site status.
return
}
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
}
// ── Stop / Start ────────────────────────────────────────────────────
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Stop(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
}
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Start(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
}
// ── Test Connection ─────────────────────────────────────────────────
type testConnectionRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
var req testConnectionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
return
}
// Encrypt token for the manager to decrypt (consistent handling).
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
}
// ── Branches ────────────────────────────────────────────────────────
type listBranchesRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
var req listBranchesRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
return
}
respondJSON(w, http.StatusOK, branches)
}
// ── Tree ────────────────────────────────────────────────────────────
type listTreeRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
}
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
var req listTreeRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
return
}
respondJSON(w, http.StatusOK, entries)
}
// ── Secrets ─────────────────────────────────────────────────────────
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list secrets")
return
}
// Mask encrypted values.
for i := range secrets {
if secrets[i].Encrypted {
secrets[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, secrets)
}
type createSecretRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
value := req.Value
if req.Encrypted && value != "" {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
secret := store.StaticSiteSecret{
SiteID: siteID,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
}
created, err := s.store.CreateStaticSiteSecret(secret)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
return
}
if created.Encrypted {
created.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, created)
}
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
existing, err := s.store.GetStaticSiteSecretByID(secretID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to get secret")
return
}
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key != "" {
existing.Key = req.Key
}
existing.Encrypted = req.Encrypted
if req.Value != "" {
value := req.Value
if req.Encrypted {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
existing.Value = value
}
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
return
}
if existing.Encrypted {
existing.Value = "••••••••"
}
respondJSON(w, http.StatusOK, existing)
}
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete secret")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
}
// ── List Repos ──────────────────────────────────────────────────────
type listReposRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
Query string `json:"query"`
}
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
var req listReposRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" {
respondError(w, http.StatusBadRequest, "gitea_url is required")
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
return
}
respondJSON(w, http.StatusOK, repos)
}
// ── Detect Provider ─────────────────────────────────────────────────
type detectProviderRequest struct {
URL string `json:"url"`
}
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
var req detectProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required")
return
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
}
// ── Storage Usage ──────────────────────────────────────────────────
func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
if !site.StorageEnabled {
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": false,
"used_bytes": 0,
"limit_mb": 0,
})
return
}
usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to inspect storage usage")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"used_bytes": usage.UsedBytes,
"limit_mb": site.StorageLimitMB,
})
}
// maskToken returns a masked version of a token string for API responses.
func maskToken(token string) string {
if token == "" {
return ""
}
return "••••••••"
}
+5 -126
View File
@@ -1,28 +1,23 @@
package api
import (
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/store"
)
// topConsumerWindow is how recent a container sample must be to count toward
// topConsumerMinWindow is how recent a container sample must be to count toward
// the "top consumers" list. Scaled with the collector interval (read from
// settings) so it stays meaningful even when sampling is sparse.
const topConsumerMinWindow = 2 * time.Minute
// TopContainerSample augments a stats sample with the human-readable owner
// name so the UI can show "project/stage" or the static-site name without an
// extra round-trip per row.
// name so the UI can show "workload/role" without an extra round-trip per row.
type TopContainerSample struct {
store.ContainerStatsSample
OwnerName string `json:"owner_name"`
@@ -90,107 +85,6 @@ func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, samples)
}
// getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history.
// {iid} is the container row ID (same UUID as the legacy instance ID).
func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) {
instanceID := chi.URLParam(r, "iid")
if _, err := s.store.GetContainerByID(instanceID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return
}
slog.Error("failed to get container", "id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get container")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot.
func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID)
if err != nil {
slog.Error("failed to get site stats", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site stats")
return
}
respondJSON(w, http.StatusOK, cs)
}
// getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history.
func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list site stats samples", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true.
// Reuses the shared container log streamer so the SSE + multiplex handling
// matches /api/projects/.../instances/.../logs exactly.
func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
s.streamLogsForContainer(w, r, site.ContainerID)
}
// listTopContainers handles GET /api/system/stats/top?limit=5&by=cpu.
// Returns the top-N most recent samples across containers, sorted by CPU or
// memory. Container IDs are stripped for non-admins so a low-privilege viewer
@@ -246,8 +140,6 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
top = top[:limit]
}
// Resolve owner names so the UI can show "project/stage" or the site name
// without a per-row round trip.
enriched := s.enrichWithOwnerNames(top)
// Scrub container IDs for non-admins. The owner name is the actionable
@@ -264,18 +156,13 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
}
// enrichWithOwnerNames attaches a human-readable owner name to each sample.
// Looks up instances and sites in batch so the cost is independent of the
// number of samples (which is at most 'limit').
// Names are resolved through the containers index → workloads, which after
// the cutover is the only available lookup path.
func (s *Server) enrichWithOwnerNames(samples []store.ContainerStatsSample) []TopContainerSample {
out := make([]TopContainerSample, len(samples))
for i, sm := range samples {
out[i] = TopContainerSample{ContainerStatsSample: sm}
switch sm.OwnerType {
case stats.OwnerTypeInstance:
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
case stats.OwnerTypeSite:
out[i].OwnerName = s.lookupSiteName(sm.OwnerID)
}
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
}
return out
}
@@ -300,11 +187,3 @@ func (s *Server) lookupInstanceName(instanceID string) string {
return w.Name
}
// lookupSiteName returns the site's display name or empty on lookup error.
func (s *Server) lookupSiteName(siteID string) string {
site, err := s.store.GetStaticSiteByID(siteID)
if err != nil {
return ""
}
return site.Name
}
-209
View File
@@ -1,209 +0,0 @@
package api
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
return strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r == '\n' || r == '\r' {
return '_'
}
return r
}, name)
}
const maxUploadSize = 100 * 1024 * 1024 // 100MB
// resolveVolumeRoot looks up a volume and resolves its host path.
func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) {
projectID := chi.URLParam(r, "id")
volID := chi.URLParam(r, "volId")
proj, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return "", false
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return "", false
}
vol, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return "", false
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return "", false
}
// Verify volume belongs to this project.
if vol.ProjectID != projectID {
respondNotFound(w, "volume")
return "", false
}
if vol.Scope == "ephemeral" {
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
return "", false
}
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings", "error", err)
respondError(w, http.StatusInternalServerError, "failed to get settings")
return "", false
}
q := r.URL.Query()
params := volume.ResolveParams{
BasePath: settings.BaseVolumePath,
ProjectName: proj.Name,
StageName: q.Get("stage"),
ImageTag: q.Get("tag"),
AllowedVolumePaths: settings.AllowedVolumePaths,
}
rootPath, err := volume.ResolvePath(vol, params)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return "", false
}
return rootPath, true
}
// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag=
func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
entries, err := volume.ListDir(rootPath, relPath)
if err != nil {
slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list directory")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"path": relPath,
"entries": entries,
})
}
// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag=
// Downloads a single file directly, or a directory/root as a zip archive.
func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
// If path is empty or points to a directory, serve as zip.
if relPath == "" {
s.serveZip(w, rootPath, "", "volume")
return
}
// Check if it's a file or directory.
f, info, err := volume.OpenFile(rootPath, relPath)
if err != nil {
// Might be a directory — try zip.
entries, listErr := volume.ListDir(rootPath, relPath)
if listErr == nil && entries != nil {
name := filepath.Base(relPath)
s.serveZip(w, rootPath, relPath, name)
return
}
slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusNotFound, "file not found")
return
}
defer f.Close()
// Serve single file with forced download.
safeName := sanitizeFilename(filepath.Base(relPath))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName))
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
}
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
safeName := sanitizeFilename(name)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName))
w.WriteHeader(http.StatusOK)
if err := volume.WriteZip(rootPath, relPath, w); err != nil {
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
}
}
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
// Accepts multipart form uploads. Overrides the global body limit for large files.
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
// Override the global 1MB body limit for uploads.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
respondError(w, http.StatusBadRequest, "upload too large (max 100MB)")
return
}
relPath := r.URL.Query().Get("path")
uploaded := []string{}
for _, fileHeaders := range r.MultipartForm.File {
for _, fh := range fileHeaders {
f, err := fh.Open()
if err != nil {
slog.Error("failed to open upload", "filename", fh.Filename, "error", err)
continue
}
// Strip directory components from filename to prevent directory creation attacks.
targetRel := filepath.Join(relPath, filepath.Base(fh.Filename))
if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
f.Close()
slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename)
return
}
f.Close()
uploaded = append(uploaded, fh.Filename)
}
}
respondJSON(w, http.StatusOK, map[string]any{
"uploaded": uploaded,
"count": len(uploaded),
})
}
-327
View File
@@ -1,327 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// validateVolumePath checks that the source path does not contain path traversal.
func validateVolumePath(source string) bool {
cleaned := filepath.Clean(source)
return !strings.Contains(cleaned, "..")
}
// volumeRequest is the expected JSON body for creating/updating a volume.
type volumeRequest struct {
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
Scope string `json:"scope"`
Name string `json:"name"`
}
// validScopes lists all accepted scope values.
var validScopes = map[string]bool{
"instance": true, "stage": true, "project": true,
"project_named": true, "named": true, "ephemeral": true,
"absolute": true,
}
// validateVolumeScope validates the scope, name, and source combination.
func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
if !validScopes[scope] {
return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
}
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
return "name is required for " + scope + " scope"
}
if name != "" && !safeNamePattern.MatchString(name) {
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
}
if scope == "absolute" {
if source == "" {
return "source path is required for absolute scope"
}
if !filepath.IsAbs(source) {
return "absolute scope requires an absolute source path"
}
// Validate against allowlist.
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
if err != nil {
return "failed to parse allowed volume paths"
}
if len(allowed) == 0 {
return "absolute volume paths are disabled — configure allowed paths in settings first"
}
matched := false
cleanSource := filepath.Clean(source)
for _, prefix := range allowed {
cleanPrefix := filepath.Clean(prefix)
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
matched = true
break
}
}
if !matched {
return "source path is not under any allowed volume path"
}
}
return ""
}
// scopeDescriptions returns metadata about each scope for the UI.
type scopeInfo struct {
Scope string `json:"scope"`
Description string `json:"description"`
NeedsName bool `json:"needs_name"`
PathExample string `json:"path_example"`
}
// listVolumeScopes handles GET /api/volumes/scopes.
// Returns all available scopes with descriptions for UI hints.
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
scopes := []scopeInfo{
{
Scope: "instance",
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
},
{
Scope: "stage",
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}/{source}",
},
{
Scope: "project",
Description: "Shared across all stages of the project. Good for common config or shared assets.",
NeedsName: false,
PathExample: "{base}/{project}/{source}",
},
{
Scope: "project_named",
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
NeedsName: true,
PathExample: "{base}/{project}/_named/{name}/{source}",
},
{
Scope: "named",
Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.",
NeedsName: true,
PathExample: "{base}/_named/{name}/{source}",
},
{
Scope: "ephemeral",
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.",
NeedsName: false,
PathExample: "(tmpfs — no host path)",
},
{
Scope: "absolute",
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
NeedsName: false,
PathExample: "/mnt/nfs/data (must match allowed paths)",
},
}
respondJSON(w, http.StatusOK, scopes)
}
// listVolumes handles GET /api/projects/{id}/volumes.
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
vols, err := s.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list volumes")
return
}
respondJSON(w, http.StatusOK, vols)
}
// createVolume handles POST /api/projects/{id}/volumes.
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
// Ephemeral volumes don't need a source path.
if req.Scope != "ephemeral" {
if req.Source == "" {
respondError(w, http.StatusBadRequest, "source is required")
return
}
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
}
if req.Target == "" {
respondError(w, http.StatusBadRequest, "target is required")
return
}
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
// Resolve scope — support legacy mode field.
scope := req.Scope
if scope == "" {
switch req.Mode {
case "isolated":
scope = "instance"
case "shared":
scope = "project"
default:
scope = "project"
}
}
// Fetch settings for absolute path allowlist validation.
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
vol, err := s.store.CreateVolume(store.Volume{
ProjectID: projectID,
Source: req.Source,
Target: req.Target,
Scope: scope,
Name: strings.TrimSpace(req.Name),
})
if err != nil {
slog.Error("failed to create volume", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to create volume")
return
}
respondJSON(w, http.StatusCreated, vol)
}
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
existing, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Source != "" {
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
updated.Source = req.Source
}
if req.Target != "" {
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
updated.Target = req.Target
}
if req.Scope != "" {
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
source := updated.Source
if req.Source != "" {
source = req.Source
}
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
updated.Scope = req.Scope
updated.Name = strings.TrimSpace(req.Name)
}
// Non-ephemeral scopes require a source path.
if updated.Scope != "ephemeral" && updated.Source == "" {
respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes")
return
}
if err := s.store.UpdateVolume(updated); err != nil {
slog.Error("failed to update volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to update volume")
return
}
respondJSON(w, http.StatusOK, updated)
}
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
if err := s.store.DeleteVolume(volID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to delete volume")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
}
-370
View File
@@ -1,370 +0,0 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors
// the helper in internal/store; kept here to avoid an import cycle and so the
// rotation handlers don't pretend to use uuid for what is really a secret.
func generateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
// webhookURLResponse is the common payload returned by every webhook endpoint.
// Clients never see raw secrets except at issue/rotate time via these fields;
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
type webhookURLResponse struct {
WebhookURL string `json:"webhook_url"`
WebhookSecret string `json:"webhook_secret"`
HasSigningSecret bool `json:"has_signing_secret"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// signingSecretResponse is returned when a signing secret is issued or rotated.
type signingSecretResponse struct {
SigningSecret string `json:"signing_secret"`
}
// signingToggleRequest is the body of the require-signature toggle endpoint.
type signingToggleRequest struct {
RequireSignature bool `json:"require_signature"`
}
// getProjectWebhook handles GET /api/projects/{id}/webhook.
// Returns the project's webhook URL + secret, generating one lazily if the
// project predates the per-project webhook migration.
func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureProjectWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("get project webhook: ensure secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
project, err := s.store.GetProjectByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/" + secret,
WebhookSecret: secret,
HasSigningSecret: project.WebhookSigningSecret != "",
WebhookRequireSignature: project.WebhookRequireSignature,
})
}
// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate.
// Issues a fresh HMAC signing secret for inbound webhook verification. The
// secret is returned exactly once — the UI is responsible for letting the
// user copy it into their CI configuration.
func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil {
slog.Error("rotate project signing secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
return
}
slog.Info("project webhook signing secret rotated", "project", id)
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
}
// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret.
// Clears the HMAC signing secret and disables enforcement.
func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
return
}
if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil {
slog.Warn("disable project require_signature", "project", id, "error", err)
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature.
// Toggles whether unsigned/invalidly-signed inbound webhook requests are
// rejected with 401.
func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req signingToggleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RequireSignature {
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
if project.WebhookSigningSecret == "" {
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
return
}
}
if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to update setting")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
// Rotates the project's webhook secret, invalidating the old URL.
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Verify project exists before rotating.
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("regenerate project webhook: lookup", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectWebhookSecret(id, secret); err != nil {
slog.Error("regenerate project webhook: set secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
slog.Info("project webhook secret rotated", "project", id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/" + secret,
WebhookSecret: secret,
})
}
// getStaticSiteWebhook handles GET /api/sites/{id}/webhook.
func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureStaticSiteWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("get site webhook: ensure secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/sites/" + secret,
WebhookSecret: secret,
HasSigningSecret: site.WebhookSigningSecret != "",
WebhookRequireSignature: site.WebhookRequireSignature,
})
}
// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate.
func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil {
slog.Error("rotate site signing secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
return
}
slog.Info("static site webhook signing secret rotated", "site", id)
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
}
// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret.
func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
return
}
if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil {
slog.Warn("disable site require_signature", "site", id, "error", err)
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
// Returns the most recent webhook deliveries for the project so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
if err != nil {
slog.Error("list project webhook deliveries", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
if err != nil {
slog.Error("list site webhook deliveries", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// parseLimit clamps a query-string limit to [1, max], falling back to def.
func parseLimit(raw string, def, max int) int {
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > max {
return max
}
return n
}
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req signingToggleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RequireSignature {
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
if site.WebhookSigningSecret == "" {
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
return
}
}
if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to update setting")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("regenerate site webhook: lookup", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil {
slog.Error("regenerate site webhook: set secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
slog.Info("static site webhook secret rotated", "site", id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/sites/" + secret,
WebhookSecret: secret,
})
}
+6 -55
View File
@@ -136,61 +136,12 @@ func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns
// the canonical URL + secret + signature-state flags. Lazily generates
// a secret if the workload row predates the column.
func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureWorkloadWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("ensure workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate.
// Rotates the URL secret. The old secret is invalidated immediately —
// any external system still hitting the old URL gets a 404 on next call.
func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
secret := generateWebhookSecret()
if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil {
slog.Error("rotate workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
row, _ := s.store.GetWorkloadByID(id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// Workload-level webhook URL handlers were dropped in the hard legacy
// cutover: the old `/api/webhook/workloads/{secret}` route is gone, so
// minting a workload secret would hand operators a URL that 404s. The
// inbound webhook surface is now exclusively first-class triggers
// (`/api/webhook/triggers/{secret}`); use the trigger CRUD + bindings
// endpoints to wire a workload to inbound deploys.
// validEnvKey accepts POSIX-style env names. Rejects anything that would
// confuse Docker's env parser (=, spaces, control chars).
+60
View File
@@ -20,6 +20,66 @@ type workloadVolumeRequest struct {
Name string `json:"name"`
}
// scopeInfo carries one volume scope plus its operator-facing description.
// The UI uses NeedsName to decide whether to show the name input.
type scopeInfo struct {
Scope string `json:"scope"`
Description string `json:"description"`
NeedsName bool `json:"needs_name"`
PathExample string `json:"path_example"`
}
// listVolumeScopes handles GET /api/volumes/scopes. Returns the catalogue
// of supported volume scopes so the workload-volume editor can render
// scope-specific help text without baking the list into the frontend.
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
scopes := []scopeInfo{
{
Scope: "instance",
Description: "Each deploy gets its own isolated directory keyed by image tag.",
NeedsName: false,
PathExample: "{base}/{workload}/instance-{tag}/{source}",
},
{
Scope: "stage",
Description: "Shared across all instances of this workload (alias of project scope).",
NeedsName: false,
PathExample: "{base}/{workload}/{source}",
},
{
Scope: "project",
Description: "Shared across all instances of this workload.",
NeedsName: false,
PathExample: "{base}/{workload}/{source}",
},
{
Scope: "project_named",
Description: "A named volume within the workload — multiple mounts can share the name.",
NeedsName: true,
PathExample: "{base}/{workload}/_named/{name}/{source}",
},
{
Scope: "named",
Description: "Globally named volume shared across workloads (e.g. shared databases).",
NeedsName: true,
PathExample: "{base}/_named/{name}/{source}",
},
{
Scope: "ephemeral",
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops.",
NeedsName: false,
PathExample: "(tmpfs — no host path)",
},
{
Scope: "absolute",
Description: "Direct host path. Must be under an allowed path configured in settings.",
NeedsName: false,
PathExample: "/mnt/nfs/data (must match allowed paths)",
},
}
respondJSON(w, http.StatusOK, scopes)
}
func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {