Files
tiny-forge/internal/store/models.go
T
alexei.dolgolyov 0c4c338bfe feat(apps): per-workload deploy history, rollback, and resource metrics
Two additions to the app detail page, each backed by a per-workload
endpoint.

Deploy history + rollback:
- New deploy_history table — a structured, version-pinned ledger of every
  dispatch (success AND failure), distinct from the free-text event_log.
  Recorded at the single DispatchPlugin choke point so every source kind
  is covered. The raw deploy error is never persisted (it can carry
  registry-auth / compose-stdout secrets) — only a generic marker, with
  detail going to slog. Pruned to the newest N per workload; cascade-
  deleted with the workload.
- GET /api/workloads/{id}/deploys lists the ledger; POST .../rollback
  (admin) replays a prior successful deploy's pinned reference as a
  rollback-reason dispatch. Phase 1 is image-source only (RollbackCapable);
  git-built sources need checkout-by-commit, a later phase.
- DeployHistoryPanel.svelte renders the ledger with confirm-gated rollback.

Per-workload metrics:
- ListContainerStatsSamplesByWorkload joins the existing container stats
  samples through the containers index; GET /api/workloads/{id}/stats/history
  aggregates CPU/memory per timestamp across the workload's containers.
- WorkloadMetricsPanel.svelte reuses ResourceChart (CPU% + memory MiB,
  windowed, 15s poll).

en/ru i18n added with parity. Tests: store CRUD + cascade + workload-scoped
join, deployer recording (incl. secret-non-leak on failure), API rollback
guards, and per-timestamp aggregation. Plans under docs/plans/.
2026-06-19 16:22:12 +03:00

535 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
// Registry represents a container image registry.
type Registry struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
Token string `json:"token"`
Owner string `json:"owner"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Settings holds global application configuration (single-row pattern).
type Settings struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"` // Docker host IP (for NPM remote forwarding)
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
BaseVolumePath string `json:"base_volume_path"`
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"`
NpmAccessListID int `json:"npm_access_list_id"`
ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"`
TraefikNetwork string `json:"traefik_network"`
TraefikAPIURL string `json:"traefik_api_url"`
ImagePruneThresholdMB int `json:"image_prune_threshold_mb"`
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
// AutoBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB backup
// at the start of every project deploy. Independent of BackupEnabled
// (which governs the periodic auto-backup cron).
AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"`
}
// ContainerStatsSample is one persisted sample of container resource usage.
// Cumulative counters (network, block I/O) require differencing two samples
// to get rates; CPU is already a percent-since-previous-sample value.
type ContainerStatsSample struct {
ContainerID string `json:"container_id"`
OwnerType string `json:"owner_type"` // "instance" or "site"
OwnerID string `json:"owner_id"`
TS int64 `json:"ts"` // Unix seconds UTC
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"`
}
// SystemStatsSample is one persisted host-level snapshot that aggregates
// workload usage across all containers plus daemon capacity + disk totals.
type SystemStatsSample struct {
TS int64 `json:"ts"` // Unix seconds UTC
NCPU int `json:"ncpu"`
MemoryTotal int64 `json:"memory_total"`
WorkloadCPUPercent float64 `json:"workload_cpu_percent"`
WorkloadMemUsage int64 `json:"workload_mem_usage"`
ContainersRunning int `json:"containers_running"`
DiskTotalBytes int64 `json:"disk_total_bytes"`
}
// Backup represents a backup metadata record.
type Backup struct {
ID string `json:"id"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
BackupType string `json:"backup_type"` // "manual" or "auto"
CreatedAt string `json:"created_at"`
}
// VolumeSnapshot is one captured archive of a workload's host-bind data
// volumes. Unlike Backup (global, SQLite-specific) it is per-workload and the
// archive is a tar.gz of the resolved volume directories. Manifest is a
// JSON-encoded []SnapshotVolume describing what the archive covers, so a
// future restore can re-resolve each target even if volume settings drift.
type VolumeSnapshot struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Label string `json:"label"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
Manifest string `json:"manifest"` // JSON []SnapshotVolume
CreatedAt string `json:"created_at"`
}
// DNSRecord tracks a DNS record managed by the application.
type DNSRecord struct {
ID string `json:"id"`
FQDN string `json:"fqdn"`
ProviderRecordID string `json:"provider_record_id"`
ConsumerType string `json:"consumer_type"` // "instance" or "standalone"
ConsumerID string `json:"consumer_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// 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"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
StageID string `json:"stage_id"`
StageName string `json:"stage_name"`
ImageTag string `json:"image_tag"`
Subdomain string `json:"subdomain"`
Domain string `json:"domain"`
ContainerID string `json:"container_id"`
Port int `json:"port"`
ProxyRouteID string `json:"proxy_route_id"`
NpmProxyID int `json:"npm_proxy_id"`
Status string `json:"status"`
CreatedAt string `json:"created_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
// project_id assumption is loosened.
type WorkloadVolume struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Source string `json:"source"`
Target string `json:"target"`
Scope string `json:"scope"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadEnv is the plugin-shape equivalent of StageEnv: per-workload
// environment variable overrides, optionally encrypted at rest. Read by
// the Source plugin at deploy time, merged on top of source_config.env.
type WorkloadEnv struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SharedSecret is an env var shared across workloads by scope. Resolved
// into a workload's container env as a low-precedence default (overridden
// by image cfg.Env and workload_env).
type SharedSecret struct {
ID string `json:"id"`
Name string `json:"name"` // the env KEY
Value string `json:"value"` // ciphertext when Encrypted; never returned decrypted by the API
Encrypted bool `json:"encrypted"`
Scope string `json:"scope"` // global | app
AppID string `json:"app_id"` // set when scope == app; "" for global
Description string `json:"description"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Shared-secret scope enum: a secret is either applied to every workload
// (global) or only to workloads whose app_id matches (app).
const (
SharedSecretScopeGlobal = "global"
SharedSecretScopeApp = "app"
)
// VolumeScope defines the sharing scope for a volume mount.
// Valid scopes: instance, stage, project, project_named, named, ephemeral.
type VolumeScope string
const (
VolumeScopeInstance VolumeScope = "instance"
VolumeScopeStage VolumeScope = "stage"
VolumeScopeProject VolumeScope = "project"
VolumeScopeProjectNamed VolumeScope = "project_named"
VolumeScopeNamed VolumeScope = "named"
VolumeScopeEphemeral VolumeScope = "ephemeral"
VolumeScopeAbsolute VolumeScope = "absolute"
)
// ValidVolumeScopes contains all valid scope values for validation.
var ValidVolumeScopes = []VolumeScope{
VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject,
VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral,
VolumeScopeAbsolute,
}
// IsValidVolumeScope returns true if the given string is a valid scope.
func IsValidVolumeScope(s string) bool {
for _, v := range ValidVolumeScopes {
if string(v) == s {
return true
}
}
return false
}
// EventLog represents a persistent event log entry.
type EventLog struct {
ID int64 `json:"id"`
Source string `json:"source"`
WorkloadID string `json:"workload_id"` // "" = unscoped (non-deploy events)
Severity string `json:"severity"` // info, warn, error
Message string `json:"message"`
Metadata string `json:"metadata"` // JSON-encoded structured data
CreatedAt string `json:"created_at"`
}
// EventTrigger is a filter+action rule evaluated against EventLog
// entries published on the bus. When all non-empty filters match, the
// trigger fires its configured action (webhook today, additional action
// types extensible via the ActionType enum).
//
// Filter fields use a comma-separated list shape for multi-value
// filters (severity, source) to keep the schema flat — empty string
// means "no filter on this dimension." FilterMessageRegex is a single
// regex evaluated against EventLog.Message.
//
// Loop-prevention: deliveries are recorded in webhook_deliveries (the
// existing audit trail). The dispatcher MUST NOT write to event_log
// or it will recurse.
type EventTrigger struct {
ID int64 `json:"id"`
Name string `json:"name"`
FilterSeverity string `json:"filter_severity"` // comma list: "warn,error"; "" = any
FilterSource string `json:"filter_source"` // comma list: "logscan,deploy"; "" = any
FilterMessageRegex string `json:"filter_message_regex"` // "" = any
ActionType string `json:"action_type"` // "webhook" today
ActionTarget string `json:"action_target"` // URL for webhook
ActionSecret string `json:"action_secret"` // optional HMAC secret for signed delivery
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// EventTriggerActionType enumerates the supported action_type values.
// Adding a new action is additive — old triggers keep working, the
// dispatcher just learns a new branch.
const (
EventTriggerActionWebhook = "webhook"
)
// LogScanRule is one regex-based pattern the log scanner evaluates
// against container log lines. The (workload_id, overrides_id) pair
// implements the "global rule with optional per-workload override"
// pattern documented in docs/LOGSCAN_AND_TRIGGERS_TODO.md:
//
// - WorkloadID == "" && OverridesID == 0 → global rule, applies to
// every workload unless overridden.
// - WorkloadID != "" && OverridesID == 0 → workload-only addition.
// - WorkloadID != "" && OverridesID != 0 → override of the named
// global rule for one workload (Enabled=false to disable globally
// for this workload).
type LogScanRule struct {
ID int64 `json:"id"`
WorkloadID string `json:"workload_id"` // "" = global
OverridesID int64 `json:"overrides_id"` // 0 = not an override
Name string `json:"name"`
Pattern string `json:"pattern"` // regex, compiled at load
Severity string `json:"severity"` // info|warn|error
Streams string `json:"streams"` // all|stdout|stderr
CooldownSeconds int `json:"cooldown_seconds"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Log scan stream filter values. "all" reads both streams; "stdout"
// or "stderr" filter to one. Used both for store validation and at
// docker-side log read time.
const (
LogScanStreamAll = "all"
LogScanStreamStdout = "stdout"
LogScanStreamStderr = "stderr"
)
// Log scan severity values mirror the event_log enum so a matched
// rule lands as an event_log row with the rule's severity verbatim.
const (
LogScanSeverityInfo = "info"
LogScanSeverityWarn = "warn"
LogScanSeverityError = "error"
)
// MetricAlertRule fires an event when a container metric breaches a
// threshold. Mirrors LogScanRule but evaluated against stats_samples
// instead of log lines.
type MetricAlertRule struct {
ID int64 `json:"id"`
WorkloadID string `json:"workload_id"` // "" = applies to all workloads
Name string `json:"name"`
Metric string `json:"metric"` // cpu_percent | memory_percent | memory_bytes
Comparator string `json:"comparator"` // gt | lt
Threshold float64 `json:"threshold"`
Severity string `json:"severity"` // info | warn | error
CooldownSeconds int `json:"cooldown_seconds"` // min seconds between fires per (rule,workload)
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Metric-alert metric identifiers. cpu_percent + memory_percent are
// 0100 ratios; memory_bytes is an absolute usage figure. Validated in
// the store on create/update.
const (
MetricCPUPercent = "cpu_percent"
MetricMemoryPercent = "memory_percent"
MetricMemoryBytes = "memory_bytes"
)
// Metric-alert comparators. gt fires when the value exceeds the
// threshold; lt when it falls below.
const (
MetricComparatorGT = "gt"
MetricComparatorLT = "lt"
)
// 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.
//
// `build` is the dockerfile-source kind: a container built from a
// Dockerfile in a Git repo. Operationally it looks like a site (one
// container, one optional public face) but its origin is the build
// pipeline, not a static-asset extract. Dashboard filters that need to
// distinguish "I built this from source" from "I served files from a
// repo" should key on this value.
type WorkloadKind string
const (
WorkloadKindProject WorkloadKind = "project"
WorkloadKindStack WorkloadKind = "stack"
WorkloadKindSite WorkloadKind = "site"
WorkloadKindBuild WorkloadKind = "build"
)
// Workload is the unifying primitive that abstracts Project, Stack, and StaticSite.
// Each row is paired with exactly one project/stack/site via (Kind, RefID).
// Notification + webhook config moves here so it lives in one place across kinds.
//
// SourceKind / SourceConfig / TriggerKind / TriggerConfig / PublicFaces /
// ParentWorkloadID populate the unified plugin model from the Workload-first
// refactor. Existing rows keep these empty until they are explicitly migrated
// or replaced — the legacy Kind/RefID columns continue to point at
// project/stack/site rows in parallel during the cutover.
type Workload struct {
ID string `json:"id"`
Kind string `json:"kind"` // project | stack | site (legacy discriminator)
RefID string `json:"ref_id"`
Name string `json:"name"`
AppID string `json:"app_id"` // nullable; "" = unassigned (a.k.a. GroupID after rename)
SourceKind string `json:"source_kind"` // "" until plugin-mode populated
SourceConfig string `json:"source_config"` // JSON-encoded, decoded by the matching Source
TriggerKind string `json:"trigger_kind"`
TriggerConfig string `json:"trigger_config"` // JSON-encoded, decoded by the matching Trigger
PublicFaces string `json:"public_faces"` // JSON-encoded []PublicFace
ParentWorkloadID string `json:"parent_workload_id"` // "" = root; non-empty = stage chain
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // never serialized
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadNotification is one configured outbound notification route for
// a workload. Multiple rows per workload model the "one Slack channel
// for failures, one Discord webhook for successes" routing the legacy
// single notification_url column could not express.
//
// EventTypes is a comma-separated allow-list (e.g. "build_failure" or
// "deploy_success,deploy_failure"). An empty EventTypes means the row
// fires for every event type — the cheapest way to keep the existing
// single-destination behaviour expressible in the new shape.
//
// Secret round-trips through the same crypto envelope as other stored
// secrets; the API layer strips it from responses.
type WorkloadNotification struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"-"`
EventTypes string `json:"event_types"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Container is the normalized index of every Tinyforge-managed container.
// Replaces the project-specific Instance table after migration. Subdomain/
// proxy fields are hoisted as first-class columns because ListProxyRoutes,
// stale detection, and dashboard queries filter on them frequently.
//
// StageID is populated by the deployer for project containers so ListProxyRoutes
// survives stage renames; it stays empty for stack and site rows.
type Container struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
WorkloadKind string `json:"workload_kind"` // denormalized for filtered queries
Role string `json:"role"` // stage name (project), service name (stack), '' (site)
StageID string `json:"stage_id"` // project containers only; '' otherwise
ContainerID string `json:"container_id"` // Docker container ID; '' between create+start
ImageRef string `json:"image_ref"` // "image:tag" as scheduled
ImageTag string `json:"image_tag"` // just the tag, for ListProxyRoutes
Host string `json:"host"`
State string `json:"state"` // running | stopped | failed | removing | missing
Port int `json:"port"`
Subdomain string `json:"subdomain"`
ProxyRouteID string `json:"proxy_route_id"`
NpmProxyID int `json:"npm_proxy_id"`
LastSeenAt string `json:"last_seen_at"`
// ExtraJSON carries source-specific metadata that isn't promoted to a
// first-class column — currently per-face proxy route IDs for
// multi-face image deploys. Stored as a JSON object; '{}' on empty
// rows. Sources own the shape; consumers should tolerate unknown
// keys.
ExtraJSON string `json:"extra_json"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Trigger is a first-class redeploy signal source. Triggers were embedded
// in workload rows (workload.trigger_kind / trigger_config) until the
// trigger-split refactor; they are now standalone records bound to
// workloads via WorkloadTriggerBinding so a single trigger (a webhook,
// registry watcher, schedule, git push) can fan out to many workloads.
//
// Webhook secrets live here, not on the workload — the inbound webhook
// URL identifies a trigger, which then resolves its bindings to decide
// which workloads to fire.
type Trigger struct {
ID string `json:"id"`
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
// LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched
// this trigger. Empty for never-fired or non-schedule triggers. The
// scheduler reads + writes this column to decide next-fire windows
// and to surface "last fired" on the trigger detail page.
LastFiredAt string `json:"last_fired_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is
// the per-binding override applied on top of Trigger.Config (top-level
// JSON merge: binding fields win). Empty BindingConfig means "use the
// trigger's config verbatim". Enabled false skips the binding without
// deleting it (useful for paused stages).
type WorkloadTriggerBinding struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
TriggerID string `json:"trigger_id"`
BindingConfig string `json:"binding_config"` // JSON-encoded; "{}" = none
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack).
// Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeployHistoryEntry is one row in the per-workload deploy ledger. Unlike
// event_log (free-text human timeline), this is the structured, version-
// pinned record the rollback action replays from. Reference is the
// effective deployed artifact handle (image tag for image sources, commit
// sha for git-built sources, "" when none applies). Error is NEVER the raw
// source error — that can carry registry-auth bytes or compose stdout; it
// holds only a fixed, secret-free marker. Raw detail goes to slog.
type DeployHistoryEntry struct {
ID int64 `json:"id"`
WorkloadID string `json:"workload_id"`
SourceKind string `json:"source_kind"`
Reference string `json:"reference"` // effective tag | commit sha | ""
Reason string `json:"reason"` // manual|registry-push|git-push|cron|rollback|promote
TriggeredBy string `json:"triggered_by"`
Note string `json:"note"`
Outcome string `json:"outcome"` // success | failure
Error string `json:"error"` // generic, secret-free marker on failure
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
// Rollbackable is computed at the API layer (not persisted): a row is
// rollbackable when it succeeded, has a non-empty Reference, and its
// source kind supports reference-pinned redeploy.
Rollbackable bool `json:"rollbackable"`
}