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

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

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

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

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

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

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

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
This commit is contained in:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
+16 -47
View File
@@ -187,23 +187,22 @@ func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) {
return c, nil
}
// ListProxyRoutes returns proxy-enabled project containers joined with
// project + stage names. Reads from the normalized containers index and
// joins through stage_id so a stage rename does not orphan the row's view.
//
// Source is reported as "instance" for back-compat with the Proxies page
// filter (the frontend keys off the literal string).
// ListProxyRoutes returns proxy-enabled containers joined with their
// owning workload's name. The legacy stages join is gone — Role is used
// as the StageName fallback so the Proxies page still reads naturally
// for project-style workloads. Source is reported as "instance" for
// back-compat with the Proxies page filter (the frontend keys off the
// literal string).
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(`
SELECT c.id, p.id, p.name, s.id, s.name,
SELECT c.id, w.id, w.name,
c.image_tag, c.subdomain, c.container_id, c.port,
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at,
c.role, c.stage_id
FROM containers c
JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project'
JOIN projects p ON p.id = w.ref_id
JOIN stages s ON s.id = c.stage_id OR (c.stage_id = '' AND s.project_id = p.id AND s.name = c.role)
JOIN workloads w ON w.id = c.workload_id
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
ORDER BY p.name, s.name, c.created_at DESC`,
ORDER BY w.name, c.role, c.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query proxy routes: %w", err)
@@ -213,14 +212,18 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var role, stageID string
if err := rows.Scan(
&r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName,
&r.InstanceID, &r.ProjectID, &r.ProjectName,
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
&role, &stageID,
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
r.StageID = stageID
r.StageName = role
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
@@ -229,40 +232,6 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
return routes, rows.Err()
}
// ListContainersByStageID returns project containers for the given stage,
// newest first. Resolves via stage_id with a fallback to the legacy
// (stage.name = container.role) join for rows written before the stage_id
// column was populated. Replaces GetInstancesByStageID.
func (s *Store) ListContainersByStageID(stageID string) ([]Container, error) {
rows, err := s.db.Query(`
SELECT `+prefixCols(containerColumns, "c.")+`
FROM containers c
LEFT JOIN stages s ON s.id = ?
WHERE c.stage_id = ?
OR (c.stage_id = '' AND s.id IS NOT NULL
AND c.role = s.name
AND EXISTS (
SELECT 1 FROM workloads w
WHERE w.id = c.workload_id
AND w.kind = 'project'
AND w.ref_id = s.project_id))
ORDER BY c.created_at DESC`, stageID, stageID)
if err != nil {
return nil, fmt.Errorf("query containers by stage: %w", err)
}
defer rows.Close()
out := []Container{}
for rows.Next() {
c, err := scanContainer(rows)
if err != nil {
return nil, fmt.Errorf("scan container: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// ListContainersByWorkload returns all containers for a given workload, newest first.
func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) {
rows, err := s.db.Query(
-212
View File
@@ -1,212 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// CreateDeploy inserts a new deploy record.
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
d.ID = uuid.New().String()
d.StartedAt = Now()
if d.Status == "" {
d.Status = "pending"
}
_, err := s.db.Exec(
`INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error,
)
if err != nil {
return Deploy{}, fmt.Errorf("insert deploy: %w", err)
}
return d, nil
}
// GetDeployByID returns a single deploy by its ID.
func (s *Store) GetDeployByID(id string) (Deploy, error) {
var d Deploy
err := s.db.QueryRow(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE id = ?`, id,
).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error)
if errors.Is(err, sql.ErrNoRows) {
return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
if err != nil {
return Deploy{}, fmt.Errorf("query deploy: %w", err)
}
return d, nil
}
// GetDeploysByProjectID returns all deploys for a project, newest first.
func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// GetRecentDeploys returns the most recent deploys across all projects.
func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys ORDER BY started_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, fmt.Errorf("query recent deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
ts := Now()
var finishedAt string
if IsTerminalDeployStatus(status) {
finishedAt = ts
}
result, err := s.db.Exec(
`UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`,
status, deployErr, finishedAt, id,
)
if err != nil {
return fmt.Errorf("update deploy status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
return nil
}
// SetDeployInstanceID links a deploy to the instance it created.
func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error {
result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID)
if err != nil {
return fmt.Errorf("set deploy instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound)
}
return nil
}
// AppendDeployLog adds a log entry for a deploy.
func (s *Store) AppendDeployLog(deployID string, message string, level string) error {
if level == "" {
level = "info"
}
_, err := s.db.Exec(
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
deployID, message, level, Now(),
)
if err != nil {
return fmt.Errorf("append deploy log: %w", err)
}
return nil
}
// GetDeployLogs returns all log entries for a deploy, ordered chronologically.
func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
rows, err := s.db.Query(
`SELECT id, deploy_id, message, level, created_at
FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID,
)
if err != nil {
return nil, fmt.Errorf("query deploy logs: %w", err)
}
defer rows.Close()
logs := []DeployLog{}
for rows.Next() {
var l DeployLog
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
return nil, fmt.Errorf("scan deploy log: %w", err)
}
logs = append(logs, l)
}
return logs, rows.Err()
}
// scanDeploys is a helper that scans deploy rows from a cursor.
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
deploys := []Deploy{}
for rows.Next() {
var d Deploy
if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil {
return nil, fmt.Errorf("scan deploy: %w", err)
}
deploys = append(deploys, d)
}
return deploys, rows.Err()
}
// IsTerminalDeployStatus returns true if the status indicates the deploy is finished.
func IsTerminalDeployStatus(status string) bool {
switch status {
case "success", "failed", "rolled_back":
return true
default:
return false
}
}
// GetDeploys returns deploys with optional filtering by project and stage, with pagination.
func (s *Store) GetDeploys(projectID, stageID string, limit, offset int) ([]Deploy, error) {
query := `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error FROM deploys`
var args []any
var conditions []string
if projectID != "" {
conditions = append(conditions, "project_id = ?")
args = append(args, projectID)
}
if stageID != "" {
conditions = append(conditions, "stage_id = ?")
args = append(args, stageID)
}
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// CleanupOldDeploys removes deploy records and their logs older than the given
// number of days. Returns the number of deploys removed.
func (s *Store) CleanupOldDeploys(retentionDays int) (int64, error) {
cutoff := fmt.Sprintf("-%d days", retentionDays)
result, err := s.db.Exec(
`DELETE FROM deploys WHERE started_at < datetime('now', ?)`, cutoff,
)
if err != nil {
return 0, fmt.Errorf("cleanup old deploys: %w", err)
}
n, _ := result.RowsAffected()
return n, nil
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"crypto/rand"
"encoding/hex"
"github.com/google/uuid"
)
// rowScanner is the subset of *sql.Row / *sql.Rows used by row scanners
// across this package. Kept package-private — callers should not need to
// implement it themselves.
type rowScanner interface {
Scan(dest ...any) error
}
// BoolToInt converts a Go bool to the 0/1 INTEGER convention SQLite uses
// for boolean columns across this schema.
func BoolToInt(b bool) int {
if b {
return 1
}
return 0
}
// GenerateWebhookSecret returns a 256-bit hex-encoded random token.
// Exported so the api layer can share one implementation — keeping
// two copies invited drift (one panicked, one fell back to UUID).
// crypto/rand directly rather than uuid.New() so the intent ("secret
// token, not identifier") is explicit and the entropy is unambiguous.
func GenerateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand is documented to never fail on supported platforms;
// fall back to a UUID rather than panicking.
return uuid.New().String()
}
return hex.EncodeToString(b)
}
// generateWebhookSecret is the in-package alias kept for the existing
// CRUD call sites that don't reach across packages. New callers in
// other packages should use GenerateWebhookSecret directly.
func generateWebhookSecret() string { return GenerateWebhookSecret() }
+13 -174
View File
@@ -1,45 +1,5 @@
package store
// Project represents a deployable application.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
WebhookSecret string `json:"-"` // per-project webhook secret (URL identifier); never serialized
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stage represents a deployment stage within a project (e.g. dev, rel, prod).
type Stage struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy bool `json:"auto_deploy"`
MaxInstances int `json:"max_instances"`
Confirm bool `json:"confirm"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Registry represents a container image registry.
type Registry struct {
ID string `json:"id"`
@@ -142,10 +102,15 @@ type DNSRecord struct {
UpdatedAt string `json:"updated_at"`
}
// ProxyRoute is a proxy-enabled container row joined with its project + stage
// names, shaped for the Proxies page. Source is "instance" for project
// containers and "static_site" for site rows — the names are historical
// (the table itself was renamed to containers in the workload refactor).
// ProxyRoute shapes one proxy-enabled container row for the Proxies
// page. The legacy field names (ProjectID, ProjectName, StageID,
// StageName, InstanceID) are retained verbatim for the existing
// frontend contract — after the workload-first cutover they map to:
// ProjectID/Name → workload id / workload name
// StageID/Name → containers.stage_id / containers.role
// InstanceID → container row id
// Source → "instance" for image/compose, "static_site" for static
// Renaming would require a coordinated frontend change; deferred.
type ProxyRoute struct {
Source string `json:"source"`
InstanceID string `json:"instance_id"`
@@ -164,39 +129,6 @@ type ProxyRoute struct {
CreatedAt string `json:"created_at"`
}
// Deploy represents a deployment attempt.
type Deploy struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
StageID string `json:"stage_id"`
InstanceID string `json:"instance_id"`
ImageTag string `json:"image_tag"`
Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Error string `json:"error"`
}
// DeployLog is a single log entry for a deploy.
type DeployLog struct {
ID int64 `json:"id"`
DeployID string `json:"deploy_id"`
Message string `json:"message"`
Level string `json:"level"` // info, warn, error
CreatedAt string `json:"created_at"`
}
// StageEnv represents a per-stage environment variable override.
type StageEnv struct {
ID string `json:"id"`
StageID string `json:"stage_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadVolume is the plugin-shape equivalent of legacy Volume: a
// per-workload mount declaration. The Scope enum matches the existing
// VolumeScope contract so the legacy resolver can be reused once its
@@ -256,101 +188,6 @@ func IsValidVolumeScope(s string) bool {
return false
}
// Volume represents a volume mount configuration for a project.
type Volume struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat
Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral
Name string `json:"name"` // required for project_named and named scopes
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StaticSite represents a static site deployed from a Git repository folder.
type StaticSite struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect
GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages"
AccessToken string `json:"access_token"` // encrypted; optional for public repos
Domain string `json:"domain"` // full domain for proxy
Mode string `json:"mode"` // "static" or "deno"
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual"
TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync
ContainerID string `json:"container_id"`
ProxyRouteID string `json:"proxy_route_id"`
Status string `json:"status"` // idle, syncing, deployed, failed
LastSyncAt string `json:"last_sync_at"`
LastCommitSHA string `json:"last_commit_sha"`
Error string `json:"error"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
WebhookSecret string `json:"-"` // per-site webhook secret (URL identifier); never serialized
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend.
type StaticSiteSecret struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stack represents a docker-compose stack managed as a single deployable unit.
type Stack struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ComposeProjectName string `json:"compose_project_name"` // `-p` arg for docker compose
Status string `json:"status"` // stopped, deploying, running, failed
Error string `json:"error"`
CurrentRevisionID string `json:"current_revision_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StackRevision is an append-only record of a YAML version for a stack.
// Rollback = insert a new revision whose YAML is copied from an older one.
type StackRevision struct {
ID string `json:"id"`
StackID string `json:"stack_id"`
Revision int `json:"revision"` // monotonic per stack
YAML string `json:"yaml"`
Author string `json:"author"`
DeployID string `json:"deploy_id"`
Status string `json:"status"` // pending, success, failed
CreatedAt string `json:"created_at"`
}
// StackDeploy records a deployment attempt of a specific revision.
type StackDeploy struct {
ID string `json:"id"`
StackID string `json:"stack_id"`
RevisionID string `json:"revision_id"`
Status string `json:"status"` // pending, deploying, success, failed, rolled_back
Log string `json:"log"`
Error string `json:"error"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
}
// EventLog represents a persistent event log entry.
type EventLog struct {
ID int64 `json:"id"`
@@ -437,8 +274,10 @@ const (
LogScanSeverityError = "error"
)
// WorkloadKind enumerates the kinds of things that own containers.
// Each kind has a corresponding row in projects/stacks/static_sites referenced via Workload.RefID.
// WorkloadKind enumerates the legacy discriminator values written into
// containers.workload_kind and workloads.kind. After the hard cutover the
// backing project / stack / static_site tables are gone — these constants
// are just strings used to filter the unified containers index in the UI.
type WorkloadKind string
const (
-75
View File
@@ -1,75 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// PollState tracks the last polled tag for a stage, enabling the poller to
// detect new tags since the previous poll cycle.
type PollState struct {
StageID string `json:"stage_id"`
LastTag string `json:"last_tag"`
LastPolled string `json:"last_polled"`
}
// GetPollState returns the poll state for a given stage.
func (s *Store) GetPollState(stageID string) (PollState, error) {
var ps PollState
err := s.db.QueryRow(
`SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`,
stageID,
).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled)
if errors.Is(err, sql.ErrNoRows) {
return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound)
}
if err != nil {
return PollState{}, fmt.Errorf("query poll state: %w", err)
}
return ps, nil
}
// UpsertPollState inserts or updates the poll state for a stage.
func (s *Store) UpsertPollState(ps PollState) error {
_, err := s.db.Exec(
`INSERT INTO poll_states (stage_id, last_tag, last_polled)
VALUES (?, ?, ?)
ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`,
ps.StageID, ps.LastTag, ps.LastPolled,
)
if err != nil {
return fmt.Errorf("upsert poll state: %w", err)
}
return nil
}
// DeletePollState removes the poll state for a stage.
func (s *Store) DeletePollState(stageID string) error {
_, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID)
if err != nil {
return fmt.Errorf("delete poll state: %w", err)
}
return nil
}
// GetAllPollStates returns all poll states, ordered by last_polled descending.
func (s *Store) GetAllPollStates() ([]PollState, error) {
rows, err := s.db.Query(
`SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`,
)
if err != nil {
return nil, fmt.Errorf("query poll states: %w", err)
}
defer rows.Close()
states := []PollState{}
for rows.Next() {
var ps PollState
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
return nil, fmt.Errorf("scan poll state: %w", err)
}
states = append(states, ps)
}
return states, rows.Err()
}
-342
View File
@@ -1,342 +0,0 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"github.com/google/uuid"
)
// minWebhookSecretLength is the smallest user-supplied webhook secret accepted
// at insert time. Auto-generated secrets are 64 hex chars (256 bits); a
// 32-char floor still leaves > 128 bits of brute-force resistance for hex
// alphabets and rejects obvious typos / placeholder strings.
const minWebhookSecretLength = 32
// generateWebhookSecret returns a 256-bit hex-encoded random token. We use
// crypto/rand directly rather than uuid.New() so the intent ("secret token,
// not identifier") is explicit and the entropy is unambiguous.
func generateWebhookSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand is documented to never fail on supported platforms;
// fall back to a UUID rather than panicking.
return uuid.New().String()
}
return hex.EncodeToString(b)
}
// projectCols is the canonical column list for projects queries.
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
npm_access_list_id, webhook_secret, webhook_signing_secret, webhook_require_signature,
notification_url, notification_secret, created_at, updated_at`
// rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject.
type rowScanner interface {
Scan(dest ...any) error
}
// scanProject reads one row in projectCols order. webhook_require_signature
// is stored as INTEGER and converted to bool here.
func scanProject(r rowScanner) (Project, error) {
var p Project
var requireSig int
if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig,
&p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return Project{}, err
}
p.WebhookRequireSignature = requireSig != 0
return p, nil
}
// CreateProject inserts a new project and returns it. A webhook secret is
// generated automatically if one is not already set on the input. Project
// row + matching workload row are written in a single transaction.
func (s *Store) CreateProject(p Project) (Project, error) {
p.ID = uuid.New().String()
p.CreatedAt = Now()
p.UpdatedAt = p.CreatedAt
if p.WebhookSecret == "" {
p.WebhookSecret = generateWebhookSecret()
} else if len(p.WebhookSecret) < minWebhookSecretLength {
return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
requireSig := 0
if p.WebhookRequireSignature {
requireSig = 1
}
tx, err := s.db.Begin()
if err != nil {
return Project{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO projects (`+projectCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.WebhookSecret, p.WebhookSigningSecret, requireSig,
p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
); err != nil {
return Project{}, fmt.Errorf("insert project: %w", err)
}
if err := SyncProjectWorkloadTx(tx, p); err != nil {
return Project{}, err
}
if err := tx.Commit(); err != nil {
return Project{}, fmt.Errorf("commit: %w", err)
}
return p, nil
}
// GetProjectByID returns a single project by its ID.
func (s *Store) GetProjectByID(id string) (Project, error) {
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if err != nil {
return Project{}, fmt.Errorf("query project: %w", err)
}
return p, nil
}
// GetProjectByWebhookSecret looks up a project by its webhook secret.
// Returns ErrNotFound if no project has this secret (including empty).
func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
if secret == "" {
return Project{}, ErrNotFound
}
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret)
p, err := scanProject(row)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("query project by webhook secret: %w", err)
}
return p, nil
}
// GetAllProjects returns every project ordered by name.
func (s *Store) GetAllProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT ` + projectCols + ` FROM projects ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectsByImage returns all projects using the given image, newest first.
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
rows, err := s.db.Query(
`SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
)
if err != nil {
return nil, fmt.Errorf("query projects by image: %w", err)
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// updateProjectAndSyncWorkloadTx performs the parent UPDATE + workload sync in
// a single transaction. Used by every Set*Secret / UpdateProject path so the
// project row and the workload row never desync after a partial failure.
// updateSQL must be a parameterized UPDATE on `projects` ending with `WHERE id=?`;
// args are the parameter values in order, with the project ID last.
func (s *Store) updateProjectAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("update project: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
// Re-read the row inside the transaction so the workload sync sees the
// canonical values (the caller may have only updated one column).
row := tx.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row)
if err != nil {
return fmt.Errorf("reread project for workload sync: %w", err)
}
if err := SyncProjectWorkloadTx(tx, p); err != nil {
return err
}
return tx.Commit()
}
// UpdateProject updates an existing project's mutable fields. Webhook secret
// and notification_secret are intentionally not updated here — use the
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = Now()
return s.updateProjectAndSyncWorkloadTx(p.ID,
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?,
npm_access_list_id=?, notification_url=?, updated_at=?
WHERE id=?`,
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
)
}
// SetProjectWebhookSecret assigns a webhook secret to a project.
// Pass an empty string to disable webhook access for the project.
func (s *Store) SetProjectWebhookSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetProjectWebhookSigningSecret assigns the HMAC signing secret used to
// verify inbound webhook payloads. Pass an empty string to clear it (which
// also implicitly disables signature enforcement on the next request).
func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetProjectWebhookRequireSignature toggles whether unsigned (or
// invalidly-signed) webhook requests are rejected with 401.
func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error {
v := 0
if require {
v = 1
}
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
}
// EnsureProjectWebhookSecret returns the current webhook secret for a project,
// generating one on the fly if the stored value is empty (lazy backfill for
// projects created before the per-project webhook migration).
func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
project, err := s.GetProjectByID(id)
if err != nil {
return "", err
}
if project.WebhookSecret != "" {
return project.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetProjectWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this project (notifications
// still send unsigned, falling through to the parent tier's secret if any).
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
return s.updateProjectAndSyncWorkloadTx(id,
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
// secret, generating one lazily if missing. Used when an operator first opens
// the outgoing-webhook panel for a project that predates this feature.
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
project, err := s.GetProjectByID(id)
if err != nil {
return "", err
}
if project.NotificationSecret != "" {
return project.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetProjectNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
// Workload row + container index entries are removed too so the global views
// don't show ghost rows after a project is gone. Atomic: the project, its
// container index entries, and its workload row all live or die together.
func (s *Store) DeleteProject(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
// Resolve the workload before deleting the project so we have the
// workload ID for the cascade.
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindProject), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup project workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete project containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete project workload: %w", err)
}
}
return tx.Commit()
}
-173
View File
@@ -1,173 +0,0 @@
package store
import "testing"
// TestListProxyRoutesJoinShape verifies the new containers-based join produces
// the same ProxyRoute shape the /api/proxies frontend has consumed since this
// query was instances-based. Without this test, a missing column or a wrong
// join condition would silently break the Proxies page.
func TestListProxyRoutesJoinShape(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
stage, err := s.CreateStage(Stage{
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
})
if err != nil {
t.Fatalf("CreateStage: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err != nil {
t.Fatalf("workload: %v", err)
}
// Container with both subdomain and proxy_route_id populated — the rule
// the WHERE clause filters on.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-abc",
ImageTag: "v1",
State: "running",
Port: 8080,
Subdomain: "wf-prod",
ProxyRouteID: "route-1",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
// Container without subdomain — must be filtered OUT.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-def",
ImageTag: "v2",
State: "running",
}); err != nil {
t.Fatalf("CreateContainer 2: %v", err)
}
routes, err := s.ListProxyRoutes("example.test")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d (filter wrong?)", len(routes))
}
r := routes[0]
if r.Source != "instance" {
t.Errorf("Source: got %q, want 'instance' (back-compat)", r.Source)
}
if r.ProjectID != p.ID {
t.Errorf("ProjectID: got %q, want %q", r.ProjectID, p.ID)
}
if r.ProjectName != "wf" {
t.Errorf("ProjectName: got %q, want 'wf'", r.ProjectName)
}
if r.StageID != stage.ID {
t.Errorf("StageID: got %q, want %q", r.StageID, stage.ID)
}
if r.StageName != "prod" {
t.Errorf("StageName: got %q, want 'prod'", r.StageName)
}
if r.ImageTag != "v1" {
t.Errorf("ImageTag: got %q, want 'v1'", r.ImageTag)
}
if r.Subdomain != "wf-prod" {
t.Errorf("Subdomain: got %q, want 'wf-prod'", r.Subdomain)
}
if r.Domain != "wf-prod.example.test" {
t.Errorf("Domain: got %q, want 'wf-prod.example.test'", r.Domain)
}
if r.ContainerID != "docker-abc" {
t.Errorf("ContainerID: got %q, want 'docker-abc'", r.ContainerID)
}
if r.Port != 8080 {
t.Errorf("Port: got %d, want 8080", r.Port)
}
if r.ProxyRouteID != "route-1" {
t.Errorf("ProxyRouteID: got %q, want 'route-1'", r.ProxyRouteID)
}
if r.Status != "running" {
t.Errorf("Status (state): got %q, want 'running'", r.Status)
}
}
func TestListProxyRoutesNpmOnly(t *testing.T) {
// NPM-only routes (npm_proxy_id > 0, proxy_route_id == "") must still be
// returned — that's the original WHERE-clause OR branch.
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "npm-only", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
stage, _ := s.CreateStage(Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: stage.Name,
ContainerID: "docker-1",
Subdomain: "npm-only-dev",
NpmProxyID: 42,
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
routes, err := s.ListProxyRoutes("")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected 1 npm route, got %d", len(routes))
}
if routes[0].NpmProxyID != 42 {
t.Errorf("NpmProxyID: got %d, want 42", routes[0].NpmProxyID)
}
}
func TestListProxyRoutesIgnoresWrongRole(t *testing.T) {
// Belt-and-suspenders: a container whose role doesn't match a stage name
// would orphan the JOIN. Verify the row falls out cleanly (LEFT JOIN
// would expose a real bug here).
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
_, _ = s.CreateStage(Stage{
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1,
})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID,
WorkloadKind: "project",
Role: "ghost-stage", // intentionally not a real stage name
ContainerID: "docker-x",
Subdomain: "wf-ghost",
ProxyRouteID: "route-x",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
routes, err := s.ListProxyRoutes("")
if err != nil {
t.Fatalf("ListProxyRoutes: %v", err)
}
if len(routes) != 0 {
t.Fatalf("orphan-role row leaked into result: got %d", len(routes))
}
}
+21
View File
@@ -101,6 +101,27 @@ func (s *Store) UpdateSettings(st Settings) error {
return nil
}
// EnsureSettingsNotificationSecret returns the current global notification
// secret, lazily generating + persisting one if none is set. Lets the
// settings UI render a stable secret on first load for any install that
// predates the signing feature.
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
var secret string
if err := s.db.QueryRow(
`SELECT notification_secret FROM settings WHERE id = 1`,
).Scan(&secret); err != nil {
return "", fmt.Errorf("get settings notification secret: %w", err)
}
if secret != "" {
return secret, nil
}
secret = generateWebhookSecret()
if err := s.SetSettingsNotificationSecret(secret); err != nil {
return "", err
}
return secret, nil
}
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
// signing secret on the singleton settings row. Pass an empty string to
// disable signing globally (notifications still send, just without HMAC).
-398
View File
@@ -1,398 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const stackCols = `id, name, description, compose_project_name, status, error,
current_revision_id, created_at, updated_at`
// CreateStack inserts a new stack and returns it. Stack row + matching
// workload row are written in a single transaction so a partial failure
// leaves no orphan.
func (s *Store) CreateStack(st Stack) (Stack, error) {
st.ID = uuid.New().String()
st.CreatedAt = Now()
st.UpdatedAt = st.CreatedAt
if st.Status == "" {
st.Status = "stopped"
}
tx, err := s.db.Begin()
if err != nil {
return Stack{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO stacks (`+stackCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
); err != nil {
return Stack{}, fmt.Errorf("insert stack: %w", err)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return Stack{}, err
}
if err := tx.Commit(); err != nil {
return Stack{}, fmt.Errorf("commit: %w", err)
}
return st, nil
}
// GetStackByID returns a single stack by its ID.
func (s *Store) GetStackByID(id string) (Stack, error) {
st, err := scanStackRow(s.db.QueryRow(
`SELECT `+stackCols+` FROM stacks WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
if err != nil {
return Stack{}, fmt.Errorf("query stack: %w", err)
}
return st, nil
}
// GetStackByComposeProjectName looks up a stack by its compose project name.
// Compose project names are unique per the stacks table schema, so this is an
// O(1) index lookup. Used by the reconciler to resolve compose-managed
// containers without scanning every stack.
func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) {
if name == "" {
return Stack{}, ErrNotFound
}
st, err := scanStackRow(s.db.QueryRow(
`SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name,
))
if errors.Is(err, sql.ErrNoRows) {
return Stack{}, ErrNotFound
}
if err != nil {
return Stack{}, fmt.Errorf("query stack by compose project: %w", err)
}
return st, nil
}
// GetAllStacks returns every stack ordered by name.
func (s *Store) GetAllStacks() ([]Stack, error) {
rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("query stacks: %w", err)
}
defer rows.Close()
out := []Stack{}
for rows.Next() {
st, err := scanStackRows(rows)
if err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// UpdateStack updates the mutable metadata fields (name, description).
// Atomic: stack row UPDATE and workload row sync share a transaction so the
// workload row's name never lags after a rename.
func (s *Store) UpdateStack(st Stack) error {
st.UpdatedAt = Now()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(
`UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`,
st.Name, st.Description, st.UpdatedAt, st.ID,
)
if err != nil {
return fmt.Errorf("update stack: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return err
}
return tx.Commit()
}
// UpdateStackStatus updates the deployment status + error fields.
func (s *Store) UpdateStackStatus(id, status, errMsg string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`,
status, errMsg, now, id,
)
if err != nil {
return fmt.Errorf("update stack status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
return nil
}
// SetStackCurrentRevision updates the current_revision_id pointer.
func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`,
revisionID, now, id,
)
if err != nil {
return fmt.Errorf("update stack revision pointer: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
// Stack + workload + container index rows are dropped atomically.
func (s *Store) DeleteStack(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindStack), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup stack workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stack: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack workload: %w", err)
}
}
return tx.Commit()
}
func scanStackRow(row *sql.Row) (Stack, error) {
var st Stack
err := row.Scan(
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
)
return st, err
}
func scanStackRows(rows *sql.Rows) (Stack, error) {
var st Stack
err := rows.Scan(
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
)
if err != nil {
return Stack{}, fmt.Errorf("scan stack: %w", err)
}
return st, nil
}
// --- Stack revisions ---
const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at`
// CreateStackRevision inserts a new revision with the next monotonic revision number.
func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) {
r.ID = uuid.New().String()
r.CreatedAt = Now()
if r.Status == "" {
r.Status = "pending"
}
tx, err := s.db.Begin()
if err != nil {
return StackRevision{}, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var next int
if err := tx.QueryRow(
`SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`,
r.StackID,
).Scan(&next); err != nil {
return StackRevision{}, fmt.Errorf("next revision: %w", err)
}
r.Revision = next
if _, err := tx.Exec(
`INSERT INTO stack_revisions (`+stackRevisionCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt,
); err != nil {
return StackRevision{}, fmt.Errorf("insert revision: %w", err)
}
if err := tx.Commit(); err != nil {
return StackRevision{}, fmt.Errorf("commit revision: %w", err)
}
return r, nil
}
// GetStackRevisionByID returns a single revision by ID.
func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) {
r, err := scanStackRevisionRow(s.db.QueryRow(
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound)
}
if err != nil {
return StackRevision{}, fmt.Errorf("query revision: %w", err)
}
return r, nil
}
// GetStackRevisionsByStackID returns revisions newest-first.
func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) {
rows, err := s.db.Query(
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ?
ORDER BY revision DESC`,
stackID,
)
if err != nil {
return nil, fmt.Errorf("query revisions: %w", err)
}
defer rows.Close()
out := []StackRevision{}
for rows.Next() {
r, err := scanStackRevisionRows(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// UpdateStackRevisionStatus updates status + deploy_id linkage.
func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error {
result, err := s.db.Exec(
`UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`,
status, deployID, id,
)
if err != nil {
return fmt.Errorf("update revision status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("revision %s: %w", id, ErrNotFound)
}
return nil
}
func scanStackRevisionRow(row *sql.Row) (StackRevision, error) {
var r StackRevision
err := row.Scan(
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
)
return r, err
}
func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) {
var r StackRevision
err := rows.Scan(
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
)
if err != nil {
return StackRevision{}, fmt.Errorf("scan revision: %w", err)
}
return r, nil
}
// --- Stack deploys ---
const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at`
// CreateStackDeploy inserts a new deploy record.
func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) {
d.ID = uuid.New().String()
d.StartedAt = Now()
if d.Status == "" {
d.Status = "pending"
}
_, err := s.db.Exec(
`INSERT INTO stack_deploys (`+stackDeployCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt,
)
if err != nil {
return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err)
}
return d, nil
}
// GetStackDeployByID returns a single deploy by ID.
func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) {
d, err := scanStackDeployRow(s.db.QueryRow(
`SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound)
}
if err != nil {
return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err)
}
return d, nil
}
// UpdateStackDeploy updates status, log, error, finished_at.
func (s *Store) UpdateStackDeploy(d StackDeploy) error {
result, err := s.db.Exec(
`UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`,
d.Status, d.Log, d.Error, d.FinishedAt, d.ID,
)
if err != nil {
return fmt.Errorf("update stack deploy: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound)
}
return nil
}
func scanStackDeployRow(row *sql.Row) (StackDeploy, error) {
var d StackDeploy
err := row.Scan(
&d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt,
)
return d, err
}
-112
View File
@@ -1,112 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStageEnv inserts a new stage environment variable override.
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
env.ID = uuid.New().String()
env.CreatedAt = Now()
env.UpdatedAt = env.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
env.ID, env.StageID, env.Key, env.Value, BoolToInt(env.Encrypted),
env.CreatedAt, env.UpdatedAt,
)
if err != nil {
return StageEnv{}, fmt.Errorf("insert stage env: %w", err)
}
return env, nil
}
// GetStageEnvByStageID returns all environment variable overrides for a stage.
func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) {
rows, err := s.db.Query(
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID,
)
if err != nil {
return nil, fmt.Errorf("query stage env: %w", err)
}
defer rows.Close()
envs := []StageEnv{}
for rows.Next() {
env, err := scanStageEnv(rows)
if err != nil {
return nil, err
}
envs = append(envs, env)
}
return envs, rows.Err()
}
// GetStageEnvByID returns a single stage env override by ID.
func (s *Store) GetStageEnvByID(id string) (StageEnv, error) {
var env StageEnv
var encrypted int
err := s.db.QueryRow(
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
FROM stage_env WHERE id = ?`, id,
).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
&env.CreatedAt, &env.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound)
}
if err != nil {
return StageEnv{}, fmt.Errorf("query stage env: %w", err)
}
env.Encrypted = encrypted != 0
return env, nil
}
// UpdateStageEnv updates an existing stage environment variable override.
func (s *Store) UpdateStageEnv(env StageEnv) error {
env.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
env.Key, env.Value, BoolToInt(env.Encrypted), env.UpdatedAt, env.ID,
)
if err != nil {
return fmt.Errorf("update stage env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound)
}
return nil
}
// DeleteStageEnv removes a stage env override by ID.
func (s *Store) DeleteStageEnv(id string) error {
result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stage env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage env %s: %w", id, ErrNotFound)
}
return nil
}
// scanStageEnv scans a stage env row from a *sql.Rows cursor.
func scanStageEnv(rows *sql.Rows) (StageEnv, error) {
var env StageEnv
var encrypted int
err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
&env.CreatedAt, &env.UpdatedAt)
if err != nil {
return StageEnv{}, fmt.Errorf("scan stage env: %w", err)
}
env.Encrypted = encrypted != 0
return env, nil
}
-168
View File
@@ -1,168 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String()
st.CreatedAt = Now()
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.NotificationSecret,
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err)
}
return st, nil
}
// GetStagesByProjectID returns all stages for a given project.
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
rows, err := s.db.Query(
`SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query stages: %w", err)
}
defer rows.Close()
stages := []Stage{}
for rows.Next() {
st, err := scanStage(rows)
if err != nil {
return nil, err
}
stages = append(stages, st)
}
return stages, rows.Err()
}
// GetStageByID returns a single stage by its ID.
func (s *Store) GetStageByID(id string) (Stage, error) {
var st Stage
var autoDeploy, confirm, enableProxy int
err := s.db.QueryRow(
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
if err != nil {
return Stage{}, fmt.Errorf("query stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil
}
// UpdateStage updates an existing stage's mutable fields.
func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=?
WHERE id=?`,
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
)
// notification_secret is intentionally not updated here — use the
// dedicated SetStageNotificationSecret rotation helper.
if err != nil {
return fmt.Errorf("update stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound)
}
return nil
}
// DeleteStage removes a stage by ID. Cascading deletes handle child instances.
func (s *Store) DeleteStage(id string) error {
result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this stage (notifications
// still send unsigned, falling through to project/global resolution).
func (s *Store) SetStageNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set stage notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
stage, err := s.GetStageByID(id)
if err != nil {
return "", err
}
if stage.NotificationSecret != "" {
return stage.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStageNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// BoolToInt converts a bool to an integer for SQLite storage.
func BoolToInt(b bool) int {
if b {
return 1
}
return 0
}
// scanStage scans a stage row from a *sql.Rows cursor.
func scanStage(rows *sql.Rows) (Stage, error) {
var st Stage
var autoDeploy, confirm, enableProxy int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil
}
-112
View File
@@ -1,112 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStaticSiteSecret inserts a new secret for a static site.
func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) {
secret.ID = uuid.New().String()
secret.CreatedAt = Now()
secret.UpdatedAt = secret.CreatedAt
_, err := s.db.Exec(
`INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
secret.ID, secret.SiteID, secret.Key, secret.Value,
BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt,
)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err)
}
return secret, nil
}
// GetStaticSiteSecretsBySiteID returns all secrets for a static site.
func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) {
rows, err := s.db.Query(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID,
)
if err != nil {
return nil, fmt.Errorf("query static site secrets: %w", err)
}
defer rows.Close()
secrets := []StaticSiteSecret{}
for rows.Next() {
secret, err := scanStaticSiteSecret(rows)
if err != nil {
return nil, err
}
secrets = append(secrets, secret)
}
return secrets, rows.Err()
}
// GetStaticSiteSecretByID returns a single secret by ID.
func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := s.db.QueryRow(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE id = ?`, id,
).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
// UpdateStaticSiteSecret updates an existing secret.
func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error {
secret.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID,
)
if err != nil {
return fmt.Errorf("update static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound)
}
return nil
}
// DeleteStaticSiteSecret removes a secret by ID.
func (s *Store) DeleteStaticSiteSecret(id string) error {
result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
return nil
}
// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor.
func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
-502
View File
@@ -1,502 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// staticSiteCols is the column list for static_sites queries.
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
storage_enabled, storage_limit_mb,
webhook_secret, webhook_signing_secret, webhook_require_signature,
notification_url, notification_secret,
created_at, updated_at`
// UpsertStaticSiteWithID inserts or replaces a static site, keeping the
// caller-supplied ID. Used by the plugin static-source Backend adapter
// to keep a phantom row keyed on the workload ID so staticsite.Manager
// (which reads from this table) can serve plugin-native workloads
// without being refactored. Skips workload-row sync since the caller
// already owns the workload row.
func (s *Store) UpsertStaticSiteWithID(site StaticSite) error {
if site.ID == "" {
return fmt.Errorf("UpsertStaticSiteWithID: id is required")
}
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
}
if site.SyncTrigger == "" {
site.SyncTrigger = "manual"
}
if site.Mode == "" {
site.Mode = "static"
}
if site.Branch == "" {
site.Branch = "main"
}
if site.Status == "" {
site.Status = "idle"
}
now := Now()
site.UpdatedAt = now
if site.CreatedAt == "" {
site.CreatedAt = now
}
_, err := s.db.Exec(
`INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return fmt.Errorf("upsert static site: %w", err)
}
return nil
}
// CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input. Site row
// + matching workload row are written in a single transaction.
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
site.ID = uuid.New().String()
site.CreatedAt = Now()
site.UpdatedAt = site.CreatedAt
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
} else if len(site.WebhookSecret) < minWebhookSecretLength {
return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
tx, err := s.db.Begin()
if err != nil {
return StaticSite{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
); err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
}
if err := SyncStaticSiteWorkloadTx(tx, site); err != nil {
return StaticSite{}, err
}
if err := tx.Commit(); err != nil {
return StaticSite{}, fmt.Errorf("commit: %w", err)
}
return site, nil
}
// GetStaticSiteByID returns a single static site by its ID.
func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) {
site, err := scanStaticSiteRow(s.db.QueryRow(
`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSite{}, fmt.Errorf("query static site: %w", err)
}
return site, nil
}
// GetAllStaticSites returns every static site ordered by name.
func (s *Store) GetAllStaticSites() ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static sites: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// GetStaticSitesByRepo returns all static sites for a given repo owner/name.
func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT `+staticSiteCols+`
FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ?
ORDER BY name`,
giteaURL, owner, name,
)
if err != nil {
return nil, fmt.Errorf("query static sites by repo: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// updateStaticSiteAndSyncWorkloadTx wraps a parameterized UPDATE on
// static_sites with the workload sync, all inside a single transaction.
// updateSQL must end with `WHERE id=?`; args end with the site ID.
func (s *Store) updateStaticSiteAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("update static site: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
row := tx.QueryRow(`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id)
current, err := scanStaticSiteRowFromQuery(row)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
if err := SyncStaticSiteWorkloadTx(tx, current); err != nil {
return err
}
return tx.Commit()
}
// scanStaticSiteRowFromQuery is a thin wrapper around scanStaticSiteRow that
// accepts a *sql.Row from either s.db or a transaction. Kept private so the
// public surface stays narrow.
func scanStaticSiteRowFromQuery(row *sql.Row) (StaticSite, error) {
return scanStaticSiteRow(row)
}
// UpdateStaticSite updates an existing static site's configuration fields.
// notification_secret is intentionally not updated here — use the dedicated
// SetStaticSiteNotificationSecret rotation helper.
func (s *Store) UpdateStaticSite(site StaticSite) error {
site.UpdatedAt = Now()
return s.updateStaticSiteAndSyncWorkloadTx(site.ID,
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
notification_url=?, updated_at=?
WHERE id=?`,
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.NotificationURL, site.UpdatedAt, site.ID,
)
}
// UpdateStaticSiteStatus updates the deployment status fields.
func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=?
WHERE id=?`,
status, commitSHA, now, errMsg, now, id,
)
if err != nil {
return fmt.Errorf("update static site status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment.
func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`,
containerID, proxyRouteID, now, id,
)
if err != nil {
return fmt.Errorf("update static site container: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
// shaped to match the unified ProxyRoute model used by the Proxies page.
// Sites without an active proxy route are skipped.
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
FROM static_sites
WHERE proxy_route_id != ''
ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static site proxy routes: %w", err)
}
defer rows.Close()
suffix := ""
if domain != "" {
suffix = "." + strings.ToLower(domain)
}
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var mode, provider, fullDomain string
if err := rows.Scan(
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan static site proxy route: %w", err)
}
r.Source = "static_site"
r.StageName = mode
r.ImageTag = provider
r.Domain = fullDomain
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
} else {
r.Subdomain = fullDomain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// DeleteStaticSite removes a static site by ID. Cascading deletes handle
// secrets. Site + workload + container index rows are dropped atomically.
func (s *Store) DeleteStaticSite(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindSite), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup site workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete static site containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete static site workload: %w", err)
}
}
return tx.Commit()
}
// scanStaticSiteRow scans a static site from a *sql.Row.
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled, requireSig int
err := row.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, err
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled, requireSig int
err := rows.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret.
// Pass an empty string to clear it (also implicitly disables enforcement).
func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or
// invalidly-signed) inbound webhook requests are rejected with 401.
func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) error {
v := 0
if require {
v = 1
}
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
}
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
// signing secret. Empty string disables HMAC signing for this site
// (notifications still send unsigned, falling through to global resolution).
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
// signing secret, generating one lazily if missing.
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
site, err := s.GetStaticSiteByID(id)
if err != nil {
return "", err
}
if site.NotificationSecret != "" {
return site.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
st, err := s.GetSettings()
if err != nil {
return "", err
}
if st.NotificationSecret != "" {
return st.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetSettingsNotificationSecret(secret); err != nil {
return "", err
}
return secret, nil
}
// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret.
// Returns ErrNotFound if no site has this secret (including empty).
func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) {
if secret == "" {
return StaticSite{}, ErrNotFound
}
site, err := scanStaticSiteRow(s.db.QueryRow(
`SELECT `+staticSiteCols+` FROM static_sites WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return StaticSite{}, ErrNotFound
}
if err != nil {
return StaticSite{}, fmt.Errorf("query static site by webhook secret: %w", err)
}
return site, nil
}
// SetStaticSiteWebhookSecret assigns a webhook secret to a static site.
// Pass an empty string to disable webhook access for the site.
func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error {
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
}
// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site,
// generating one on the fly if the stored value is empty (lazy backfill).
func (s *Store) EnsureStaticSiteWebhookSecret(id string) (string, error) {
site, err := s.GetStaticSiteByID(id)
if err != nil {
return "", err
}
if site.WebhookSecret != "" {
return site.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
+39 -269
View File
@@ -97,97 +97,44 @@ func (s *Store) migrate() error {
}
// runMigrations applies additive schema changes that cannot be expressed
// with CREATE TABLE IF NOT EXISTS.
// with CREATE TABLE IF NOT EXISTS, plus the hard-cutover drops that
// remove every legacy project/stage/stack/static_site/deploy table.
func (s *Store) runMigrations() error {
migrations := []string{
// Add owner column to registries (2026-03-28).
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
// Add base_volume_path to settings (2026-03-28).
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
// Settings column adds that survive the cutover. SQLite is tolerant
// of "duplicate column" errors at the apply step, so re-running on
// a fully-migrated DB is a no-op.
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
// Add enable_proxy to stages (2026-03-29). Default true for backwards compat.
`ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`,
// Add ssl_certificate_id to settings (2026-03-29).
`ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`,
// Add stale_threshold_days to settings (2026-03-30).
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
// Add last_alive_at to instances for stale container detection (2026-03-30).
`ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`,
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
// Add DNS management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
// Add backup management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
// Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04).
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
// Add Traefik provider settings (2026-04-04).
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
// NPM remote mode: forward to server_ip instead of container name.
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
// Resource limits per stage.
`ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`,
`ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`,
// NPM access list support (global default + per-project override).
`ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
// Separate public IP for DNS A records.
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
// Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled.
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
// Add provider column to static_sites (2026-04-11).
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
// Add persistent storage columns to static_sites (2026-04-12).
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
// Per-project + per-site webhook secrets (2026-04-23). Global
// settings.webhook_secret is deprecated; its column is retained to
// avoid a destructive migration on SQLite.
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
// Resource metrics collection (2026-04-24). Interval in seconds,
// retention in hours. 0 in either disables collection.
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
// tokens (matches the inbound webhook_secret pattern). Empty = no
// signing; existing rows stay unsigned on upgrade for back-compat.
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
// Auto-backup before deploy (2026-05-07). When enabled, the deployer
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
// so a corrupted deploy is recoverable without data loss.
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
// Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret
// is the HMAC-SHA256 key separate from the URL secret so a leaked URL
// alone is not sufficient to forge a valid request. require_signature
// rejects unsigned requests when set (defense-in-depth opt-in).
`ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
// Webhook delivery audit log (2026-05-07). Persists every inbound
// webhook request (project or site) with its outcome so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
// Registries — owner column.
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
// Webhook delivery audit log persists every inbound webhook
// request so operators can debug "why didn't my deploy fire?"
// without grepping daemon logs.
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
@@ -203,19 +150,36 @@ func (s *Store) runMigrations() error {
)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
// Add stage_id to containers (2026-05-09). Backfill via the deployer
// re-write path; the LEFT JOIN in ListContainersByStageID falls back
// to (project_id, role=stage_name) so legacy rows still resolve.
// Containers — stage_id is now an opaque string set by the source
// plugin (image plugin uses it for the deploy-target tag). No FK
// semantics: the legacy `stages` table this column once joined to
// is gone; the column is just a free-form discriminator the
// proxies / dashboard views read to disambiguate sibling rows.
`ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`,
// Workload-first refactor columns (2026-05-10). Land additively so
// the legacy kind/ref_id columns continue to serve existing
// project/stack/site rows during cutover.
// Workload-first refactor columns. Land additively so old databases
// (which have a bare workloads table) pick them up on the next boot.
`ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE
// IF EXISTS is a no-op once the table is gone. Operators upgrading
// from a pre-cutover build will lose any project / stack / static
// site rows; the upgrade notes call this out explicitly.
`DROP TABLE IF EXISTS deploy_logs`,
`DROP TABLE IF EXISTS deploys`,
`DROP TABLE IF EXISTS stage_env`,
`DROP TABLE IF EXISTS stages`,
`DROP TABLE IF EXISTS poll_states`,
`DROP TABLE IF EXISTS volumes`,
`DROP TABLE IF EXISTS static_site_secrets`,
`DROP TABLE IF EXISTS static_sites`,
`DROP TABLE IF EXISTS stack_deploys`,
`DROP TABLE IF EXISTS stack_revisions`,
`DROP TABLE IF EXISTS stacks`,
`DROP TABLE IF EXISTS projects`,
}
// Workload refactor tables (2026-05-09). Workload is the unifying primitive
@@ -369,46 +333,6 @@ func (s *Store) runMigrations() error {
}
}
stackTables := []string{
`CREATE TABLE IF NOT EXISTS stacks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
compose_project_name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'stopped',
error TEXT NOT NULL DEFAULT '',
current_revision_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS stack_revisions (
id TEXT PRIMARY KEY,
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,
yaml TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
deploy_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(stack_id, revision)
)`,
`CREATE TABLE IF NOT EXISTS stack_deploys (
id TEXT PRIMARY KEY,
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
revision_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
log TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT NOT NULL DEFAULT ''
)`,
}
for _, t := range stackTables {
if _, err := s.db.Exec(t); err != nil {
return fmt.Errorf("create stack table: %w", err)
}
}
// Observability: event_triggers — consume EventLog entries off the
// bus and dispatch webhook actions. Schema kept flat (comma-list
// filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md.
@@ -469,34 +393,18 @@ func (s *Store) runMigrations() error {
}
}
// Create indexes on foreign key columns for query performance.
// Create indexes on foreign key columns for query performance. Only
// indexes targeting tables that still exist after the hard cutover.
indexes := []string{
// instances table dropped 2026-05-09 (workload refactor) — no indexes
// needed; containers replaces it with idx_containers_workload below.
`CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`,
`CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
`CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`,
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
// Drop the legacy instances table — containers is the canonical index
// after the workload refactor (2026-05-09). Idempotent: SQLite's
// DROP TABLE IF EXISTS is a no-op on databases that already shed it.
`DROP TABLE IF EXISTS instances`,
// Workload refactor indexes (2026-05-09).
// Workload refactor indexes.
`CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`,
`CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`,
@@ -508,7 +416,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
// Trigger-split indexes (2026-05-16).
// Trigger-split indexes.
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
@@ -520,19 +428,6 @@ func (s *Store) runMigrations() error {
}
}
// Data migration: copy mode→scope for volumes that have scope still empty.
// shared→project, isolated→instance. Log errors but don't fail startup.
dataMigrations := []struct{ query, desc string }{
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`, "migrate shared→project"},
{`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`, "migrate isolated→instance"},
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`, "migrate empty→project"},
}
for _, dm := range dataMigrations {
if _, err := s.db.Exec(dm.query); err != nil {
fmt.Printf("volume scope migration warning (%s): %v\n", dm.desc, err)
}
}
if err := s.backfillTriggersFromWorkloads(); err != nil {
slog.Warn("trigger backfill", "error", err)
}
@@ -658,42 +553,6 @@ func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config,
}
const schema = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
registry TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 0,
healthcheck TEXT NOT NULL DEFAULT '',
env TEXT NOT NULL DEFAULT '{}',
volumes TEXT NOT NULL DEFAULT '{}',
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS stages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tag_pattern TEXT NOT NULL DEFAULT '*',
auto_deploy INTEGER NOT NULL DEFAULT 0,
max_instances INTEGER NOT NULL DEFAULT 1,
confirm INTEGER NOT NULL DEFAULT 0,
enable_proxy INTEGER NOT NULL DEFAULT 1,
promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
cpu_limit REAL NOT NULL DEFAULT 0,
memory_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS registries (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
@@ -730,36 +589,6 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The instances table was removed in the workload refactor (2026-05-09).
-- Container state lives in the containers table; see runMigrations for the
-- current schema. The DROP TABLE migration runs unconditionally on boot.
CREATE TABLE IF NOT EXISTS deploys (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS deploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE,
message TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS poll_states (
stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE,
last_tag TEXT NOT NULL DEFAULT '',
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -785,27 +614,6 @@ INSERT OR IGNORE INTO settings (id) VALUES (1);
-- Seed the auth_settings row if it does not exist.
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS stage_env (
id TEXT PRIMARY KEY,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(stage_id, key)
);
CREATE TABLE IF NOT EXISTS volumes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
source TEXT NOT NULL,
target TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'shared',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
@@ -845,44 +653,6 @@ CREATE TABLE IF NOT EXISTS backups (
backup_type TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL DEFAULT '',
gitea_url TEXT NOT NULL DEFAULT '',
repo_owner TEXT NOT NULL DEFAULT '',
repo_name TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT 'main',
folder_path TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'static',
render_markdown INTEGER NOT NULL DEFAULT 0,
sync_trigger TEXT NOT NULL DEFAULT 'manual',
tag_pattern TEXT NOT NULL DEFAULT '',
container_id TEXT NOT NULL DEFAULT '',
proxy_route_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle',
last_sync_at TEXT NOT NULL DEFAULT '',
last_commit_sha TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_site_secrets (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(site_id, key)
);
`
// Now returns the current time formatted for SQLite storage.
-236
View File
@@ -14,62 +14,6 @@ func newTestStore(t *testing.T) *Store {
return s
}
func TestCreateAndGetProject(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "test-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if p.ID == "" {
t.Fatal("project ID should be set")
}
got, err := s.GetProjectByID(p.ID)
if err != nil {
t.Fatalf("GetProjectByID: %v", err)
}
if got.Name != "test-project" {
t.Fatalf("got name %q, want %q", got.Name, "test-project")
}
}
func TestGetAllProjects(t *testing.T) {
s := newTestStore(t)
s.CreateProject(Project{Name: "bravo", Image: "img", Env: "{}", Volumes: "{}"})
s.CreateProject(Project{Name: "alpha", Image: "img", Env: "{}", Volumes: "{}"})
projects, err := s.GetAllProjects()
if err != nil {
t.Fatalf("GetAllProjects: %v", err)
}
if len(projects) != 2 {
t.Fatalf("expected 2 projects, got %d", len(projects))
}
// Should be ordered by name
if projects[0].Name != "alpha" {
t.Fatalf("expected first project 'alpha', got %q", projects[0].Name)
}
}
func TestDeleteProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "del-me", Image: "img", Env: "{}", Volumes: "{}"})
err := s.DeleteProject(p.ID)
if err != nil {
t.Fatalf("DeleteProject: %v", err)
}
_, err = s.GetProjectByID(p.ID)
if err == nil {
t.Fatal("expected error getting deleted project")
}
}
func TestCreateAndGetUser(t *testing.T) {
s := newTestStore(t)
@@ -110,80 +54,6 @@ func TestUserCount(t *testing.T) {
}
}
func TestCreateStageAndDeploy(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, err := s.CreateStage(Stage{
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 2,
})
if err != nil {
t.Fatalf("CreateStage: %v", err)
}
d, err := s.CreateDeploy(Deploy{
ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1.0",
})
if err != nil {
t.Fatalf("CreateDeploy: %v", err)
}
if d.Status != "pending" {
t.Fatalf("expected pending status, got %q", d.Status)
}
err = s.UpdateDeployStatus(d.ID, "success", "")
if err != nil {
t.Fatalf("UpdateDeployStatus: %v", err)
}
got, _ := s.GetDeployByID(d.ID)
if got.Status != "success" {
t.Fatalf("expected success, got %q", got.Status)
}
}
func TestGetDeploysByProjectID(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
for i := 0; i < 5; i++ {
_, err := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
if err != nil {
t.Fatalf("CreateDeploy %d: %v", i, err)
}
}
deploys, err := s.GetDeploysByProjectID(p.ID)
if err != nil {
t.Fatalf("GetDeploysByProjectID: %v", err)
}
if len(deploys) != 5 {
t.Fatalf("expected 5 deploys, got %d", len(deploys))
}
}
func TestGetRecentDeploys(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
for i := 0; i < 5; i++ {
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
}
// Limit to 2
deploys, err := s.GetRecentDeploys(2)
if err != nil {
t.Fatalf("GetRecentDeploys: %v", err)
}
if len(deploys) != 2 {
t.Fatalf("expected 2 deploys with limit, got %d", len(deploys))
}
}
func TestDeleteUser(t *testing.T) {
s := newTestStore(t)
@@ -199,27 +69,6 @@ func TestDeleteUser(t *testing.T) {
}
}
func TestUpdateProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "original", Image: "nginx", Env: "{}", Volumes: "{}"})
p.Name = "updated"
p.Image = "alpine"
err := s.UpdateProject(p)
if err != nil {
t.Fatalf("UpdateProject: %v", err)
}
got, _ := s.GetProjectByID(p.ID)
if got.Name != "updated" {
t.Fatalf("expected name 'updated', got %q", got.Name)
}
if got.Image != "alpine" {
t.Fatalf("expected image 'alpine', got %q", got.Image)
}
}
func TestUpdateUser(t *testing.T) {
s := newTestStore(t)
@@ -241,88 +90,3 @@ func TestUpdateUser(t *testing.T) {
}
}
func TestDeployLogs(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
d, _ := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
err := s.AppendDeployLog(d.ID, "pulling image", "info")
if err != nil {
t.Fatalf("AppendDeployLog: %v", err)
}
err = s.AppendDeployLog(d.ID, "something failed", "error")
if err != nil {
t.Fatalf("AppendDeployLog: %v", err)
}
logs, err := s.GetDeployLogs(d.ID)
if err != nil {
t.Fatalf("GetDeployLogs: %v", err)
}
if len(logs) != 2 {
t.Fatalf("expected 2 logs, got %d", len(logs))
}
if logs[0].Message != "pulling image" {
t.Fatalf("expected first log 'pulling image', got %q", logs[0].Message)
}
if logs[1].Level != "error" {
t.Fatalf("expected second log level 'error', got %q", logs[1].Level)
}
}
func TestGetStagesByProjectID(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
s.CreateStage(Stage{ProjectID: p.ID, Name: "prod", TagPattern: "v*"})
s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
stages, err := s.GetStagesByProjectID(p.ID)
if err != nil {
t.Fatalf("GetStagesByProjectID: %v", err)
}
if len(stages) != 2 {
t.Fatalf("expected 2 stages, got %d", len(stages))
}
// Ordered by name
if stages[0].Name != "dev" {
t.Fatalf("expected first stage 'dev', got %q", stages[0].Name)
}
}
func TestIsTerminalDeployStatus(t *testing.T) {
terminals := []string{"success", "failed", "rolled_back"}
for _, s := range terminals {
if !IsTerminalDeployStatus(s) {
t.Fatalf("expected %q to be terminal", s)
}
}
nonTerminals := []string{"pending", "pulling", "starting", "configuring_proxy", "health_checking"}
for _, s := range nonTerminals {
if IsTerminalDeployStatus(s) {
t.Fatalf("expected %q to be non-terminal", s)
}
}
}
func TestCascadeDeleteProjectRemovesStagesAndDeploys(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
err := s.DeleteProject(p.ID)
if err != nil {
t.Fatalf("DeleteProject: %v", err)
}
// Stage should be gone
_, err = s.GetStageByID(stage.ID)
if err == nil {
t.Fatal("expected stage to be deleted by cascade")
}
}
-119
View File
@@ -1,119 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// volumeColumns is the canonical column list for volume queries.
const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at`
// CreateVolume inserts a new volume configuration for a project.
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
vol.ID = uuid.New().String()
vol.CreatedAt = Now()
vol.UpdatedAt = vol.CreatedAt
// Default scope for backward compatibility.
if vol.Scope == "" {
switch vol.Mode {
case "isolated":
vol.Scope = "instance"
default:
vol.Scope = "project"
}
}
_, err := s.db.Exec(
`INSERT INTO volumes (`+volumeColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode,
vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt,
)
if err != nil {
return Volume{}, fmt.Errorf("insert volume: %w", err)
}
return vol, nil
}
// GetVolumesByProjectID returns all volume configurations for a project.
func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
rows, err := s.db.Query(
`SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query volumes: %w", err)
}
defer rows.Close()
vols := []Volume{}
for rows.Next() {
vol, err := scanVolume(rows)
if err != nil {
return nil, err
}
vols = append(vols, vol)
}
return vols, rows.Err()
}
// GetVolumeByID returns a single volume by its ID.
func (s *Store) GetVolumeByID(id string) (Volume, error) {
var vol Volume
err := s.db.QueryRow(
`SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id,
).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound)
}
if err != nil {
return Volume{}, fmt.Errorf("query volume: %w", err)
}
return vol, nil
}
// UpdateVolume updates an existing volume configuration.
func (s *Store) UpdateVolume(vol Volume) error {
vol.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=?
WHERE id=?`,
vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID,
)
if err != nil {
return fmt.Errorf("update volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound)
}
return nil
}
// DeleteVolume removes a volume configuration by ID.
func (s *Store) DeleteVolume(id string) error {
result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("volume %s: %w", id, ErrNotFound)
}
return nil
}
// scanVolume scans a volume row from a *sql.Rows cursor.
func scanVolume(rows *sql.Rows) (Volume, error) {
var vol Volume
err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
if err != nil {
return Volume{}, fmt.Errorf("scan volume: %w", err)
}
return vol, nil
}
+4 -4
View File
@@ -8,7 +8,7 @@ import (
// handler decides what to do so the row reflects the final outcome.
type WebhookDelivery struct {
ID int64 `json:"id"`
TargetType string `json:"target_type"` // "project" or "site"
TargetType string `json:"target_type"` // "trigger" today; legacy rows may carry "project" or "site"
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
ReceivedAt string `json:"received_at"`
@@ -38,9 +38,9 @@ func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error {
return nil
}
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a
// specific target. Used by the per-entity panel on the project / site detail
// pages.
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for
// a specific target. Used by the trigger detail panel after the legacy
// project / site detail pages were removed.
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
if limit <= 0 || limit > 200 {
limit = 50
-150
View File
@@ -1,150 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so
// CRUD callers can pass in either a transaction or the raw DB handle. Keeps
// the sync logic atomic with the parent row when wrapped in a Begin/Commit.
type dbExec interface {
Exec(query string, args ...any) (sql.Result, error)
QueryRow(query string, args ...any) *sql.Row
}
// syncWorkloadTx is the shared upsert path used by every kind-specific
// sync helper. Caller passes the kind, ref, and the projection of fields
// that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE
// constraint to decide INSERT vs UPDATE.
func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error {
now := Now()
requireInt := 0
if requireSig {
requireInt = 1
}
var existingID string
err := ex.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(kind), refID,
).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
_, err := ex.Exec(
`INSERT INTO workloads (id, kind, ref_id, name, app_id,
notification_url, notification_secret,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at)
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`,
uuid.New().String(), string(kind), refID, name,
notifURL, notifSecret, hookSecret, signSecret, requireInt,
now, now,
)
if err != nil {
return fmt.Errorf("insert %s workload: %w", kind, err)
}
return nil
}
if err != nil {
return fmt.Errorf("lookup %s workload: %w", kind, err)
}
_, err = ex.Exec(
`UPDATE workloads SET name=?,
notification_url=?, notification_secret=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID,
)
if err != nil {
return fmt.Errorf("update %s workload: %w", kind, err)
}
return nil
}
// SyncProjectWorkloadTx upserts the workload row paired with a project inside
// the caller's transaction. Used by CreateProject / UpdateProject /
// SetProject*Secret so the parent UPDATE and the workload sync share atomicity.
func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error {
return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkloadTx upserts the workload row paired with a stack inside the
// caller's transaction. Stacks don't carry notification or webhook config yet.
func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error {
return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkloadTx upserts the workload row paired with a static site
// inside the caller's transaction.
func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error {
return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// SyncProjectWorkload is the non-transactional convenience used by
// BackfillWorkloads (a boot-time, single-row, idempotent recovery pass).
// CRUD paths must use SyncProjectWorkloadTx instead, with their parent
// UPDATE inside the same transaction.
func (s *Store) SyncProjectWorkload(p Project) error {
return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStackWorkload(st Stack) error {
return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStaticSiteWorkload(site StaticSite) error {
return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// BackfillWorkloads scans every project / stack / static_site row and ensures
// each has a matching workload row. Called once at boot before HTTP starts so
// any pre-Workload-refactor data is upgraded transparently. Idempotent.
func (s *Store) BackfillWorkloads() error {
projects, err := s.GetAllProjects()
if err != nil {
return fmt.Errorf("backfill: list projects: %w", err)
}
for _, p := range projects {
if err := s.SyncProjectWorkload(p); err != nil {
return fmt.Errorf("backfill project %s: %w", p.ID, err)
}
}
stacks, err := s.GetAllStacks()
if err != nil {
return fmt.Errorf("backfill: list stacks: %w", err)
}
for _, st := range stacks {
if err := s.SyncStackWorkload(st); err != nil {
return fmt.Errorf("backfill stack %s: %w", st.ID, err)
}
}
sites, err := s.GetAllStaticSites()
if err != nil {
return fmt.Errorf("backfill: list static sites: %w", err)
}
for _, site := range sites {
if err := s.SyncStaticSiteWorkload(site); err != nil {
return fmt.Errorf("backfill static site %s: %w", site.ID, err)
}
}
return nil
}
-190
View File
@@ -1,190 +0,0 @@
package store
import (
"errors"
"testing"
)
func TestCreateProjectAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "wf-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
NotificationURL: "https://example.test/hook",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err != nil {
t.Fatalf("workload should exist after CreateProject: %v", err)
}
if w.Name != "wf-project" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
if w.WebhookSecret == "" {
t.Fatal("webhook secret should be carried into workload row")
}
if w.NotificationURL != "https://example.test/hook" {
t.Fatalf("notification url not synced: got %q", w.NotificationURL)
}
}
func TestUpdateProjectSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "before", Image: "i", Env: "{}", Volumes: "{}",
})
p.Name = "after"
p.NotificationURL = "https://new.test/hook"
if err := s.UpdateProject(p); err != nil {
t.Fatalf("UpdateProject: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
if w.NotificationURL != "https://new.test/hook" {
t.Fatalf("workload notification url not updated: got %q", w.NotificationURL)
}
}
func TestDeleteProjectCascadesWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "doomed", Image: "i", Env: "{}", Volumes: "{}"})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
// Add a container under this workload to verify cascade.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID, WorkloadKind: "project", State: "running",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
if err := s.DeleteProject(p.ID); err != nil {
t.Fatalf("DeleteProject: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
containers, _ := s.ListContainersByWorkload(w.ID)
if len(containers) != 0 {
t.Fatalf("containers should be deleted, got %d", len(containers))
}
}
func TestSetProjectWebhookSecretSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "n", Image: "i", Env: "{}", Volumes: "{}"})
newSecret := "new-secret-value-with-enough-entropy-1234"
if err := s.SetProjectWebhookSecret(p.ID, newSecret); err != nil {
t.Fatalf("SetProjectWebhookSecret: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.WebhookSecret != newSecret {
t.Fatalf("workload webhook secret not synced: got %q", w.WebhookSecret)
}
}
func TestCreateStackAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
st, err := s.CreateStack(Stack{Name: "wf-stack", ComposeProjectName: "wf-stack"})
if err != nil {
t.Fatalf("CreateStack: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err != nil {
t.Fatalf("workload should exist after CreateStack: %v", err)
}
if w.Name != "wf-stack" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
}
func TestUpdateStackSyncsWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "before", ComposeProjectName: "before-cp"})
st.Name = "after"
if err := s.UpdateStack(st); err != nil {
t.Fatalf("UpdateStack: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
}
func TestDeleteStackCascadesWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "doomed-stack", ComposeProjectName: "doomed-cp"})
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err := s.DeleteStack(st.ID); err != nil {
t.Fatalf("DeleteStack: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
}
func TestBackfillWorkloadsIdempotent(t *testing.T) {
s := newTestStore(t)
// Create rows directly via the store (which already auto-syncs), then run
// the backfill twice — it must be a no-op the second time and not error.
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
st, _ := s.CreateStack(Stack{Name: "s1", ComposeProjectName: "s1-cp"})
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("first backfill: %v", err)
}
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("second backfill (should be idempotent): %v", err)
}
all, _ := s.ListWorkloads("")
// Expect exactly 2: one project workload, one stack workload, no duplicates.
if len(all) != 2 {
t.Fatalf("expected 2 workloads after backfill, got %d", len(all))
}
// Confirm both refs are findable.
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("project workload not found: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID); err != nil {
t.Fatalf("stack workload not found: %v", err)
}
}
func TestBackfillRecoversFromMissingWorkloads(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
// Simulate the legacy state: a project exists but its workload row is gone
// (e.g. the rollout from before the refactor). Backfill must restore it.
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
_ = s.DeleteWorkload(w.ID)
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("backfill: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("workload should be restored: %v", err)
}
}
+6 -52
View File
@@ -93,24 +93,6 @@ func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, err
return w, nil
}
// GetWorkloadByWebhookSecret looks up a workload by its inbound webhook URL secret.
// Returns ErrNotFound when no match — used by the webhook router.
func (s *Store) GetWorkloadByWebhookSecret(secret string) (Workload, error) {
if secret == "" {
return Workload{}, fmt.Errorf("empty secret: %w", ErrNotFound)
}
w, err := scanWorkload(s.db.QueryRow(
`SELECT `+workloadColumns+` FROM workloads WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return Workload{}, ErrNotFound
}
if err != nil {
return Workload{}, fmt.Errorf("query workload by webhook secret: %w", err)
}
return w, nil
}
// ListWorkloads returns all workloads, optionally filtered by kind. Pass
// empty string to get every workload regardless of kind.
func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
@@ -231,40 +213,12 @@ func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) {
return out, rows.Err()
}
// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass
// empty to disable inbound webhooks for this workload.
func (s *Store) SetWorkloadWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update workload webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureWorkloadWebhookSecret returns the current secret, generating one
// lazily for workloads that predate the column. Mirrors the project /
// site equivalents.
func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) {
w, err := s.GetWorkloadByID(id)
if err != nil {
return "", err
}
if w.WebhookSecret != "" {
return w.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetWorkloadWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// Workload-level webhook secret accessors (Get/Set/Ensure) were dropped
// in the hard legacy cutover: the inbound `/api/webhook/workloads/...`
// route is gone. The trigger-split refactor's boot backfill still reads
// the `workloads.webhook_secret` column directly via SQL to lift any
// pre-cutover embedded secret onto its standalone Trigger row, then the
// column is effectively dead.
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
// Idempotent — returns nil if no row exists, since the kind-specific Delete
+3 -22
View File
@@ -84,28 +84,9 @@ func TestUpdateWorkload(t *testing.T) {
}
}
func TestGetWorkloadByWebhookSecret(t *testing.T) {
s := newTestStore(t)
w, _ := s.CreateWorkload(Workload{
Kind: "project", RefID: "p1", Name: "n", WebhookSecret: "deadbeef",
})
got, err := s.GetWorkloadByWebhookSecret("deadbeef")
if err != nil {
t.Fatalf("GetWorkloadByWebhookSecret: %v", err)
}
if got.ID != w.ID {
t.Fatalf("got workload %s, want %s", got.ID, w.ID)
}
if _, err := s.GetWorkloadByWebhookSecret(""); !errors.Is(err, ErrNotFound) {
t.Fatalf("empty secret should be NotFound, got %v", err)
}
if _, err := s.GetWorkloadByWebhookSecret("nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("unknown secret should be NotFound, got %v", err)
}
}
// GetWorkloadByWebhookSecret was deleted with the legacy
// `/api/webhook/workloads/{secret}` route in the hard cutover; the
// inbound webhook surface is now first-class Triggers.
func TestListWorkloads(t *testing.T) {
s := newTestStore(t)