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