216 lines
8.0 KiB
Go
216 lines
8.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, context_digest)
|
|
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, NULLIF($9, ''))`
|
|
_, err := s.db.ExecContext(ctx, q,
|
|
b.ID, b.ImageRef, string(b.Status), b.Dockerfile, b.ContextRef, b.BaseImage,
|
|
b.RequestedBy, b.CreatedAt, b.ContextDigest)
|
|
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, context_digest
|
|
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
|
|
digest sql.NullString
|
|
)
|
|
switch err := row.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
|
|
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); {
|
|
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
|
|
b.ContextDigest = digest.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, context_digest
|
|
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
|
|
digest sql.NullString
|
|
)
|
|
if err := rows.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
|
|
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); 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
|
|
b.ContextDigest = digest.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 this demote branch is REACHABLE for today's recommended rows: 0021
|
|
// re-pointed the seeds at host-qualified registry refs (registry.<ns>.svc:5000/…),
|
|
// which ValidateImageRef accepts — so an admin re-admitting one of those refs
|
|
// demotes the curated row, by design. It was dead only while the seeds were bare
|
|
// local containerd tags (0018's felis-lobby:demo), which the validation refuses.
|
|
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
|
|
}
|