0405ecd9ce
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
323 lines
14 KiB
Go
323 lines
14 KiB
Go
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; never serialized directly
|
|
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"`
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// Instance represents a running (or stopped) container for a project stage.
|
|
type Instance struct {
|
|
ID string `json:"id"`
|
|
StageID string `json:"stage_id"`
|
|
ProjectID string `json:"project_id"`
|
|
ContainerID string `json:"container_id"`
|
|
ImageTag string `json:"image_tag"`
|
|
Subdomain string `json:"subdomain"`
|
|
NpmProxyID int `json:"npm_proxy_id"`
|
|
ProxyRouteID string `json:"proxy_route_id"`
|
|
Status string `json:"status"` // running, stopped, failed, removing
|
|
Port int `json:"port"`
|
|
LastAliveAt string `json:"last_alive_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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; never serialized directly
|
|
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"`
|
|
Source string `json:"source"`
|
|
Severity string `json:"severity"` // info, warn, error
|
|
Message string `json:"message"`
|
|
Metadata string `json:"metadata"` // JSON-encoded structured data
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|