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.
This commit is contained in:
18 files changed
+3475
No files matched your search
@@ -0,0 +1,505 @@
|
||||
// Package build implements the image build subsystem (spec §16) — "the
|
||||
// platform's biggest security surface". A SysAdmin uploads a Dockerfile and a
|
||||
// context tarball; felis-api starts an in-cluster Kaniko Job that builds and
|
||||
// pushes to the internal registry, after which a Trivy scan gates admission to
|
||||
// the image whitelist.
|
||||
//
|
||||
// Trust model (spec §16, §22): we trust the SysAdmin at the *ingress* (only an
|
||||
// admin through Zero Trust may submit a build) but never trust the *Dockerfile
|
||||
// at runtime* — an arbitrary Dockerfile is build-time RCE whose victim is the
|
||||
// cluster, not the uploader. So the build Pod runs with a deliberately weak
|
||||
// service account in an isolated namespace that can only push to the registry
|
||||
// and cannot touch the minecraft namespace, the felis database, or the K8s API
|
||||
// (spec §21). Those isolation guarantees live in the Job/NetworkPolicy specs
|
||||
// (jobspec.go) and are asserted by unit tests, since no cluster runs here.
|
||||
//
|
||||
// The Trivy gate is enforced as the build Pod's *exit code*: a kaniko
|
||||
// initContainer builds and pushes, then a trivy container scans the pushed ref
|
||||
// with `--exit-code 1 --severity CRITICAL`. Therefore "Job Succeeded" is
|
||||
// equivalent to "pushed AND no CRITICAL CVE". felis-api observes the Job phase
|
||||
// and performs the database writes — the build Pod itself never has database
|
||||
// credentials (the weak-SA red line). On success the image is admitted to
|
||||
// image_whitelist with enabled=true (recording added_by); on failure the build
|
||||
// is marked failed and nothing is admitted (spec §16: the only retained
|
||||
// automatic gate).
|
||||
//
|
||||
// The Builder depends on the Store and Jobs interfaces, so submission, the
|
||||
// scan-gate translation, cancellation, and image admission are all unit-tested
|
||||
// against in-memory fakes. The Postgres (pgStore) and controller-runtime
|
||||
// (k8sJobs) implementations compile here but are exercised only by integration
|
||||
// tests against a live database / cluster.
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status mirrors the build_status enum (spec §6).
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusBuilding Status = "building"
|
||||
StatusSucceeded Status = "succeeded"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// terminal reports whether a status is final and no longer reconciled.
|
||||
func (s Status) terminal() bool {
|
||||
switch s {
|
||||
case StatusSucceeded, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// JobPhase is the build Pod's lifecycle as observed from the K8s Job, decoupled
|
||||
// from any K8s type so the scan-gate translation stays unit-testable.
|
||||
type JobPhase int
|
||||
|
||||
const (
|
||||
// JobUnknown means the Job was not found (e.g. GC'd); treated as failed.
|
||||
JobUnknown JobPhase = iota
|
||||
JobPending
|
||||
JobRunning
|
||||
// JobSucceeded means kaniko pushed AND trivy found no CRITICAL CVE — the
|
||||
// scan gate passed (spec §16).
|
||||
JobSucceeded
|
||||
// JobFailed means kaniko failed OR trivy found a CRITICAL CVE — the build
|
||||
// is rejected and nothing is admitted.
|
||||
JobFailed
|
||||
)
|
||||
|
||||
// image admission sources (spec §6 image_whitelist.source).
|
||||
const (
|
||||
SourceBuilt = "built"
|
||||
SourceExternal = "external"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a build id / image ref does not exist.
|
||||
var ErrNotFound = errors.New("build: not found")
|
||||
|
||||
// ErrAlreadyTerminal is returned by Cancel when the build has already finished.
|
||||
var ErrAlreadyTerminal = errors.New("build: already in a terminal state")
|
||||
|
||||
// ErrInvalid wraps every request-validation failure (bad image ref, missing /
|
||||
// oversize Dockerfile, missing context). Callers map it to a 400; it is kept
|
||||
// distinct from store/cluster failures so those surface as 500.
|
||||
var ErrInvalid = errors.New("build: invalid request")
|
||||
|
||||
// Request is the validated POST /images/build input (spec §16). The dockerfile
|
||||
// and context are archived for audit; the target ref must address the internal
|
||||
// registry (enforced in Validate).
|
||||
type Request struct {
|
||||
// ImageRef is the push target, e.g. registry.felis.svc:5000/foo:1.0. It must
|
||||
// be under the configured internal registry — a build can never push
|
||||
// elsewhere.
|
||||
ImageRef string
|
||||
// Dockerfile is the uploaded build recipe (size-capped).
|
||||
Dockerfile string
|
||||
// ContextRef locates the uploaded tar.gz context in object storage / a PVC
|
||||
// (spec §17: Kaniko pulls it; Git context is intentionally not supported).
|
||||
ContextRef string
|
||||
// BaseImage is the resolved FROM, recorded for audit only — it is NOT a hard
|
||||
// gate (spec §16: base FROM is not hard-gated; the scan + egress lock cover
|
||||
// poisoned bases).
|
||||
BaseImage string
|
||||
// RequestedBy is the admin Access email, used as the audit actor and the
|
||||
// added_by of any admitted image.
|
||||
RequestedBy string
|
||||
}
|
||||
|
||||
// Build mirrors an image_builds row (spec §6).
|
||||
type Build struct {
|
||||
ID string `json:"id"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Status Status `json:"status"`
|
||||
Dockerfile string `json:"dockerfile,omitempty"`
|
||||
ContextRef string `json:"context_ref,omitempty"`
|
||||
BaseImage string `json:"base_image,omitempty"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
JobName string `json:"job_name,omitempty"`
|
||||
LogRef string `json:"log_ref,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// Image mirrors an image_whitelist row (spec §6): the dynamic, auditable image
|
||||
// admission list that the create-server form reads from.
|
||||
type Image struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
Source string `json:"source"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
AddedBy string `json:"added_by"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
// Store is the business-layer persistence the Builder depends on (image_builds
|
||||
// + image_whitelist). It is an interface so the Builder is tested against an
|
||||
// in-memory fake; the Postgres implementation (pgStore) is integration-tested
|
||||
// only.
|
||||
type Store interface {
|
||||
// CreateBuild inserts a new image_builds row (status pending).
|
||||
CreateBuild(ctx context.Context, b *Build) error
|
||||
// GetBuild loads one build, or ErrNotFound.
|
||||
GetBuild(ctx context.Context, id string) (*Build, error)
|
||||
// SetBuildJob records the Job name and advances status to building.
|
||||
SetBuildJob(ctx context.Context, id, jobName string) error
|
||||
// FinishBuild sets a terminal status, an optional error, and finished_at.
|
||||
FinishBuild(ctx context.Context, id string, status Status, errMsg string, at time.Time) error
|
||||
// ListUnfinishedBuilds returns builds still being reconciled (status pending
|
||||
// or building), oldest first — the work list for SyncAll.
|
||||
ListUnfinishedBuilds(ctx context.Context) ([]Build, error)
|
||||
// AdmitBuiltImage upserts an image_whitelist row with enabled=true and
|
||||
// source=built (the scan-gate success path, spec §16). It records added_by.
|
||||
AdmitBuiltImage(ctx context.Context, img Image) error
|
||||
// ListImages returns the image whitelist.
|
||||
ListImages(ctx context.Context) ([]Image, error)
|
||||
// AddExternalImage upserts an externally-pushed image (spec §15 external
|
||||
// admission; source=external, no build_id).
|
||||
AddExternalImage(ctx context.Context, img Image) error
|
||||
// RemoveImage deletes an image_whitelist row, or ErrNotFound.
|
||||
RemoveImage(ctx context.Context, imageRef string) error
|
||||
}
|
||||
|
||||
// Jobs is the cluster-side build lifecycle the Builder depends on. It is an
|
||||
// interface so the scan-gate translation is tested against a fake; the
|
||||
// controller-runtime implementation (k8sJobs) is integration-tested only — it
|
||||
// requires a live cluster.
|
||||
type Jobs interface {
|
||||
// CreateBuildJob starts the Kaniko+Trivy Job for p in the felis-build
|
||||
// namespace and returns the Job name.
|
||||
CreateBuildJob(ctx context.Context, p JobParams) (jobName string, err error)
|
||||
// JobPhase reports the current phase of a previously-created Job.
|
||||
JobPhase(ctx context.Context, jobName string) (JobPhase, error)
|
||||
// CancelBuildJob deletes the Job (and its pods), tolerating not-found.
|
||||
CancelBuildJob(ctx context.Context, jobName string) error
|
||||
}
|
||||
|
||||
// Config parameterises the build subsystem from felis.toml (spec §24 [registry]
|
||||
// + safety limits). It is validated by withDefaults before use.
|
||||
type Config struct {
|
||||
// Namespace is the isolated build namespace (spec §16: felis-build).
|
||||
Namespace string
|
||||
// ServiceAccount is the weak SA the build Pod runs as. It MUST NOT be the
|
||||
// felis-api SA (spec §16 red line).
|
||||
ServiceAccount string
|
||||
// RegistryURL is the internal registry the build pushes to and Trivy scans
|
||||
// (spec §17). Image refs are validated to be under it.
|
||||
RegistryURL string
|
||||
// KanikoImage / TrivyImage are the executor images.
|
||||
KanikoImage string
|
||||
TrivyImage string
|
||||
// Deadline caps a build's wall-clock (spec §16: activeDeadlineSeconds).
|
||||
Deadline time.Duration
|
||||
// MaxDockerfileBytes caps the uploaded Dockerfile (spec §16: context size
|
||||
// limits). Zero applies the default.
|
||||
MaxDockerfileBytes int
|
||||
// CPULimit / MemLimit cap each build container (spec §16: resource limits).
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
}
|
||||
|
||||
// Defaults applied when a Config field is left zero.
|
||||
const (
|
||||
defaultNamespace = "felis-build"
|
||||
defaultServiceAccount = "felis-build"
|
||||
defaultKanikoImage = "gcr.io/kaniko-project/executor:latest"
|
||||
defaultTrivyImage = "aquasec/trivy:latest"
|
||||
defaultDeadline = 30 * time.Minute
|
||||
defaultMaxDockerfile = 256 * 1024 // 256 KiB
|
||||
defaultCPULimit = "2"
|
||||
defaultMemLimit = "4Gi"
|
||||
)
|
||||
|
||||
// withDefaults returns a copy of c with zero fields filled, so a partially
|
||||
// configured Config (or the zero value, in tests) is always usable.
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.Namespace == "" {
|
||||
c.Namespace = defaultNamespace
|
||||
}
|
||||
if c.ServiceAccount == "" {
|
||||
c.ServiceAccount = defaultServiceAccount
|
||||
}
|
||||
if c.KanikoImage == "" {
|
||||
c.KanikoImage = defaultKanikoImage
|
||||
}
|
||||
if c.TrivyImage == "" {
|
||||
c.TrivyImage = defaultTrivyImage
|
||||
}
|
||||
if c.Deadline <= 0 {
|
||||
c.Deadline = defaultDeadline
|
||||
}
|
||||
if c.MaxDockerfileBytes <= 0 {
|
||||
c.MaxDockerfileBytes = defaultMaxDockerfile
|
||||
}
|
||||
if c.CPULimit == "" {
|
||||
c.CPULimit = defaultCPULimit
|
||||
}
|
||||
if c.MemLimit == "" {
|
||||
c.MemLimit = defaultMemLimit
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Builder orchestrates the build subsystem. It holds no mutable state; the
|
||||
// clock and id generator are injectable for hermetic tests.
|
||||
type Builder struct {
|
||||
Store Store
|
||||
Jobs Jobs
|
||||
Config Config
|
||||
|
||||
// Now is the clock, injectable for tests. Defaults to time.Now.
|
||||
Now func() time.Time
|
||||
// IDGen mints build ids. Defaults to a time-based generator.
|
||||
IDGen func() string
|
||||
}
|
||||
|
||||
func (b *Builder) now() time.Time {
|
||||
if b.Now != nil {
|
||||
return b.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (b *Builder) newID() string {
|
||||
if b.IDGen != nil {
|
||||
return b.IDGen()
|
||||
}
|
||||
return fmt.Sprintf("bld-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Submit validates req, records a pending build, and starts the Kaniko+Trivy
|
||||
// Job (spec §16). The build is returned in the building state once the Job is
|
||||
// created; if Job creation fails the build is marked failed so it never lingers
|
||||
// pending. The caller (felis-api) drives the build to a terminal state by
|
||||
// polling Sync / SyncAll.
|
||||
func (b *Builder) Submit(ctx context.Context, req Request) (*Build, error) {
|
||||
cfg := b.Config.withDefaults()
|
||||
if err := Validate(req, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := b.now()
|
||||
bld := &Build{
|
||||
ID: b.newID(),
|
||||
ImageRef: req.ImageRef,
|
||||
Status: StatusPending,
|
||||
Dockerfile: req.Dockerfile,
|
||||
ContextRef: req.ContextRef,
|
||||
BaseImage: req.BaseImage,
|
||||
RequestedBy: req.RequestedBy,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := b.Store.CreateBuild(ctx, bld); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jobName, err := b.Jobs.CreateBuildJob(ctx, b.jobParams(bld, cfg))
|
||||
if err != nil {
|
||||
// The pending row exists; mark it failed so it is not reconciled forever.
|
||||
_ = b.Store.FinishBuild(ctx, bld.ID, StatusFailed, "job creation failed: "+err.Error(), b.now())
|
||||
bld.Status = StatusFailed
|
||||
bld.Error = "job creation failed: " + err.Error()
|
||||
return bld, fmt.Errorf("build: create job: %w", err)
|
||||
}
|
||||
|
||||
if err := b.Store.SetBuildJob(ctx, bld.ID, jobName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bld.JobName = jobName
|
||||
bld.Status = StatusBuilding
|
||||
return bld, nil
|
||||
}
|
||||
|
||||
// jobParams projects a build + config onto the inputs jobspec.go renders.
|
||||
func (b *Builder) jobParams(bld *Build, cfg Config) JobParams {
|
||||
return JobParams{
|
||||
BuildID: bld.ID,
|
||||
ImageRef: bld.ImageRef,
|
||||
ContextRef: bld.ContextRef,
|
||||
Namespace: cfg.Namespace,
|
||||
ServiceAccount: cfg.ServiceAccount,
|
||||
RegistryURL: cfg.RegistryURL,
|
||||
KanikoImage: cfg.KanikoImage,
|
||||
TrivyImage: cfg.TrivyImage,
|
||||
Deadline: cfg.Deadline,
|
||||
CPULimit: cfg.CPULimit,
|
||||
MemLimit: cfg.MemLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a build by id, or ErrNotFound.
|
||||
func (b *Builder) Get(ctx context.Context, id string) (*Build, error) {
|
||||
return b.Store.GetBuild(ctx, id)
|
||||
}
|
||||
|
||||
// Sync reconciles one non-terminal build against its Job phase — the scan-gate
|
||||
// translation (spec §16). A terminal build is returned unchanged (idempotent).
|
||||
//
|
||||
// - JobSucceeded → status=succeeded AND the image is admitted to the whitelist
|
||||
// with enabled=true (kaniko pushed and trivy found no CRITICAL CVE).
|
||||
// - JobFailed / JobUnknown → status=failed, nothing admitted (a CRITICAL CVE
|
||||
// surfaces here as a failed Job, since trivy runs with --exit-code 1).
|
||||
// - JobPending / JobRunning → no change.
|
||||
//
|
||||
// The image admission is performed by felis-api (this code path), never by the
|
||||
// build Pod, which holds no database credentials.
|
||||
func (b *Builder) Sync(ctx context.Context, id string) (*Build, error) {
|
||||
bld, err := b.Store.GetBuild(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bld.Status.terminal() {
|
||||
return bld, nil
|
||||
}
|
||||
if bld.JobName == "" {
|
||||
// Created but the Job name was never recorded; treat as failed rather
|
||||
// than reconcile forever against a phantom Job.
|
||||
return b.finish(ctx, bld, StatusFailed, "no build job recorded")
|
||||
}
|
||||
|
||||
phase, err := b.Jobs.JobPhase(ctx, bld.JobName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch phase {
|
||||
case JobSucceeded:
|
||||
now := b.now()
|
||||
// Admit the image first; only then mark the build succeeded, so a
|
||||
// succeeded build always has its whitelist row (no admitted-but-not-
|
||||
// recorded window if the second write fails).
|
||||
if err := b.Store.AdmitBuiltImage(ctx, Image{
|
||||
ImageRef: bld.ImageRef,
|
||||
Source: SourceBuilt,
|
||||
BuildID: bld.ID,
|
||||
AddedBy: bld.RequestedBy,
|
||||
Enabled: true,
|
||||
AddedAt: now,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.finishAt(ctx, bld, StatusSucceeded, "", now)
|
||||
case JobFailed, JobUnknown:
|
||||
return b.finish(ctx, bld, StatusFailed, "build job failed or scan found a CRITICAL CVE")
|
||||
default: // JobPending / JobRunning
|
||||
return bld, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SyncAll reconciles every unfinished build and returns the count advanced to a
|
||||
// terminal state. felis-api calls this periodically (spec §16: the scan gate is
|
||||
// observed, not pushed by the build Pod).
|
||||
func (b *Builder) SyncAll(ctx context.Context) (int, error) {
|
||||
builds, err := b.Store.ListUnfinishedBuilds(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
advanced := 0
|
||||
for i := range builds {
|
||||
bld, err := b.Sync(ctx, builds[i].ID)
|
||||
if err != nil {
|
||||
return advanced, err
|
||||
}
|
||||
if bld.Status.terminal() {
|
||||
advanced++
|
||||
}
|
||||
}
|
||||
return advanced, nil
|
||||
}
|
||||
|
||||
// Cancel stops an in-flight build: delete its Job and mark it cancelled. A
|
||||
// build that has already finished returns ErrAlreadyTerminal.
|
||||
func (b *Builder) Cancel(ctx context.Context, id string) (*Build, error) {
|
||||
bld, err := b.Store.GetBuild(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bld.Status.terminal() {
|
||||
return nil, ErrAlreadyTerminal
|
||||
}
|
||||
if bld.JobName != "" {
|
||||
if err := b.Jobs.CancelBuildJob(ctx, bld.JobName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b.finish(ctx, bld, StatusCancelled, "cancelled by administrator")
|
||||
}
|
||||
|
||||
// finish marks a build terminal at the current clock and returns the updated
|
||||
// view without a second round-trip.
|
||||
func (b *Builder) finish(ctx context.Context, bld *Build, status Status, msg string) (*Build, error) {
|
||||
return b.finishAt(ctx, bld, status, msg, b.now())
|
||||
}
|
||||
|
||||
func (b *Builder) finishAt(ctx context.Context, bld *Build, status Status, msg string, at time.Time) (*Build, error) {
|
||||
if err := b.Store.FinishBuild(ctx, bld.ID, status, msg, at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bld.Status = status
|
||||
bld.Error = msg
|
||||
finished := at
|
||||
bld.FinishedAt = &finished
|
||||
return bld, nil
|
||||
}
|
||||
|
||||
// ListImages returns the image whitelist (spec §15 create-server form source).
|
||||
func (b *Builder) ListImages(ctx context.Context) ([]Image, error) {
|
||||
return b.Store.ListImages(ctx)
|
||||
}
|
||||
|
||||
// AddExternalImage admits an externally-pushed image (spec §15). It is enabled
|
||||
// immediately; external images bypass the build pipeline but are still recorded
|
||||
// with added_by for audit.
|
||||
func (b *Builder) AddExternalImage(ctx context.Context, imageRef, addedBy string) (*Image, error) {
|
||||
if err := ValidateImageRef(imageRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := Image{
|
||||
ImageRef: imageRef,
|
||||
Source: SourceExternal,
|
||||
AddedBy: addedBy,
|
||||
Enabled: true,
|
||||
AddedAt: b.now(),
|
||||
}
|
||||
if err := b.Store.AddExternalImage(ctx, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &img, nil
|
||||
}
|
||||
|
||||
// RemoveImage withdraws an image from the whitelist (spec §22: dynamic,
|
||||
// auditable). It does not delete the underlying registry blob.
|
||||
func (b *Builder) RemoveImage(ctx context.Context, imageRef string) error {
|
||||
return b.Store.RemoveImage(ctx, imageRef)
|
||||
}
|
||||
|
||||
// ImageAdmitted reports whether a concrete image reference is on the whitelist
|
||||
// and enabled (spec §15: the create-server form may only choose an admitted
|
||||
// image). A disabled row never admits. A wildcard whitelist entry
|
||||
// ("registry/foo:*") admits any concrete tag on that repo (imageMatches); the
|
||||
// caller always passes a concrete ref, never a wildcard. An empty ref is never
|
||||
// admitted.
|
||||
func (b *Builder) ImageAdmitted(ctx context.Context, imageRef string) (bool, error) {
|
||||
if strings.TrimSpace(imageRef) == "" {
|
||||
return false, nil
|
||||
}
|
||||
images, err := b.Store.ListImages(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, img := range images {
|
||||
if img.Enabled && imageMatches(imageRef, img.ImageRef) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
Reference in new issue
Block a user