Files
Felis/internal/build/pgstore.go
T
flyemoji 708cdfc5b8 feat(core): add naming, RCON, store, config, and image-build libraries
Foundational libraries: deterministic resource naming, the RCON client, the Postgres store with embedded SQL migrations, configuration loading, and container image-build helpers.
2026-06-26 23:31:58 +09:00

187 lines
6.0 KiB
Go

package build
import (
"context"
"database/sql"
"time"
)
// PGStore is the production Store backed by Postgres (spec §6, §16). It writes
// the two tables of the build subsystem — image_builds and image_whitelist —
// and is the *only* component that holds database credentials: the build Pod
// never does (the weak-SA red line). The SQL here is exercised by integration
// tests against a live database, not the hermetic build_test.go suite. Every
// statement is a narrow operation; there is no generic UPDATE escape hatch.
type PGStore struct {
db *sql.DB
}
// NewPGStore wraps an existing pool (from store.PostgresDriver.DB()).
func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
func (s *PGStore) CreateBuild(ctx context.Context, b *Build) error {
const q = `INSERT INTO image_builds
(id, image_ref, status, dockerfile, context_ref, base_image, requested_by, created_at)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8)`
_, err := s.db.ExecContext(ctx, q,
b.ID, b.ImageRef, string(b.Status), b.Dockerfile, b.ContextRef, b.BaseImage,
b.RequestedBy, b.CreatedAt)
return err
}
func (s *PGStore) GetBuild(ctx context.Context, id string) (*Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at
FROM image_builds WHERE id = $1`
return s.scanBuild(s.db.QueryRowContext(ctx, q, id))
}
func (s *PGStore) scanBuild(row *sql.Row) (*Build, error) {
var (
b Build
status string
ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime
)
switch err := row.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); {
case err == sql.ErrNoRows:
return nil, ErrNotFound
case err != nil:
return nil, err
}
b.Status = Status(status)
b.ContextRef = ctxRef.String
b.BaseImage = base.String
b.JobName = jobName.String
b.LogRef = logRef.String
b.Error = eMsg.String
if finished.Valid {
t := finished.Time
b.FinishedAt = &t
}
return &b, nil
}
func (s *PGStore) SetBuildJob(ctx context.Context, id, jobName string) error {
const q = `UPDATE image_builds SET job_name = $2, status = 'building'
WHERE id = $1 AND status = 'pending'`
res, err := s.db.ExecContext(ctx, q, id, jobName)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *PGStore) FinishBuild(ctx context.Context, id string, status Status, errMsg string, at time.Time) error {
const q = `UPDATE image_builds SET status = $2, error = NULLIF($3, ''), finished_at = $4
WHERE id = $1`
res, err := s.db.ExecContext(ctx, q, id, string(status), errMsg, at)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at
FROM image_builds WHERE status IN ('pending', 'building') ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Build
for rows.Next() {
var (
b Build
status string
ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime
)
if err := rows.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); err != nil {
return nil, err
}
b.Status = Status(status)
b.ContextRef = ctxRef.String
b.BaseImage = base.String
b.JobName = jobName.String
b.LogRef = logRef.String
b.Error = eMsg.String
if finished.Valid {
t := finished.Time
b.FinishedAt = &t
}
out = append(out, b)
}
return out, rows.Err()
}
// AdmitBuiltImage upserts the whitelist row on scan-gate success. ON CONFLICT
// re-enables and re-stamps a previously-removed or superseded ref, so a rebuild
// of the same tag re-admits it (spec §16).
func (s *PGStore) AdmitBuiltImage(ctx context.Context, img Image) error {
const q = `INSERT INTO image_whitelist
(image_ref, source, build_id, added_by, enabled, added_at)
VALUES ($1, 'built', NULLIF($2, ''), $3, true, $4)
ON CONFLICT (image_ref) DO UPDATE
SET source = 'built', build_id = EXCLUDED.build_id, added_by = EXCLUDED.added_by,
enabled = true, added_at = EXCLUDED.added_at`
_, err := s.db.ExecContext(ctx, q, img.ImageRef, img.BuildID, img.AddedBy, img.AddedAt)
return err
}
func (s *PGStore) ListImages(ctx context.Context) ([]Image, error) {
const q = `SELECT image_ref, source, build_id, added_by, enabled, added_at
FROM image_whitelist ORDER BY added_at DESC`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Image
for rows.Next() {
var (
img Image
buildID sql.NullString
)
if err := rows.Scan(&img.ImageRef, &img.Source, &buildID, &img.AddedBy,
&img.Enabled, &img.AddedAt); err != nil {
return nil, err
}
img.BuildID = buildID.String
out = append(out, img)
}
return out, rows.Err()
}
func (s *PGStore) AddExternalImage(ctx context.Context, img Image) error {
const q = `INSERT INTO image_whitelist
(image_ref, source, added_by, enabled, added_at)
VALUES ($1, 'external', $2, true, $3)
ON CONFLICT (image_ref) DO UPDATE
SET source = 'external', build_id = NULL, added_by = EXCLUDED.added_by,
enabled = true, added_at = EXCLUDED.added_at`
_, err := s.db.ExecContext(ctx, q, img.ImageRef, img.AddedBy, img.AddedAt)
return err
}
func (s *PGStore) RemoveImage(ctx context.Context, imageRef string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM image_whitelist WHERE image_ref = $1`, imageRef)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}