The create-server form has no way to tell a user which of the whitelisted images is a sensible starting point. Add 'recommended' as a third image_whitelist.source alongside 'built' and 'external', and seed it with the one image that has earned it. The marker is presentation only. ImageAdmitted still turns solely on enabled, so a recommended row is admitted by exactly the rule that governs every other row and carries no extra privilege; a test pins both halves, because the failure modes are silent and opposite — make admission source-aware and the curated images vanish from the form, or let curation bypass the disable switch and an admin who pulled an image finds it still creatable. Only one image is seeded, and the restraint is the point. Velocity runs proxy-wide modern forwarding, so a backend that cannot verify the signed handshake rejects every login the proxy sends it. The operator injects FELIS_FORWARDING_SECRET into every backend but cannot make an image consume it. An arbitrary public Minecraft image therefore passes admission, builds, schedules, reports Ready — and then refuses every join, with nothing in the server's status explaining why. Exactly two images read that variable, deploy/limbo and deploy/lobby; limbo is the login gate and is nonsense as a base for a user's server, which leaves lobby. The list grows when Felis ships another forwarding-aware image, not before. AdmitBuiltImage now preserves a 'recommended' source through its ON CONFLICT path. Rebuilding a curated tag is the expected way to patch it, and that rebuild arrives through this exact path, so a blind SET source = 'built' would demote the curation on the first rebuild with nothing in the request saying so. AddExternalImage deliberately does not preserve it: an admin POSTing the ref is an explicit, named re-admission, and the 201 body reports the Image it constructed without re-reading the row, so a sticky source there would report a value the database does not hold. The migration is idempotent via ON CONFLICT DO NOTHING, so an admin who disabled or re-pointed the row does not have that decision undone on the next apply.
212 lines
7.7 KiB
Go
212 lines
7.7 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).
|
|
//
|
|
// source is the ONE column the conflict path does not overwrite unconditionally:
|
|
// a 'recommended' row is platform curation (0018), while every other field here
|
|
// describes the build that just succeeded and must win. Rebuilding a curated tag
|
|
// is the expected way to patch it, and that rebuild arrives through this exact
|
|
// path — so a blind `SET source = 'built'` would silently demote the curation on
|
|
// the first rebuild, with nothing in the request saying so. Preserving it keeps
|
|
// the marker a deliberate admin decision: DELETE /images is the way to clear it,
|
|
// not a build completing. Only 'recommended' is sticky; an 'external' row still
|
|
// becomes 'built', because a real build genuinely supersedes a hand-pushed ref.
|
|
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 = CASE WHEN image_whitelist.source = 'recommended'
|
|
THEN 'recommended' ELSE 'built' END,
|
|
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()
|
|
}
|
|
|
|
// AddExternalImage upserts a hand-pushed ref. Unlike AdmitBuiltImage this path
|
|
// does NOT preserve a 'recommended' source, and the asymmetry is deliberate: an
|
|
// admin POSTing this exact ref is an explicit, named re-admission, not a build
|
|
// completing behind their back, so demoting the curated row is the outcome they
|
|
// asked for. It also keeps the 201 body honest — AddExternalImage returns the
|
|
// Image it constructed (source=external) without re-reading the row, so a sticky
|
|
// source here would report a value the database does not hold.
|
|
//
|
|
// Note that the demote branch is unreachable for the ONLY recommended row Felis
|
|
// currently seeds: the caller validates with ValidateImageRef first, which refuses
|
|
// a bare local containerd tag, and 0018's felis-lobby:demo is exactly that. The
|
|
// branch is written for the host-qualified recommendations this list grows into,
|
|
// not for today's single seed.
|
|
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
|
|
}
|