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