refactor(workload): extract Instance entirely; Container is canonical
Build / build (push) Successful in 10m41s

End-to-end extraction of the Instance concept. After this commit:

  * internal/store/instances.go — DELETED
  * internal/store/models.go — Instance struct gone, ProxyRoute moved here
  * containers table is the single source of truth for project/stack/site
    container state. instances table is dropped via DROP TABLE migration
    (idempotent; re-runnable on every boot).
  * Legacy tinyforge.project / tinyforge.stage / tinyforge.instance-id
    Docker labels are no longer emitted; only tinyforge.workload.{id,kind},
    tinyforge.role, and tinyforge.managed are stamped on new containers.

Backend rewrites:
  - internal/deployer:        executeDeploy + blueGreenDeploy + rollback +
                              promote use store.Container natively. New
                              removeContainer() replaces removeInstance().
                              enforceMaxInstances reads via
                              ListContainersByStageID.
  - internal/reconciler:      legacy tinyforge.instance-id dispatch removed;
                              upsertByWorkloadLabel now finds existing rows
                              by docker container ID first and falls back to
                              the deterministic workloadID:role key.
  - internal/stale/scanner:   Scan + new FindStaleContainers walk the
                              containers table; emit StaleContainer JSON.
  - internal/stats/collector: ListContainers replaces ListAllInstances.
  - internal/webhook/handler: workload-secret lookup tried first; falls back
                              to project / static_site secret column.
  - internal/api: instances.go, stale.go, stats.go, stats_history.go,
                  projects.go, settings.go, docker.go, dns.go all read /
                  write through Container.

Docker layer:
  - ManagedContainer exposes WorkloadID/Kind/Role from the canonical labels.
  - ListContainers filters by tinyforge.managed=true.
  - Network creation uses LabelManaged instead of LabelProject.

Frontend:
  - Instance type is now a Container alias; .status → .state,
    .last_alive_at → .last_seen_at.
  - InstanceCard takes stageId as a prop (no longer derived from Instance).
  - StaleContainer JSON shape rewritten: { container, workload_name, role,
    days_stale }. StaleContainerCard + /containers/stale page updated.
  - ProjectCard / homepage / SystemHealthCard filter by .state.

The migration loop now tolerates "no such table" alongside "duplicate
column" / "already exists" so obsolete ALTER TABLE entries targeting the
dropped instances table no-op cleanly on first boot.

Tests: store + deployer + reconciler + webhook + staticsite + notify all
still pass. Frontend svelte-check: zero errors.
This commit is contained in:
2026-05-09 14:43:12 +03:00
parent d516462750
commit d8ab22876f
32 changed files with 649 additions and 957 deletions
+71
View File
@@ -135,6 +135,77 @@ 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. Stage
// ID is resolved through a (project_id, role=stage_name) join, which is
// uniquely indexed via UNIQUE(project_id, name) on stages.
//
// 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,
c.image_tag, c.subdomain, c.container_id, c.port,
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
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.project_id = p.id AND s.name = c.role
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
ORDER BY p.name, s.name, c.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query proxy routes: %w", err)
}
defer rows.Close()
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
if err := rows.Scan(
&r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName,
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// ListContainersByStageID returns project containers for the given stage,
// newest first. Resolves stage → project_id → workload(kind=project) →
// containers with role = stage.name. Replaces GetInstancesByStageID for
// callers in the deployer / API layer.
func (s *Store) ListContainersByStageID(stageID string) ([]Container, error) {
rows, err := s.db.Query(`
SELECT `+prefixCols(containerColumns, "c.")+`
FROM containers c
JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project'
JOIN stages s ON s.project_id = w.ref_id AND s.name = c.role
WHERE s.id = ?
ORDER BY c.created_at DESC`, 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(
-251
View File
@@ -1,251 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// instanceColumns is the canonical column list for instance queries.
const instanceColumns = `id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, proxy_route_id, status, port, last_alive_at, created_at, updated_at`
// scanInstance scans a row into an Instance struct using the canonical column order.
func scanInstance(scanner interface{ Scan(...any) error }) (Instance, error) {
var inst Instance
err := scanner.Scan(
&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag,
&inst.Subdomain, &inst.NpmProxyID, &inst.ProxyRouteID, &inst.Status, &inst.Port,
&inst.LastAliveAt, &inst.CreatedAt, &inst.UpdatedAt,
)
return inst, err
}
// CreateInstance inserts a new instance record.
func (s *Store) CreateInstance(inst Instance) (Instance, error) {
inst.ID = uuid.New().String()
inst.CreatedAt = Now()
inst.UpdatedAt = inst.CreatedAt
_, err := s.db.Exec(
`INSERT INTO instances (`+instanceColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag,
inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port,
inst.LastAliveAt, inst.CreatedAt, inst.UpdatedAt,
)
if err != nil {
return Instance{}, fmt.Errorf("insert instance: %w", err)
}
return inst, nil
}
// CreateInstanceWithID inserts a new instance using a pre-generated ID.
// Use this when the ID must be known before creation (e.g., for container labels).
func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) {
if inst.ID == "" {
return Instance{}, fmt.Errorf("instance ID is required")
}
inst.CreatedAt = Now()
inst.UpdatedAt = inst.CreatedAt
_, err := s.db.Exec(
`INSERT INTO instances (`+instanceColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag,
inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port,
inst.LastAliveAt, inst.CreatedAt, inst.UpdatedAt,
)
if err != nil {
return Instance{}, fmt.Errorf("insert instance: %w", err)
}
return inst, nil
}
// GetInstanceByID returns a single instance by its ID.
func (s *Store) GetInstanceByID(id string) (Instance, error) {
inst, err := scanInstance(s.db.QueryRow(
`SELECT `+instanceColumns+` FROM instances WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return Instance{}, fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
if err != nil {
return Instance{}, fmt.Errorf("query instance: %w", err)
}
return inst, nil
}
// GetInstancesByStageID returns all instances for a given stage.
func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) {
rows, err := s.db.Query(
`SELECT `+instanceColumns+` FROM instances WHERE stage_id = ? ORDER BY created_at DESC`, stageID,
)
if err != nil {
return nil, fmt.Errorf("query instances: %w", err)
}
defer rows.Close()
instances := []Instance{}
for rows.Next() {
inst, err := scanInstance(rows)
if err != nil {
return nil, fmt.Errorf("scan instance: %w", err)
}
instances = append(instances, inst)
}
return instances, rows.Err()
}
// ListAllInstances returns all instances across all stages.
func (s *Store) ListAllInstances() ([]Instance, error) {
rows, err := s.db.Query(
`SELECT ` + instanceColumns + ` FROM instances ORDER BY created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query all instances: %w", err)
}
defer rows.Close()
instances := []Instance{}
for rows.Next() {
inst, err := scanInstance(rows)
if err != nil {
return nil, fmt.Errorf("scan instance: %w", err)
}
instances = append(instances, inst)
}
return instances, rows.Err()
}
// ProxyRoute represents a proxy-enabled resource (Docker instance or static site)
// joined with the human-readable names needed to render the Proxies page.
type ProxyRoute struct {
Source string `json:"source"` // "instance" or "static_site"
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"`
}
// ListProxyRoutes returns proxy-enabled project containers joined with
// project + stage names. Reads from the normalized containers index — the
// instances table is no longer queried. Stage ID is resolved through a
// (project_id, role=stage_name) join, which is uniquely indexed.
//
// Source is reported as "instance" for back-compat with the Proxies page
// filter (which 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,
c.image_tag, c.subdomain, c.container_id, c.port,
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
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.project_id = p.id AND s.name = c.role
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
ORDER BY p.name, s.name, c.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query proxy routes: %w", err)
}
defer rows.Close()
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
if err := rows.Scan(
&r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName,
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// UpdateInstance updates an existing instance's mutable fields.
func (s *Store) UpdateInstance(inst Instance) error {
inst.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, proxy_route_id=?, status=?, port=?, last_alive_at=?, updated_at=?
WHERE id=?`,
inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag,
inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port,
inst.LastAliveAt, inst.UpdatedAt, inst.ID,
)
if err != nil {
return fmt.Errorf("update instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", inst.ID, ErrNotFound)
}
return nil
}
// UpdateInstanceStatus sets only the status field on an instance.
func (s *Store) UpdateInstanceStatus(id string, status string) error {
ts := Now()
result, err := s.db.Exec(
`UPDATE instances SET status=?, updated_at=? WHERE id=?`,
status, ts, id,
)
if err != nil {
return fmt.Errorf("update instance status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
return nil
}
// UpdateLastAliveAt sets the last_alive_at timestamp for an instance.
// Called when an instance is seen running.
func (s *Store) UpdateLastAliveAt(id string) error {
ts := Now()
result, err := s.db.Exec(
`UPDATE instances SET last_alive_at=?, updated_at=? WHERE id=?`,
ts, ts, id,
)
if err != nil {
return fmt.Errorf("update last_alive_at: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteInstance removes an instance by ID.
func (s *Store) DeleteInstance(id string) error {
result, err := s.db.Exec(`DELETE FROM instances WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
return nil
}
+19 -14
View File
@@ -142,21 +142,26 @@ type DNSRecord struct {
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
// 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).
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"`
LastAliveAt string `json:"last_alive_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ProxyRouteID string `json:"proxy_route_id"`
NpmProxyID int `json:"npm_proxy_id"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// Deploy represents a deployment attempt.
+16 -19
View File
@@ -310,12 +310,15 @@ func (s *Store) runMigrations() error {
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
// "duplicate column" / "already exists" are expected when a
// migration has already been applied. Anything else (typo, FK
// conflict, real schema bug) must surface, otherwise the store
// silently runs against the wrong shape.
// migration has already been applied. "no such table" is
// expected for obsolete ALTER TABLE migrations targeting tables
// the workload refactor dropped (e.g. instances). Anything
// else must surface — silently running against the wrong shape
// is worse than a startup failure.
msg := err.Error()
if !strings.Contains(msg, "duplicate column") &&
!strings.Contains(msg, "already exists") {
!strings.Contains(msg, "already exists") &&
!strings.Contains(msg, "no such table") {
return fmt.Errorf("apply migration %q: %w", m, err)
}
}
@@ -323,8 +326,8 @@ func (s *Store) runMigrations() error {
// Create indexes on foreign key columns for query performance.
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_instances_stage_id ON instances(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_instances_project_id ON instances(project_id)`,
// 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)`,
@@ -344,6 +347,10 @@ func (s *Store) runMigrations() error {
`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).
`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 != ''`,
@@ -449,19 +456,9 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
container_id TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL,
subdomain TEXT NOT NULL DEFAULT '',
npm_proxy_id INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'stopped',
port INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
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,