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
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// ---- in-memory fakes ----
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
builds map[string]*Build
|
||||||
|
images map[string]Image
|
||||||
|
// call recorders
|
||||||
|
admitted []Image
|
||||||
|
finished []string // "id:status"
|
||||||
|
removeErr error
|
||||||
|
createErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore() *fakeStore {
|
||||||
|
return &fakeStore{builds: map[string]*Build{}, images: map[string]Image{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateBuild(_ context.Context, b *Build) error {
|
||||||
|
if f.createErr != nil {
|
||||||
|
return f.createErr
|
||||||
|
}
|
||||||
|
cp := *b
|
||||||
|
f.builds[b.ID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetBuild(_ context.Context, id string) (*Build, error) {
|
||||||
|
b, ok := f.builds[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *b
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetBuildJob(_ context.Context, id, jobName string) error {
|
||||||
|
b, ok := f.builds[id]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
b.JobName = jobName
|
||||||
|
b.Status = StatusBuilding
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) FinishBuild(_ context.Context, id string, status Status, errMsg string, at time.Time) error {
|
||||||
|
b, ok := f.builds[id]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
b.Status = status
|
||||||
|
b.Error = errMsg
|
||||||
|
finished := at
|
||||||
|
b.FinishedAt = &finished
|
||||||
|
f.finished = append(f.finished, id+":"+string(status))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListUnfinishedBuilds(_ context.Context) ([]Build, error) {
|
||||||
|
var out []Build
|
||||||
|
for _, b := range f.builds {
|
||||||
|
if !b.Status.terminal() {
|
||||||
|
out = append(out, *b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AdmitBuiltImage(_ context.Context, img Image) error {
|
||||||
|
f.images[img.ImageRef] = img
|
||||||
|
f.admitted = append(f.admitted, img)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListImages(_ context.Context) ([]Image, error) {
|
||||||
|
var out []Image
|
||||||
|
for _, img := range f.images {
|
||||||
|
out = append(out, img)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AddExternalImage(_ context.Context, img Image) error {
|
||||||
|
f.images[img.ImageRef] = img
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RemoveImage(_ context.Context, ref string) error {
|
||||||
|
if f.removeErr != nil {
|
||||||
|
return f.removeErr
|
||||||
|
}
|
||||||
|
if _, ok := f.images[ref]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.images, ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeJobs struct {
|
||||||
|
phase JobPhase
|
||||||
|
phaseErr error
|
||||||
|
createErr error
|
||||||
|
|
||||||
|
created []JobParams
|
||||||
|
cancelled []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) CreateBuildJob(_ context.Context, p JobParams) (string, error) {
|
||||||
|
if f.createErr != nil {
|
||||||
|
return "", f.createErr
|
||||||
|
}
|
||||||
|
f.created = append(f.created, p)
|
||||||
|
return BuildJobName(p.BuildID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) JobPhase(_ context.Context, _ string) (JobPhase, error) {
|
||||||
|
return f.phase, f.phaseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) CancelBuildJob(_ context.Context, jobName string) error {
|
||||||
|
f.cancelled = append(f.cancelled, jobName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBuilder wires a Builder over fresh fakes with a frozen clock and a
|
||||||
|
// deterministic id generator.
|
||||||
|
func newBuilder() (*Builder, *fakeStore, *fakeJobs) {
|
||||||
|
st := newFakeStore()
|
||||||
|
jb := &fakeJobs{phase: JobPending}
|
||||||
|
n := 0
|
||||||
|
b := &Builder{
|
||||||
|
Store: st,
|
||||||
|
Jobs: jb,
|
||||||
|
Config: Config{
|
||||||
|
RegistryURL: "registry.felis.svc:5000",
|
||||||
|
},
|
||||||
|
Now: func() time.Time { return testNow },
|
||||||
|
IDGen: func() string {
|
||||||
|
n++
|
||||||
|
return "bld-" + string(rune('0'+n))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return b, st, jb
|
||||||
|
}
|
||||||
|
|
||||||
|
func goodRequest() Request {
|
||||||
|
return Request{
|
||||||
|
ImageRef: "registry.felis.svc:5000/mc-paper:1.0",
|
||||||
|
Dockerfile: "FROM eclipse-temurin:21\nCOPY . /data\n",
|
||||||
|
ContextRef: "tar://contexts/abc.tar.gz",
|
||||||
|
RequestedBy: "[email protected]",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- submission ----
|
||||||
|
|
||||||
|
func TestSubmitCreatesPendingBuildAndStartsJob(t *testing.T) {
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
bld, err := b.Submit(context.Background(), goodRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Submit: %v", err)
|
||||||
|
}
|
||||||
|
if bld.Status != StatusBuilding {
|
||||||
|
t.Errorf("status = %q, want building", bld.Status)
|
||||||
|
}
|
||||||
|
if bld.JobName == "" {
|
||||||
|
t.Error("job name not recorded")
|
||||||
|
}
|
||||||
|
if got := st.builds[bld.ID]; got == nil || got.Dockerfile == "" {
|
||||||
|
t.Error("build row not persisted with dockerfile")
|
||||||
|
}
|
||||||
|
if len(jb.created) != 1 {
|
||||||
|
t.Fatalf("CreateBuildJob calls = %d, want 1", len(jb.created))
|
||||||
|
}
|
||||||
|
// The Job must be parameterised with the weak build SA and the namespace,
|
||||||
|
// never the api identity — this is the §16 red line, asserted at the seam.
|
||||||
|
p := jb.created[0]
|
||||||
|
if p.ServiceAccount != defaultServiceAccount {
|
||||||
|
t.Errorf("job SA = %q, want %q", p.ServiceAccount, defaultServiceAccount)
|
||||||
|
}
|
||||||
|
if p.Namespace != defaultNamespace {
|
||||||
|
t.Errorf("job namespace = %q, want %q", p.Namespace, defaultNamespace)
|
||||||
|
}
|
||||||
|
if p.RegistryURL != "registry.felis.svc:5000" {
|
||||||
|
t.Errorf("job registry = %q", p.RegistryURL)
|
||||||
|
}
|
||||||
|
if p.Deadline <= 0 {
|
||||||
|
t.Error("job deadline not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitRejectsExternalRegistryTarget(t *testing.T) {
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
req := goodRequest()
|
||||||
|
req.ImageRef = "docker.io/library/evil:latest"
|
||||||
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
||||||
|
t.Fatal("expected rejection of non-internal registry target")
|
||||||
|
}
|
||||||
|
if len(st.builds) != 0 {
|
||||||
|
t.Error("a rejected build must not be persisted")
|
||||||
|
}
|
||||||
|
if len(jb.created) != 0 {
|
||||||
|
t.Error("a rejected build must not start a job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitRejectsEmptyAndOversizeDockerfile(t *testing.T) {
|
||||||
|
b, _, _ := newBuilder()
|
||||||
|
req := goodRequest()
|
||||||
|
req.Dockerfile = " "
|
||||||
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
||||||
|
t.Error("expected rejection of empty dockerfile")
|
||||||
|
}
|
||||||
|
|
||||||
|
b2, _, _ := newBuilder()
|
||||||
|
b2.Config.MaxDockerfileBytes = 16
|
||||||
|
req2 := goodRequest()
|
||||||
|
req2.Dockerfile = "FROM scratch\n# padding padding padding padding\n"
|
||||||
|
if _, err := b2.Submit(context.Background(), req2); err == nil {
|
||||||
|
t.Error("expected rejection of oversize dockerfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitRequiresContext(t *testing.T) {
|
||||||
|
b, _, _ := newBuilder()
|
||||||
|
req := goodRequest()
|
||||||
|
req.ContextRef = ""
|
||||||
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
||||||
|
t.Error("expected rejection when context reference is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitMarksBuildFailedWhenJobCreationFails(t *testing.T) {
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
jb.createErr = errors.New("apiserver down")
|
||||||
|
bld, err := b.Submit(context.Background(), goodRequest())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when job creation fails")
|
||||||
|
}
|
||||||
|
if bld == nil || bld.Status != StatusFailed {
|
||||||
|
t.Fatalf("build should be marked failed, got %+v", bld)
|
||||||
|
}
|
||||||
|
// The persisted row must not be left pending forever.
|
||||||
|
if got := st.builds[bld.ID]; got == nil || got.Status != StatusFailed {
|
||||||
|
t.Errorf("persisted status = %v, want failed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the scan gate (Job phase -> DB) ----
|
||||||
|
|
||||||
|
func TestSyncSucceededAdmitsImageWithAddedBy(t *testing.T) {
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
|
||||||
|
jb.phase = JobSucceeded
|
||||||
|
got, err := b.Sync(context.Background(), bld.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != StatusSucceeded {
|
||||||
|
t.Errorf("status = %q, want succeeded", got.Status)
|
||||||
|
}
|
||||||
|
img, ok := st.images[bld.ImageRef]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("succeeded build did not admit its image to the whitelist")
|
||||||
|
}
|
||||||
|
if !img.Enabled {
|
||||||
|
t.Error("admitted image must be enabled=true")
|
||||||
|
}
|
||||||
|
if img.Source != SourceBuilt {
|
||||||
|
t.Errorf("source = %q, want built", img.Source)
|
||||||
|
}
|
||||||
|
if img.AddedBy != "[email protected]" {
|
||||||
|
t.Errorf("added_by = %q, want the requester", img.AddedBy)
|
||||||
|
}
|
||||||
|
if img.BuildID != bld.ID {
|
||||||
|
t.Errorf("build_id = %q, want %q", img.BuildID, bld.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncFailedDoesNotAdmitImage(t *testing.T) {
|
||||||
|
// A failed Job is exactly the CRITICAL-CVE case: trivy --exit-code 1 fails
|
||||||
|
// the Pod, so the gate is enforced as the Job verdict (spec §16).
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
|
||||||
|
jb.phase = JobFailed
|
||||||
|
got, err := b.Sync(context.Background(), bld.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != StatusFailed {
|
||||||
|
t.Errorf("status = %q, want failed", got.Status)
|
||||||
|
}
|
||||||
|
if len(st.images) != 0 {
|
||||||
|
t.Error("a failed/CRITICAL build must NOT admit any image")
|
||||||
|
}
|
||||||
|
if len(st.admitted) != 0 {
|
||||||
|
t.Error("AdmitBuiltImage must not be called on failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncRunningIsNoOp(t *testing.T) {
|
||||||
|
b, _, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
jb.phase = JobRunning
|
||||||
|
got, err := b.Sync(context.Background(), bld.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != StatusBuilding {
|
||||||
|
t.Errorf("status = %q, want building (unchanged)", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncIsIdempotentOnTerminalBuild(t *testing.T) {
|
||||||
|
b, st, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
jb.phase = JobSucceeded
|
||||||
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
||||||
|
t.Fatalf("first Sync: %v", err)
|
||||||
|
}
|
||||||
|
// Flip the phase to a value that would admit again; a terminal build must
|
||||||
|
// not be re-reconciled.
|
||||||
|
before := len(st.admitted)
|
||||||
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
||||||
|
t.Fatalf("second Sync: %v", err)
|
||||||
|
}
|
||||||
|
if len(st.admitted) != before {
|
||||||
|
t.Error("terminal build was re-admitted on a second Sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncAllAdvancesUnfinishedBuilds(t *testing.T) {
|
||||||
|
b, _, jb := newBuilder()
|
||||||
|
if _, err := b.Submit(context.Background(), goodRequest()); err != nil {
|
||||||
|
t.Fatalf("submit: %v", err)
|
||||||
|
}
|
||||||
|
jb.phase = JobSucceeded
|
||||||
|
n, err := b.SyncAll(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncAll: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("advanced = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cancellation ----
|
||||||
|
|
||||||
|
func TestCancelDeletesJobAndMarksCancelled(t *testing.T) {
|
||||||
|
b, _, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
got, err := b.Cancel(context.Background(), bld.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Cancel: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != StatusCancelled {
|
||||||
|
t.Errorf("status = %q, want cancelled", got.Status)
|
||||||
|
}
|
||||||
|
if len(jb.cancelled) != 1 {
|
||||||
|
t.Errorf("CancelBuildJob calls = %d, want 1", len(jb.cancelled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelTerminalBuildFails(t *testing.T) {
|
||||||
|
b, _, jb := newBuilder()
|
||||||
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
||||||
|
jb.phase = JobSucceeded
|
||||||
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
||||||
|
t.Fatalf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := b.Cancel(context.Background(), bld.ID); !errors.Is(err, ErrAlreadyTerminal) {
|
||||||
|
t.Errorf("Cancel on terminal build err = %v, want ErrAlreadyTerminal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- external admission ----
|
||||||
|
|
||||||
|
func TestAddExternalImageIsEnabledAndRecorded(t *testing.T) {
|
||||||
|
b, st, _ := newBuilder()
|
||||||
|
img, err := b.AddExternalImage(context.Background(), "registry.felis.svc:5000/ext:1", "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddExternalImage: %v", err)
|
||||||
|
}
|
||||||
|
if img.Source != SourceExternal || !img.Enabled {
|
||||||
|
t.Errorf("external image = %+v, want enabled external", img)
|
||||||
|
}
|
||||||
|
if _, ok := st.images["registry.felis.svc:5000/ext:1"]; !ok {
|
||||||
|
t.Error("external image not persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddExternalImageRejectsMalformedRef(t *testing.T) {
|
||||||
|
b, _, _ := newBuilder()
|
||||||
|
if _, err := b.AddExternalImage(context.Background(), "not a ref!!", "[email protected]"); err == nil {
|
||||||
|
t.Error("expected rejection of malformed image ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveImage(t *testing.T) {
|
||||||
|
b, st, _ := newBuilder()
|
||||||
|
st.images["registry.felis.svc:5000/x:1"] = Image{ImageRef: "registry.felis.svc:5000/x:1"}
|
||||||
|
if err := b.RemoveImage(context.Background(), "registry.felis.svc:5000/x:1"); err != nil {
|
||||||
|
t.Fatalf("RemoveImage: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.images["registry.felis.svc:5000/x:1"]; ok {
|
||||||
|
t.Error("image not removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- whitelist admission for the create-server form (spec §15) ----
|
||||||
|
|
||||||
|
func TestImageMatches(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ref, pattern string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// exact match
|
||||||
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:1.0", true},
|
||||||
|
// exact mismatch on tag
|
||||||
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:2.0", false},
|
||||||
|
// tag wildcard admits any concrete tag on the repo
|
||||||
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:*", true},
|
||||||
|
{"registry.felis.svc:5000/mc:anything", "registry.felis.svc:5000/mc:*", true},
|
||||||
|
// wildcard does not cross repos
|
||||||
|
{"registry.felis.svc:5000/other:1", "registry.felis.svc:5000/mc:*", false},
|
||||||
|
// a registry-port colon is not a tag separator: an untagged ref under a
|
||||||
|
// ported host has no tag, so a wildcard (which needs a non-empty tag) misses
|
||||||
|
{"registry.felis.svc:5000/mc", "registry.felis.svc:5000/mc:*", false},
|
||||||
|
// a non-wildcard pattern never matches via the prefix branch
|
||||||
|
{"registry.felis.svc:5000/mc:1", "registry.felis.svc:5000/mc", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := imageMatches(c.ref, c.pattern); got != c.want {
|
||||||
|
t.Errorf("imageMatches(%q, %q) = %v, want %v", c.ref, c.pattern, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageAdmitted(t *testing.T) {
|
||||||
|
b, st, _ := newBuilder()
|
||||||
|
st.images["registry.felis.svc:5000/exact:1"] = Image{ImageRef: "registry.felis.svc:5000/exact:1", Enabled: true}
|
||||||
|
st.images["registry.felis.svc:5000/wild:*"] = Image{ImageRef: "registry.felis.svc:5000/wild:*", Enabled: true}
|
||||||
|
st.images["registry.felis.svc:5000/off:1"] = Image{ImageRef: "registry.felis.svc:5000/off:1", Enabled: false}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
ref string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"registry.felis.svc:5000/exact:1", true}, // exact, enabled
|
||||||
|
{"registry.felis.svc:5000/exact:2", false}, // wrong tag
|
||||||
|
{"registry.felis.svc:5000/wild:99", true}, // wildcard, enabled, port-colon safe
|
||||||
|
{"registry.felis.svc:5000/off:1", false}, // present but disabled
|
||||||
|
{"registry.felis.svc:5000/unknown:1", false}, // not on the list
|
||||||
|
{"", false}, // empty ref
|
||||||
|
{" ", false}, // blank ref
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := b.ImageAdmitted(context.Background(), c.ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImageAdmitted(%q): %v", c.ref, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("ImageAdmitted(%q) = %v, want %v", c.ref, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
networkingv1 "k8s.io/api/networking/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Label keys applied to build objects. ManagedBy doubles as the NetworkPolicy
|
||||||
|
// pod selector, so every build Pod is captured by the egress lock.
|
||||||
|
const (
|
||||||
|
LabelManagedBy = "app.kubernetes.io/managed-by"
|
||||||
|
LabelComponent = "app.kubernetes.io/component"
|
||||||
|
LabelBuildID = "felis.lolicon.best/build-id"
|
||||||
|
|
||||||
|
managedByValue = "felis-build"
|
||||||
|
componentValue = "image-build"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Container names within the build Pod. Kaniko is the initContainer that builds
|
||||||
|
// and pushes the image — its log IS the "build log" an admin watches (spec §16);
|
||||||
|
// Trivy is the main container whose CRITICAL-CVE verdict gates admission and is
|
||||||
|
// surfaced via the build status, not the log stream. Exported so the build-log
|
||||||
|
// streamer (internal/api.K8sBuildLogStreamer, spec §416 日志流复用 §8) follows the
|
||||||
|
// same container this Job defines — one source of truth for the name.
|
||||||
|
const (
|
||||||
|
ContainerKaniko = "kaniko"
|
||||||
|
ContainerTrivy = "trivy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobParams are the rendered inputs to a build Job. They are derived from a
|
||||||
|
// Build + Config by the Builder; jobspec is a pure function of them so the
|
||||||
|
// security-critical Job shape is unit-tested without a cluster.
|
||||||
|
type JobParams struct {
|
||||||
|
BuildID string
|
||||||
|
ImageRef string
|
||||||
|
ContextRef string
|
||||||
|
Namespace string
|
||||||
|
ServiceAccount string
|
||||||
|
RegistryURL string
|
||||||
|
KanikoImage string
|
||||||
|
TrivyImage string
|
||||||
|
Deadline time.Duration
|
||||||
|
CPULimit string
|
||||||
|
MemLimit string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildJobName is the deterministic Job name for a build id.
|
||||||
|
func BuildJobName(buildID string) string { return "build-" + buildID }
|
||||||
|
|
||||||
|
func buildLabels(p JobParams) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
LabelManagedBy: managedByValue,
|
||||||
|
LabelComponent: componentValue,
|
||||||
|
LabelBuildID: p.BuildID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildJob renders the Kaniko+Trivy build Job (spec §16). Every isolation
|
||||||
|
// guarantee the spec demands is encoded here and asserted by jobspec_test.go,
|
||||||
|
// because no cluster runs in this environment:
|
||||||
|
//
|
||||||
|
// - runs in the isolated felis-build namespace with the weak felis-build SA
|
||||||
|
// (never the felis-api SA) and does NOT mount the SA token, so it cannot
|
||||||
|
// reach the K8s API (spec §16, §21);
|
||||||
|
// - no privileged container — Kaniko builds the Dockerfile without a daemon,
|
||||||
|
// so docker-in-docker / privileged is never needed (spec §16, §22);
|
||||||
|
// - activeDeadlineSeconds + backoffLimit=0 + per-container resource limits so
|
||||||
|
// a runaway or poisoned build cannot exhaust the cluster (spec §16);
|
||||||
|
// - the Trivy step runs with `--exit-code 1 --severity CRITICAL`, so a
|
||||||
|
// CRITICAL CVE fails the Pod and therefore the Job — the only retained
|
||||||
|
// automatic admission gate (spec §16).
|
||||||
|
//
|
||||||
|
// Sequencing: kaniko runs as an initContainer (build + push to the internal
|
||||||
|
// registry) and trivy as the main container (scan the pushed ref). The Pod
|
||||||
|
// succeeds only if kaniko pushed AND trivy found no CRITICAL CVE.
|
||||||
|
func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||||
|
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deadline := int64(p.Deadline / time.Second)
|
||||||
|
if deadline <= 0 {
|
||||||
|
deadline = int64(defaultDeadline / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardened container security context shared by both build containers: no
|
||||||
|
// privilege, no privilege escalation, drop all capabilities. Kaniko needs a
|
||||||
|
// writable root filesystem to unpack layers, so we do not force read-only
|
||||||
|
// root here, but it gains no privilege.
|
||||||
|
sec := &corev1.SecurityContext{
|
||||||
|
Privileged: boolPtr(false),
|
||||||
|
AllowPrivilegeEscalation: boolPtr(false),
|
||||||
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
kaniko := corev1.Container{
|
||||||
|
Name: ContainerKaniko,
|
||||||
|
Image: p.KanikoImage,
|
||||||
|
Args: []string{
|
||||||
|
"--dockerfile=Dockerfile",
|
||||||
|
"--context=" + p.ContextRef,
|
||||||
|
"--destination=" + p.ImageRef,
|
||||||
|
// The internal registry is in-cluster only and may serve plain HTTP;
|
||||||
|
// it is never a public ingress (spec §17).
|
||||||
|
"--insecure",
|
||||||
|
"--skip-tls-verify",
|
||||||
|
},
|
||||||
|
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
||||||
|
SecurityContext: sec,
|
||||||
|
}
|
||||||
|
|
||||||
|
trivy := corev1.Container{
|
||||||
|
Name: ContainerTrivy,
|
||||||
|
Image: p.TrivyImage,
|
||||||
|
Args: []string{
|
||||||
|
"image",
|
||||||
|
"--exit-code", "1",
|
||||||
|
"--severity", "CRITICAL",
|
||||||
|
"--no-progress",
|
||||||
|
"--insecure",
|
||||||
|
p.ImageRef,
|
||||||
|
},
|
||||||
|
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
||||||
|
SecurityContext: sec,
|
||||||
|
}
|
||||||
|
|
||||||
|
job := &batchv1.Job{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: BuildJobName(p.BuildID),
|
||||||
|
Namespace: p.Namespace,
|
||||||
|
Labels: buildLabels(p),
|
||||||
|
},
|
||||||
|
Spec: batchv1.JobSpec{
|
||||||
|
// A poisoned build must not loop — one shot, then a terminal verdict.
|
||||||
|
BackoffLimit: int32Ptr(0),
|
||||||
|
ActiveDeadlineSeconds: int64Ptr(deadline),
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Labels: buildLabels(p)},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
RestartPolicy: corev1.RestartPolicyNever,
|
||||||
|
ServiceAccountName: p.ServiceAccount,
|
||||||
|
AutomountServiceAccountToken: boolPtr(false),
|
||||||
|
InitContainers: []corev1.Container{kaniko},
|
||||||
|
Containers: []corev1.Container{trivy},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetPolParams parameterises the build-namespace egress lock.
|
||||||
|
type NetPolParams struct {
|
||||||
|
Namespace string
|
||||||
|
RegistryNamespace string
|
||||||
|
RegistryPort int32
|
||||||
|
// PackageSourceCIDRs is an optional, explicit allowlist of external package
|
||||||
|
// mirrors (spec §16: egress 仅 registry + 包源). Empty means the most
|
||||||
|
// locked-down default — no internet egress at all (默认拒外网).
|
||||||
|
PackageSourceCIDRs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNetworkPolicy renders the default-deny egress policy for build Pods
|
||||||
|
// (spec §16, §21: build ns egress 仅放 registry + 包源,默认拒外网). It selects
|
||||||
|
// build Pods by the managed-by label, denies all ingress, and allows egress
|
||||||
|
// only to DNS, the internal registry, and any explicitly configured package
|
||||||
|
// mirrors. There is deliberately no allow-all egress rule.
|
||||||
|
func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
|
||||||
|
port := p.RegistryPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 5000
|
||||||
|
}
|
||||||
|
dnsUDP := corev1.ProtocolUDP
|
||||||
|
dnsTCP := corev1.ProtocolTCP
|
||||||
|
dns53 := intstr.FromInt32(53)
|
||||||
|
regPort := intstr.FromInt32(port)
|
||||||
|
|
||||||
|
egress := []networkingv1.NetworkPolicyEgressRule{
|
||||||
|
// DNS resolution: port-restricted to 53, so this is not an open-internet
|
||||||
|
// hole — name resolution only.
|
||||||
|
{
|
||||||
|
Ports: []networkingv1.NetworkPolicyPort{
|
||||||
|
{Protocol: &dnsUDP, Port: &dns53},
|
||||||
|
{Protocol: &dnsTCP, Port: &dns53},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// The internal registry, selected by the namespace's immutable
|
||||||
|
// kubernetes.io/metadata.name label, on the registry port only.
|
||||||
|
{
|
||||||
|
To: []networkingv1.NetworkPolicyPeer{{
|
||||||
|
NamespaceSelector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.RegistryNamespace},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Ports: []networkingv1.NetworkPolicyPort{
|
||||||
|
{Protocol: &dnsTCP, Port: ®Port},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Explicit package-mirror CIDRs, when configured. No CIDR ⇒ no internet.
|
||||||
|
for _, cidr := range p.PackageSourceCIDRs {
|
||||||
|
egress = append(egress, networkingv1.NetworkPolicyEgressRule{
|
||||||
|
To: []networkingv1.NetworkPolicyPeer{{
|
||||||
|
IPBlock: &networkingv1.IPBlock{CIDR: cidr},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &networkingv1.NetworkPolicy{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "felis-build-egress",
|
||||||
|
Namespace: p.Namespace,
|
||||||
|
Labels: map[string]string{
|
||||||
|
LabelManagedBy: managedByValue,
|
||||||
|
LabelComponent: componentValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: networkingv1.NetworkPolicySpec{
|
||||||
|
PodSelector: metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{LabelManagedBy: managedByValue},
|
||||||
|
},
|
||||||
|
PolicyTypes: []networkingv1.PolicyType{
|
||||||
|
networkingv1.PolicyTypeIngress,
|
||||||
|
networkingv1.PolicyTypeEgress,
|
||||||
|
},
|
||||||
|
// Empty Ingress slice = deny all ingress: nothing connects to a
|
||||||
|
// build Pod.
|
||||||
|
Ingress: []networkingv1.NetworkPolicyIngressRule{},
|
||||||
|
Egress: egress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildServiceAccount renders the weak build SA (spec §16, §21). It is the most
|
||||||
|
// dangerous identity in the platform if mis-scoped, so it is created bare: no
|
||||||
|
// secrets, token auto-mounting disabled, and — by virtue of having no Role or
|
||||||
|
// RoleBinding anywhere — zero K8s API permissions. Its only capability is
|
||||||
|
// network reachability to push to the registry, which RBAC does not grant.
|
||||||
|
func BuildServiceAccount(namespace, name string) *corev1.ServiceAccount {
|
||||||
|
return &corev1.ServiceAccount{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
Labels: map[string]string{
|
||||||
|
LabelManagedBy: managedByValue,
|
||||||
|
LabelComponent: componentValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AutomountServiceAccountToken: boolPtr(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceLimits parses the CPU/memory limits into a ResourceList.
|
||||||
|
func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
|
||||||
|
if cpu == "" {
|
||||||
|
cpu = defaultCPULimit
|
||||||
|
}
|
||||||
|
if mem == "" {
|
||||||
|
mem = defaultMemLimit
|
||||||
|
}
|
||||||
|
cpuQty, err := resource.ParseQuantity(cpu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build: invalid cpu limit %q: %w", cpu, err)
|
||||||
|
}
|
||||||
|
memQty, err := resource.ParseQuantity(mem)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build: invalid memory limit %q: %w", mem, err)
|
||||||
|
}
|
||||||
|
return corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: cpuQty,
|
||||||
|
corev1.ResourceMemory: memQty,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
func int32Ptr(i int32) *int32 { return &i }
|
||||||
|
func int64Ptr(i int64) *int64 { return &i }
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
networkingv1 "k8s.io/api/networking/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleJobParams() JobParams {
|
||||||
|
return JobParams{
|
||||||
|
BuildID: "bld-1",
|
||||||
|
ImageRef: "registry.felis.svc:5000/mc-paper:1.0",
|
||||||
|
ContextRef: "tar://contexts/abc.tar.gz",
|
||||||
|
Namespace: defaultNamespace,
|
||||||
|
ServiceAccount: defaultServiceAccount,
|
||||||
|
RegistryURL: "registry.felis.svc:5000",
|
||||||
|
KanikoImage: defaultKanikoImage,
|
||||||
|
TrivyImage: defaultTrivyImage,
|
||||||
|
Deadline: 30 * time.Minute,
|
||||||
|
CPULimit: "2",
|
||||||
|
MemLimit: "4Gi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Job must run in the isolated build namespace under the weak build SA —
|
||||||
|
// never the api namespace/identity, and never the minecraft namespace. This is
|
||||||
|
// the central §16/§21 red line, asserted on the rendered spec because no cluster
|
||||||
|
// runs here.
|
||||||
|
func TestBuildJobRunsIsolatedUnderWeakSA(t *testing.T) {
|
||||||
|
job, err := BuildJob(sampleJobParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildJob: %v", err)
|
||||||
|
}
|
||||||
|
if job.Namespace != "felis-build" {
|
||||||
|
t.Errorf("namespace = %q, want felis-build (never minecraft/felis-system)", job.Namespace)
|
||||||
|
}
|
||||||
|
sa := job.Spec.Template.Spec.ServiceAccountName
|
||||||
|
if sa != "felis-build" {
|
||||||
|
t.Errorf("service account = %q, want felis-build (never felis-api)", sa)
|
||||||
|
}
|
||||||
|
if sa == "felis-api" {
|
||||||
|
t.Fatal("build Pod must NOT run as the felis-api SA")
|
||||||
|
}
|
||||||
|
// The SA token must not be mounted: with no token, the Pod cannot reach the
|
||||||
|
// K8s API even if a Role were mis-bound.
|
||||||
|
if amt := job.Spec.Template.Spec.AutomountServiceAccountToken; amt == nil || *amt {
|
||||||
|
t.Error("AutomountServiceAccountToken must be explicitly false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A poisoned build must terminate and not loop or run unbounded.
|
||||||
|
func TestBuildJobIsBoundedAndOneShot(t *testing.T) {
|
||||||
|
job, err := BuildJob(sampleJobParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildJob: %v", err)
|
||||||
|
}
|
||||||
|
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 {
|
||||||
|
t.Error("BackoffLimit must be 0 — a poisoned build must not retry")
|
||||||
|
}
|
||||||
|
if job.Spec.ActiveDeadlineSeconds == nil || *job.Spec.ActiveDeadlineSeconds <= 0 {
|
||||||
|
t.Error("ActiveDeadlineSeconds must be a positive wall-clock cap")
|
||||||
|
}
|
||||||
|
if got := *job.Spec.ActiveDeadlineSeconds; got != int64((30 * time.Minute).Seconds()) {
|
||||||
|
t.Errorf("ActiveDeadlineSeconds = %d, want 1800", got)
|
||||||
|
}
|
||||||
|
if job.Spec.Template.Spec.RestartPolicy != corev1.RestartPolicyNever {
|
||||||
|
t.Error("RestartPolicy must be Never")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No build container may be privileged or able to escalate, and all containers
|
||||||
|
// must carry resource limits.
|
||||||
|
func TestBuildJobContainersAreHardened(t *testing.T) {
|
||||||
|
job, err := BuildJob(sampleJobParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildJob: %v", err)
|
||||||
|
}
|
||||||
|
all := append([]corev1.Container{}, job.Spec.Template.Spec.InitContainers...)
|
||||||
|
all = append(all, job.Spec.Template.Spec.Containers...)
|
||||||
|
if len(all) < 2 {
|
||||||
|
t.Fatalf("expected kaniko initContainer + trivy container, got %d containers", len(all))
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
sc := c.SecurityContext
|
||||||
|
if sc == nil {
|
||||||
|
t.Fatalf("container %q has no security context", c.Name)
|
||||||
|
}
|
||||||
|
if sc.Privileged == nil || *sc.Privileged {
|
||||||
|
t.Errorf("container %q must not be privileged (Kaniko needs no daemon)", c.Name)
|
||||||
|
}
|
||||||
|
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
|
||||||
|
t.Errorf("container %q must set allowPrivilegeEscalation=false", c.Name)
|
||||||
|
}
|
||||||
|
if sc.Capabilities == nil || len(sc.Capabilities.Drop) == 0 {
|
||||||
|
t.Errorf("container %q must drop capabilities", c.Name)
|
||||||
|
} else if string(sc.Capabilities.Drop[0]) != "ALL" {
|
||||||
|
t.Errorf("container %q must drop ALL capabilities, got %v", c.Name, sc.Capabilities.Drop)
|
||||||
|
}
|
||||||
|
if c.Resources.Limits.Cpu().IsZero() || c.Resources.Limits.Memory().IsZero() {
|
||||||
|
t.Errorf("container %q must carry CPU+memory limits", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kaniko builds and pushes to the request's exact target; trivy gates admission
|
||||||
|
// with --exit-code 1 --severity CRITICAL on that same ref.
|
||||||
|
func TestBuildJobKanikoPushesAndTrivyGates(t *testing.T) {
|
||||||
|
p := sampleJobParams()
|
||||||
|
job, err := BuildJob(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildJob: %v", err)
|
||||||
|
}
|
||||||
|
if len(job.Spec.Template.Spec.InitContainers) != 1 {
|
||||||
|
t.Fatalf("expected exactly one (kaniko) initContainer")
|
||||||
|
}
|
||||||
|
kaniko := job.Spec.Template.Spec.InitContainers[0]
|
||||||
|
if kaniko.Name != "kaniko" {
|
||||||
|
t.Errorf("init container = %q, want kaniko", kaniko.Name)
|
||||||
|
}
|
||||||
|
if !hasArg(kaniko.Args, "--destination="+p.ImageRef) {
|
||||||
|
t.Errorf("kaniko must push to %q, args=%v", p.ImageRef, kaniko.Args)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(job.Spec.Template.Spec.Containers) != 1 {
|
||||||
|
t.Fatalf("expected exactly one (trivy) main container")
|
||||||
|
}
|
||||||
|
trivy := job.Spec.Template.Spec.Containers[0]
|
||||||
|
if trivy.Name != "trivy" {
|
||||||
|
t.Errorf("main container = %q, want trivy", trivy.Name)
|
||||||
|
}
|
||||||
|
// The scan gate: a CRITICAL CVE must fail the Pod (and thus the Job).
|
||||||
|
if !argPairPresent(trivy.Args, "--exit-code", "1") {
|
||||||
|
t.Errorf("trivy must run with --exit-code 1, args=%v", trivy.Args)
|
||||||
|
}
|
||||||
|
if !argPairPresent(trivy.Args, "--severity", "CRITICAL") {
|
||||||
|
t.Errorf("trivy must gate on --severity CRITICAL, args=%v", trivy.Args)
|
||||||
|
}
|
||||||
|
if !hasArg(trivy.Args, p.ImageRef) {
|
||||||
|
t.Errorf("trivy must scan the pushed ref %q, args=%v", p.ImageRef, trivy.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build namespace egress lock must be default-deny: deny all ingress, and
|
||||||
|
// allow egress only to DNS + the internal registry — never an allow-all rule.
|
||||||
|
func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) {
|
||||||
|
np := BuildNetworkPolicy(NetPolParams{
|
||||||
|
Namespace: "felis-build",
|
||||||
|
RegistryNamespace: "felis-system",
|
||||||
|
RegistryPort: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !hasPolicyType(np, networkingv1.PolicyTypeEgress) || !hasPolicyType(np, networkingv1.PolicyTypeIngress) {
|
||||||
|
t.Fatal("policy must govern both Ingress and Egress")
|
||||||
|
}
|
||||||
|
// Ingress: empty rule slice = deny all.
|
||||||
|
if len(np.Spec.Ingress) != 0 {
|
||||||
|
t.Errorf("ingress must be empty (deny all), got %d rules", len(np.Spec.Ingress))
|
||||||
|
}
|
||||||
|
// The selector must capture every build Pod by the managed-by label.
|
||||||
|
if np.Spec.PodSelector.MatchLabels[LabelManagedBy] != managedByValue {
|
||||||
|
t.Errorf("pod selector must match managed-by=%s", managedByValue)
|
||||||
|
}
|
||||||
|
// No egress rule may be an allow-all (a rule with neither To peers nor Ports
|
||||||
|
// would permit unrestricted egress).
|
||||||
|
for i, rule := range np.Spec.Egress {
|
||||||
|
if len(rule.To) == 0 && len(rule.Ports) == 0 {
|
||||||
|
t.Errorf("egress rule %d is allow-all (no To, no Ports) — internet would be open", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The registry must be reachable (by namespace selector), and DNS allowed.
|
||||||
|
if !egressAllowsNamespace(np, "felis-system") {
|
||||||
|
t.Error("egress must allow the registry namespace")
|
||||||
|
}
|
||||||
|
if !egressAllowsPort(np, 53) {
|
||||||
|
t.Error("egress must allow DNS (port 53)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no package-source CIDRs configured, there must be zero IPBlock egress —
|
||||||
|
// the most locked-down default (no open internet).
|
||||||
|
func TestBuildNetworkPolicyDefaultsToNoInternet(t *testing.T) {
|
||||||
|
np := BuildNetworkPolicy(NetPolParams{
|
||||||
|
Namespace: "felis-build",
|
||||||
|
RegistryNamespace: "felis-system",
|
||||||
|
})
|
||||||
|
for i, rule := range np.Spec.Egress {
|
||||||
|
for _, peer := range rule.To {
|
||||||
|
if peer.IPBlock != nil {
|
||||||
|
t.Errorf("egress rule %d has an IPBlock but no package sources were configured", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNetworkPolicyAllowsConfiguredPackageMirrors(t *testing.T) {
|
||||||
|
cidr := "192.0.2.0/24"
|
||||||
|
np := BuildNetworkPolicy(NetPolParams{
|
||||||
|
Namespace: "felis-build",
|
||||||
|
RegistryNamespace: "felis-system",
|
||||||
|
PackageSourceCIDRs: []string{cidr},
|
||||||
|
})
|
||||||
|
found := false
|
||||||
|
for _, rule := range np.Spec.Egress {
|
||||||
|
for _, peer := range rule.To {
|
||||||
|
if peer.IPBlock != nil && peer.IPBlock.CIDR == cidr {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("configured package-mirror CIDR %q not present in egress", cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build SA is the most dangerous identity if mis-scoped: it must be bare —
|
||||||
|
// no secrets, token automount disabled, and (by having no Role anywhere) no API
|
||||||
|
// rights. We assert the spec-level properties the renderer controls.
|
||||||
|
func TestBuildServiceAccountIsBare(t *testing.T) {
|
||||||
|
sa := BuildServiceAccount("felis-build", "felis-build")
|
||||||
|
if sa.Namespace != "felis-build" {
|
||||||
|
t.Errorf("SA namespace = %q, want felis-build", sa.Namespace)
|
||||||
|
}
|
||||||
|
if sa.AutomountServiceAccountToken == nil || *sa.AutomountServiceAccountToken {
|
||||||
|
t.Error("SA must disable token automounting")
|
||||||
|
}
|
||||||
|
if len(sa.Secrets) != 0 {
|
||||||
|
t.Errorf("SA must carry no secrets, got %d", len(sa.Secrets))
|
||||||
|
}
|
||||||
|
if len(sa.ImagePullSecrets) != 0 {
|
||||||
|
t.Errorf("SA must carry no image-pull secrets, got %d", len(sa.ImagePullSecrets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid resource limit must surface as an error rather than render a Job
|
||||||
|
// with no limits.
|
||||||
|
func TestBuildJobRejectsBadResourceLimit(t *testing.T) {
|
||||||
|
p := sampleJobParams()
|
||||||
|
p.CPULimit = "not-a-quantity"
|
||||||
|
if _, err := BuildJob(p); err == nil {
|
||||||
|
t.Error("expected error for an unparseable CPU limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
func hasArg(args []string, want string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// argPairPresent reports whether flag is immediately followed by val (the
|
||||||
|
// `--exit-code 1` two-token form).
|
||||||
|
func argPairPresent(args []string, flag, val string) bool {
|
||||||
|
for i := 0; i < len(args)-1; i++ {
|
||||||
|
if args[i] == flag && args[i+1] == val {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPolicyType(np *networkingv1.NetworkPolicy, t networkingv1.PolicyType) bool {
|
||||||
|
for _, pt := range np.Spec.PolicyTypes {
|
||||||
|
if pt == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func egressAllowsNamespace(np *networkingv1.NetworkPolicy, ns string) bool {
|
||||||
|
for _, rule := range np.Spec.Egress {
|
||||||
|
for _, peer := range rule.To {
|
||||||
|
if peer.NamespaceSelector != nil &&
|
||||||
|
peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] == ns {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func egressAllowsPort(np *networkingv1.NetworkPolicy, port int32) bool {
|
||||||
|
for _, rule := range np.Spec.Egress {
|
||||||
|
for _, p := range rule.Ports {
|
||||||
|
if p.Port != nil && p.Port.IntVal == port {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard against accidental shorthand: the test image ref must be host-qualified.
|
||||||
|
func TestSampleRefIsHostQualified(t *testing.T) {
|
||||||
|
if !strings.Contains(sampleJobParams().ImageRef, "/") {
|
||||||
|
t.Fatal("sample image ref must be host-qualified")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// K8sJobs is the production Jobs backed by a controller-runtime client (spec
|
||||||
|
// §16). It creates the Kaniko+Trivy build Job, reads its phase for the scan-gate
|
||||||
|
// translation, and deletes it on cancel — nothing more. The cluster-bootstrap
|
||||||
|
// objects (the felis-build namespace, the weak SA, and the egress
|
||||||
|
// NetworkPolicy) are installed once by the deployment manifests (spec §21), not
|
||||||
|
// per build, so this binding never needs to create them. It is integration-
|
||||||
|
// tested against a live cluster, not the hermetic build_test.go suite.
|
||||||
|
type K8sJobs struct {
|
||||||
|
c client.Client
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewK8sJobs builds a Jobs over c using cfg for the namespace and image refs.
|
||||||
|
func NewK8sJobs(c client.Client, cfg Config) *K8sJobs {
|
||||||
|
return &K8sJobs{c: c, cfg: cfg.withDefaults()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBuildJob renders and applies the build Job, returning its name. The Job
|
||||||
|
// is the security-critical object; its shape is fixed by BuildJob (jobspec.go)
|
||||||
|
// and asserted by jobspec_test.go.
|
||||||
|
func (k *K8sJobs) CreateBuildJob(ctx context.Context, p JobParams) (string, error) {
|
||||||
|
job, err := BuildJob(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := k.c.Create(ctx, job); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return job.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobPhase reads the Job and maps its status to a JobPhase. A missing Job
|
||||||
|
// (GC'd, never created) is JobUnknown, which Sync treats as failed. The mapping
|
||||||
|
// is deliberately conservative: a Job is Succeeded only when the Complete
|
||||||
|
// condition is true, so a half-finished Job is never admitted.
|
||||||
|
func (k *K8sJobs) JobPhase(ctx context.Context, jobName string) (JobPhase, error) {
|
||||||
|
var job batchv1.Job
|
||||||
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.cfg.Namespace, Name: jobName}, &job); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return JobUnknown, nil
|
||||||
|
}
|
||||||
|
return JobUnknown, err
|
||||||
|
}
|
||||||
|
for _, cond := range job.Status.Conditions {
|
||||||
|
if cond.Status != "True" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch cond.Type {
|
||||||
|
case batchv1.JobComplete:
|
||||||
|
return JobSucceeded, nil
|
||||||
|
case batchv1.JobFailed:
|
||||||
|
// Covers a CRITICAL CVE (trivy --exit-code 1), a kaniko failure, and
|
||||||
|
// DeadlineExceeded — all are a rejected build.
|
||||||
|
return JobFailed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if job.Status.Active > 0 {
|
||||||
|
return JobRunning, nil
|
||||||
|
}
|
||||||
|
return JobPending, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelBuildJob deletes the Job and, via background propagation, its pods. A
|
||||||
|
// missing Job is not an error: cancellation is idempotent.
|
||||||
|
func (k *K8sJobs) CancelBuildJob(ctx context.Context, jobName string) error {
|
||||||
|
bg := metav1.DeletePropagationBackground
|
||||||
|
obj := &batchv1.Job{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: k.cfg.Namespace, Name: jobName},
|
||||||
|
}
|
||||||
|
if err := k.c.Delete(ctx, obj, &client.DeleteOptions{PropagationPolicy: &bg}); err != nil &&
|
||||||
|
!apierrors.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// invalidf builds a validation error wrapping ErrInvalid so the API layer maps
|
||||||
|
// every malformed-request case to a single 400 path.
|
||||||
|
func invalidf(format string, a ...any) error {
|
||||||
|
return fmt.Errorf("%w: "+format, append([]any{ErrInvalid}, a...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageNameRE matches the path+tag of an image reference under the registry
|
||||||
|
// host, e.g. "foo/bar:1.0" or "mc-paper:latest". It is intentionally strict:
|
||||||
|
// lowercase path segments, an optional tag of the same alphabet, no digests, no
|
||||||
|
// shell metacharacters that could escape into the Kaniko/Trivy argv.
|
||||||
|
var imageNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9._/-]*[a-z0-9])?(:[a-zA-Z0-9._-]+)?$`)
|
||||||
|
|
||||||
|
// Validate enforces the §16 admission rules on a build request: the target must
|
||||||
|
// address the internal registry, the Dockerfile must be present and within the
|
||||||
|
// size cap, and a context reference is required (Kaniko pulls it, spec §17).
|
||||||
|
func Validate(req Request, cfg Config) error {
|
||||||
|
cfg = cfg.withDefaults()
|
||||||
|
if err := validateRegistryTarget(req.ImageRef, cfg.RegistryURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Dockerfile) == "" {
|
||||||
|
return invalidf("dockerfile is required")
|
||||||
|
}
|
||||||
|
if len(req.Dockerfile) > cfg.MaxDockerfileBytes {
|
||||||
|
return invalidf("dockerfile exceeds %d bytes", cfg.MaxDockerfileBytes)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ContextRef) == "" {
|
||||||
|
return invalidf("context reference is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateImageRef checks a bare image reference (used by external admission,
|
||||||
|
// where there is no registry-target constraint beyond well-formedness). A
|
||||||
|
// whitelist entry may be a tag wildcard ("registry/foo:*", a legitimate
|
||||||
|
// image_whitelist value per spec §15): the trailing ":*" is stripped before the
|
||||||
|
// path is validated. This is safe because a wildcard is only ever compared
|
||||||
|
// against concrete refs by imageMatches — it never reaches a Kaniko/Trivy argv,
|
||||||
|
// unlike a build push target (validateRegistryTarget stays strictly concrete).
|
||||||
|
func ValidateImageRef(ref string) error {
|
||||||
|
if ref == "" {
|
||||||
|
return invalidf("image reference is required")
|
||||||
|
}
|
||||||
|
host, rest, ok := splitRegistryHost(ref)
|
||||||
|
if !ok {
|
||||||
|
return invalidf("image reference %q must be host-qualified (host/path:tag)", ref)
|
||||||
|
}
|
||||||
|
if repo, isWildcard := strings.CutSuffix(rest, ":*"); isWildcard {
|
||||||
|
rest = repo
|
||||||
|
}
|
||||||
|
if !imageNameRE.MatchString(rest) {
|
||||||
|
return invalidf("invalid image path/tag %q", rest)
|
||||||
|
}
|
||||||
|
_ = host
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTag splits an image reference's path from its tag. It is registry-port
|
||||||
|
// safe: only a colon *after* the final path separator is a tag separator, so
|
||||||
|
// "registry:5000/foo" splits to ("registry:5000/foo", ""), never a bogus tag.
|
||||||
|
func splitTag(ref string) (repo, tag string) {
|
||||||
|
slash := strings.LastIndexByte(ref, '/')
|
||||||
|
colon := strings.LastIndexByte(ref, ':')
|
||||||
|
if colon > slash {
|
||||||
|
return ref[:colon], ref[colon+1:]
|
||||||
|
}
|
||||||
|
return ref, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageMatches reports whether a concrete image ref is admitted by a whitelist
|
||||||
|
// pattern. A pattern is either exact ("registry/foo:1.0") or a tag wildcard
|
||||||
|
// ("registry/foo:*", spec §15) that matches any non-empty tag on the same repo.
|
||||||
|
// A wildcard never matches an untagged ref — admission is always to a concrete
|
||||||
|
// tag.
|
||||||
|
func imageMatches(ref, pattern string) bool {
|
||||||
|
if ref == pattern {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
prefix, ok := strings.CutSuffix(pattern, ":*")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
repo, tag := splitTag(ref)
|
||||||
|
return repo == prefix && tag != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRegistryTarget enforces that a build pushes only to the configured
|
||||||
|
// internal registry — never an arbitrary external host (spec §16: the build can
|
||||||
|
// never push elsewhere; the registry is not a public ingress). When RegistryURL
|
||||||
|
// is unset (tests / not configured) the host constraint is skipped but the
|
||||||
|
// path/tag are still validated.
|
||||||
|
func validateRegistryTarget(ref, registryURL string) error {
|
||||||
|
if ref == "" {
|
||||||
|
return invalidf("image reference is required")
|
||||||
|
}
|
||||||
|
host, rest, ok := splitRegistryHost(ref)
|
||||||
|
if !ok {
|
||||||
|
return invalidf("image reference %q must target the internal registry (host/path:tag)", ref)
|
||||||
|
}
|
||||||
|
if !imageNameRE.MatchString(rest) {
|
||||||
|
return invalidf("invalid image path/tag %q", rest)
|
||||||
|
}
|
||||||
|
if registryURL != "" && host != registryHost(registryURL) {
|
||||||
|
return invalidf("image reference %q must target the internal registry %q, not %q",
|
||||||
|
ref, registryHost(registryURL), host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRegistryHost separates the registry host from the remaining path+tag. A
|
||||||
|
// reference is host-qualified only if the first segment looks like a registry
|
||||||
|
// host — it contains a '.' or ':' (port), matching containerd's heuristic.
|
||||||
|
// "foo/bar:1" (Docker Hub shorthand) is rejected: builds must be explicit about
|
||||||
|
// the internal registry.
|
||||||
|
func splitRegistryHost(ref string) (host, rest string, ok bool) {
|
||||||
|
slash := strings.IndexByte(ref, '/')
|
||||||
|
if slash < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
host = ref[:slash]
|
||||||
|
rest = ref[slash+1:]
|
||||||
|
if !strings.ContainsAny(host, ".:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if rest == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return host, rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryHost strips any scheme and path from a configured registry URL,
|
||||||
|
// leaving the host[:port] that an image reference must match.
|
||||||
|
func registryHost(registryURL string) string {
|
||||||
|
h := registryURL
|
||||||
|
if i := strings.Index(h, "://"); i >= 0 {
|
||||||
|
h = h[i+3:]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(h, '/'); i >= 0 {
|
||||||
|
h = h[:i]
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Package config loads and validates felis.toml (spec §24). root_domain lives
|
||||||
|
// here and nowhere else in code: every FQDN is composed at runtime as
|
||||||
|
// subdomain + "." + root_domain, so changing the deployment domain is a
|
||||||
|
// one-line config edit and the source tree stays domain-agnostic.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the parsed felis.toml.
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
Database DatabaseConfig `toml:"database"`
|
||||||
|
Velocity VelocityConfig `toml:"velocity"`
|
||||||
|
Auth AuthConfig `toml:"auth"`
|
||||||
|
K8s K8sConfig `toml:"k8s"`
|
||||||
|
Registry RegistryConfig `toml:"registry"`
|
||||||
|
Archive ArchiveConfig `toml:"archive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig is the [server] table.
|
||||||
|
type ServerConfig struct {
|
||||||
|
Listen string `toml:"listen"`
|
||||||
|
RootDomain string `toml:"root_domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig is the [database] table.
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
URL string `toml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VelocityConfig is the [velocity] table.
|
||||||
|
type VelocityConfig struct {
|
||||||
|
PublicIP string `toml:"public_ip"`
|
||||||
|
ServiceTokenRef string `toml:"service_token_ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig is the [auth] table: the two privileged faces and the access-JWT
|
||||||
|
// audience the API enforces.
|
||||||
|
type AuthConfig struct {
|
||||||
|
AdminHostname string `toml:"admin_hostname"`
|
||||||
|
PanelHostname string `toml:"panel_hostname"`
|
||||||
|
AccessJWTAud string `toml:"access_jwt_aud"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// K8sConfig is the [k8s] table.
|
||||||
|
type K8sConfig struct {
|
||||||
|
Namespace string `toml:"namespace"`
|
||||||
|
EgressMode string `toml:"egress_mode"`
|
||||||
|
MetalLBPool string `toml:"metallb_pool"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryConfig is the [registry] table.
|
||||||
|
type RegistryConfig struct {
|
||||||
|
URL string `toml:"url"`
|
||||||
|
BuildNamespace string `toml:"build_namespace"`
|
||||||
|
// UserUploadsContext is the object-store base under which a user-submitted
|
||||||
|
// modpack's Kaniko build context is pinned. It belongs to the §16 build
|
||||||
|
// subsystem's input domain (the build-context store), introduced by the
|
||||||
|
// user-directed modpack approval lane (see internal/submit package doc). The
|
||||||
|
// lane derives {UserUploadsContext}/{submissionID}/context.tar.gz; the upload
|
||||||
|
// transport that places the blob there is a separate, deferred integration
|
||||||
|
// (INTEGRATION-ONLY). It is kept distinct from [archive] on purpose — a world
|
||||||
|
// archive (§19 WorldArchiver) and a build context (§16) are different artifacts
|
||||||
|
// with different lifecycles, so the two must not share a store binding.
|
||||||
|
UserUploadsContext string `toml:"user_uploads_context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveConfig is the [archive] table plus its [archive.s3] subtable (spec §19).
|
||||||
|
type ArchiveConfig struct {
|
||||||
|
Store string `toml:"store"`
|
||||||
|
LocalPath string `toml:"local_path"`
|
||||||
|
Retention string `toml:"retention"`
|
||||||
|
WarnBefore []string `toml:"warn_before"`
|
||||||
|
MaxLocalBytes string `toml:"max_local_bytes"`
|
||||||
|
S3 ArchiveS3Config `toml:"s3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveS3Config is the [archive.s3] subtable.
|
||||||
|
type ArchiveS3Config struct {
|
||||||
|
Endpoint string `toml:"endpoint"`
|
||||||
|
Bucket string `toml:"bucket"`
|
||||||
|
AccessKeyRef string `toml:"access_key_ref"`
|
||||||
|
SecretKeyRef string `toml:"secret_key_ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// archive store backends recognized by §19.
|
||||||
|
var archiveStores = map[string]struct{}{
|
||||||
|
"tarLocal": {},
|
||||||
|
"tarS3": {},
|
||||||
|
"volumeSnapshot": {},
|
||||||
|
"longhorn": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// archive store backends this build can actually honor. §19 names four, but only
|
||||||
|
// tarLocal is implemented: the reaper's buildArchiver, the `felis restore`
|
||||||
|
// command, and the felis-api restore executor all construct tarLocal and nothing
|
||||||
|
// else. A config naming a recognized-but-unimplemented store is a footgun — it
|
||||||
|
// clears the "is this a real store name" check yet silently breaks retention (the
|
||||||
|
// reaper CronJob fails every run) and restore (503), while felis-api otherwise
|
||||||
|
// looks healthy. Validate rejects it so every binary that loads config (migrate,
|
||||||
|
// api, reaper) fails fast at startup with a clear remediation instead. (`felis
|
||||||
|
// restore` enforces the same invariant on its own --store flag: it runs inside
|
||||||
|
// the sandboxed weak-SA restore Job and by design never loads felis.toml or holds
|
||||||
|
// DB credentials, so it cannot lean on this load-time check.)
|
||||||
|
var implementedArchiveStores = map[string]struct{}{
|
||||||
|
"tarLocal": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults that callers get when the field is omitted.
|
||||||
|
const (
|
||||||
|
defaultListen = "0.0.0.0:8080"
|
||||||
|
defaultNamespace = "minecraft"
|
||||||
|
defaultEgressMode = "loadbalancer"
|
||||||
|
defaultStore = "tarLocal"
|
||||||
|
// defaultUserUploadsContext is a non-empty, platform-namespaced placeholder so
|
||||||
|
// the modpack approval lane's derived context ref is well-formed even before a
|
||||||
|
// deployment points it at a real object store. The blob transport is deferred,
|
||||||
|
// so this base only has to be a sensible, parseable prefix (see the §16 build
|
||||||
|
// subsystem and the internal/submit package doc for the lane's provenance).
|
||||||
|
defaultUserUploadsContext = "s3://felis-user-uploads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load reads and validates a felis.toml from path.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
md, err := toml.DecodeFile(path, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: decode %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if undecoded := md.Undecoded(); len(undecoded) > 0 {
|
||||||
|
// Surface typos rather than silently ignoring unknown keys.
|
||||||
|
keys := make([]string, len(undecoded))
|
||||||
|
for i, k := range undecoded {
|
||||||
|
keys[i] = k.String()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("config: unknown keys in %s: %s", path, strings.Join(keys, ", "))
|
||||||
|
}
|
||||||
|
cfg.applyDefaults()
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) applyDefaults() {
|
||||||
|
if c.Server.Listen == "" {
|
||||||
|
c.Server.Listen = defaultListen
|
||||||
|
}
|
||||||
|
if c.K8s.Namespace == "" {
|
||||||
|
c.K8s.Namespace = defaultNamespace
|
||||||
|
}
|
||||||
|
if c.K8s.EgressMode == "" {
|
||||||
|
c.K8s.EgressMode = defaultEgressMode
|
||||||
|
}
|
||||||
|
if c.Archive.Store == "" {
|
||||||
|
c.Archive.Store = defaultStore
|
||||||
|
}
|
||||||
|
if c.Registry.UserUploadsContext == "" {
|
||||||
|
c.Registry.UserUploadsContext = defaultUserUploadsContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate enforces the mandatory fields (spec §24: database.url is 强制) and
|
||||||
|
// the closed value sets.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Database.URL == "" {
|
||||||
|
return fmt.Errorf("config: [database] url is required")
|
||||||
|
}
|
||||||
|
if c.Server.RootDomain == "" {
|
||||||
|
return fmt.Errorf("config: [server] root_domain is required")
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Server.RootDomain, ".") {
|
||||||
|
return fmt.Errorf("config: [server] root_domain %q is not a domain", c.Server.RootDomain)
|
||||||
|
}
|
||||||
|
if _, ok := archiveStores[c.Archive.Store]; !ok {
|
||||||
|
return fmt.Errorf("config: [archive] store %q is not one of tarLocal|tarS3|volumeSnapshot|longhorn", c.Archive.Store)
|
||||||
|
}
|
||||||
|
if _, ok := implementedArchiveStores[c.Archive.Store]; !ok {
|
||||||
|
return fmt.Errorf("config: [archive] store %q is recognized by §19 but not implemented in this build — only tarLocal is supported; set store = \"tarLocal\"", c.Archive.Store)
|
||||||
|
}
|
||||||
|
switch c.K8s.EgressMode {
|
||||||
|
case "loadbalancer", "nodeport":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config: [k8s] egress_mode %q must be loadbalancer or nodeport", c.K8s.EgressMode)
|
||||||
|
}
|
||||||
|
// The registry URL is a bare host[:port] (spec §24: url="registry.felis.svc:5000"),
|
||||||
|
// never a scheme-qualified URL. This is not cosmetic: two consumers read it with
|
||||||
|
// different robustness. The admin build path normalizes via registryHost() (which
|
||||||
|
// strips a scheme), but the user-modpack approval lane derives its push target by
|
||||||
|
// string concatenation (submit.Manager.deriveImageRef → "{url}/user-uploads/{id}:latest")
|
||||||
|
// with no stripping. A "http://" prefix would make the lane's pre-CAS build.Validate
|
||||||
|
// reject every derived ref (imageNameRE forbids the leading "http:/…") and collapse
|
||||||
|
// EVERY approve to 500 while admin builds keep working — a silent split-brain. Fail
|
||||||
|
// fast at load instead, with the contract spelled out.
|
||||||
|
if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") {
|
||||||
|
return fmt.Errorf("config: [registry] url %q must be a bare host[:port] with no scheme (e.g. registry.felis.svc:5000); a scheme breaks the user-modpack build lane's derived push target", c.Registry.URL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTOML writes content to a temp felis.toml and returns its path.
|
||||||
|
func writeTOML(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "felis.toml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write toml: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: tests use the neutral example domain mc.example.net, never a real
|
||||||
|
// deployment domain, to keep the source tree clean of domain literals.
|
||||||
|
const validTOML = `
|
||||||
|
[server]
|
||||||
|
listen = "0.0.0.0:9090"
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis:secret@db:5432/felis"
|
||||||
|
|
||||||
|
[velocity]
|
||||||
|
public_ip = "203.0.113.4"
|
||||||
|
service_token_ref = "felis-velocity-token"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
admin_hostname = "admin.example.net"
|
||||||
|
panel_hostname = "panel.example.net"
|
||||||
|
access_jwt_aud = "felis-panel"
|
||||||
|
|
||||||
|
[k8s]
|
||||||
|
namespace = "minecraft"
|
||||||
|
egress_mode = "loadbalancer"
|
||||||
|
metallb_pool = "192.0.2.200-250"
|
||||||
|
|
||||||
|
[registry]
|
||||||
|
url = "registry.felis.svc:5000"
|
||||||
|
build_namespace = "felis-build"
|
||||||
|
|
||||||
|
[archive]
|
||||||
|
store = "tarLocal"
|
||||||
|
local_path = "backup-pvc"
|
||||||
|
retention = "3mo"
|
||||||
|
warn_before = ["3d", "1d"]
|
||||||
|
max_local_bytes = "200Gi"
|
||||||
|
[archive.s3]
|
||||||
|
endpoint = ""
|
||||||
|
bucket = "felis-backups"
|
||||||
|
access_key_ref = ""
|
||||||
|
secret_key_ref = ""
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestLoadValid(t *testing.T) {
|
||||||
|
cfg, err := config.Load(writeTOML(t, validTOML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.Listen != "0.0.0.0:9090" {
|
||||||
|
t.Errorf("listen = %q", cfg.Server.Listen)
|
||||||
|
}
|
||||||
|
if cfg.Server.RootDomain != "mc.example.net" {
|
||||||
|
t.Errorf("root_domain = %q", cfg.Server.RootDomain)
|
||||||
|
}
|
||||||
|
if cfg.Database.URL == "" {
|
||||||
|
t.Error("database url empty")
|
||||||
|
}
|
||||||
|
if cfg.Archive.Store != "tarLocal" {
|
||||||
|
t.Errorf("archive store = %q", cfg.Archive.Store)
|
||||||
|
}
|
||||||
|
if len(cfg.Archive.WarnBefore) != 2 || cfg.Archive.WarnBefore[0] != "3d" {
|
||||||
|
t.Errorf("warn_before = %v", cfg.Archive.WarnBefore)
|
||||||
|
}
|
||||||
|
if cfg.Archive.S3.Bucket != "felis-backups" {
|
||||||
|
t.Errorf("s3 bucket = %q", cfg.Archive.S3.Bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAppliesDefaults(t *testing.T) {
|
||||||
|
cfg, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.Listen != "0.0.0.0:8080" {
|
||||||
|
t.Errorf("default listen = %q, want 0.0.0.0:8080", cfg.Server.Listen)
|
||||||
|
}
|
||||||
|
if cfg.K8s.Namespace != "minecraft" {
|
||||||
|
t.Errorf("default namespace = %q, want minecraft", cfg.K8s.Namespace)
|
||||||
|
}
|
||||||
|
if cfg.K8s.EgressMode != "loadbalancer" {
|
||||||
|
t.Errorf("default egress_mode = %q", cfg.K8s.EgressMode)
|
||||||
|
}
|
||||||
|
if cfg.Archive.Store != "tarLocal" {
|
||||||
|
t.Errorf("default archive store = %q", cfg.Archive.Store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingDatabaseURL(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when database.url is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingRootDomain(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when root_domain is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownArchiveStore(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
[archive]
|
||||||
|
store = "magicbox"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown archive store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRejectsUnimplementedArchiveStore guards the §19/build-reality gap:
|
||||||
|
// tarS3, volumeSnapshot and longhorn are recognized store names but only
|
||||||
|
// tarLocal is implemented in this build. A config naming one of them must be
|
||||||
|
// rejected at load — otherwise felis-api boots green while the reaper CronJob
|
||||||
|
// fails every run and restore silently 503s. The error must point the operator
|
||||||
|
// at the fix (tarLocal), distinct from the "unknown store" message.
|
||||||
|
func TestLoadRejectsUnimplementedArchiveStore(t *testing.T) {
|
||||||
|
for _, store := range []string{"tarS3", "volumeSnapshot", "longhorn"} {
|
||||||
|
t.Run(store, func(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
[archive]
|
||||||
|
store = "`+store+`"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for recognized-but-unimplemented store %q", store)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "tarLocal") {
|
||||||
|
t.Errorf("error for %q should point at the tarLocal remediation, got: %v", store, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRejectsSchemeQualifiedRegistryURL guards the §24 split-brain: the
|
||||||
|
// registry url is a bare host[:port], read scheme-tolerantly by the admin build
|
||||||
|
// path (registryHost strips the scheme) but scheme-INtolerantly by the
|
||||||
|
// user-modpack lane (deriveImageRef concatenates raw). A scheme-qualified url
|
||||||
|
// would boot felis-api green and 500 every approve while admin builds keep
|
||||||
|
// working, so it must be rejected at load with the bare-host contract spelled
|
||||||
|
// out. Both http:// and https:// are caught (the check is on "://").
|
||||||
|
func TestLoadRejectsSchemeQualifiedRegistryURL(t *testing.T) {
|
||||||
|
for _, url := range []string{"http://registry.felis.svc:5000", "https://registry.felis.svc:5000"} {
|
||||||
|
t.Run(url, func(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
[registry]
|
||||||
|
url = "`+url+`"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for scheme-qualified registry url %q", url)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "scheme") {
|
||||||
|
t.Errorf("error for %q should explain the bare-host contract, got: %v", url, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownKeys(t *testing.T) {
|
||||||
|
_, err := config.Load(writeTOML(t, `
|
||||||
|
[server]
|
||||||
|
root_domain = "mc.example.net"
|
||||||
|
typo_field = "oops"
|
||||||
|
[database]
|
||||||
|
url = "postgres://felis@db/felis"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package naming enforces the portability and admission rules (spec §2, §22):
|
||||||
|
// a server name matches ^[a-z0-9-]{3,32}$ and is non-reserved, and every
|
||||||
|
// hostname must be a single label under the configured root_domain. The root
|
||||||
|
// domain is never hardcoded — it is always supplied by config — so this package
|
||||||
|
// stays free of any deployment-specific domain.
|
||||||
|
package naming
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
serverNameRE = regexp.MustCompile(`^[a-z0-9-]{3,32}$`)
|
||||||
|
dnsLabelRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// reserved subdomains/server names that users may not claim: proxy/lobby and
|
||||||
|
// the platform's own faces.
|
||||||
|
var reserved = map[string]struct{}{
|
||||||
|
"lobby": {},
|
||||||
|
"admin": {},
|
||||||
|
"panel": {},
|
||||||
|
"api": {},
|
||||||
|
"felis": {},
|
||||||
|
"velocity": {},
|
||||||
|
"registry": {},
|
||||||
|
"internal": {},
|
||||||
|
"www": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateServerName checks the §22 name rule and reservation list.
|
||||||
|
func ValidateServerName(name string) error {
|
||||||
|
if !serverNameRE.MatchString(name) {
|
||||||
|
return fmt.Errorf("naming: invalid server name %q: must match ^[a-z0-9-]{3,32}$", name)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
||||||
|
return fmt.Errorf("naming: server name %q must not start or end with '-'", name)
|
||||||
|
}
|
||||||
|
if _, ok := reserved[name]; ok {
|
||||||
|
return fmt.Errorf("naming: server name %q is reserved", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReserved reports whether label is on the reserved list.
|
||||||
|
func IsReserved(label string) bool {
|
||||||
|
_, ok := reserved[label]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's
|
||||||
|
// volumeClaimTemplate is named "world", so a single-replica server's world PVC
|
||||||
|
// is "world-<name>-0". This is the one naming convention shared by the operator
|
||||||
|
// (which creates the PVC), the reaper (which deletes it), and restore (which
|
||||||
|
// mounts it), so it lives here rather than being duplicated per subsystem.
|
||||||
|
const worldVolumeName = "world"
|
||||||
|
|
||||||
|
// WorldPVCName returns the world PersistentVolumeClaim name for a server,
|
||||||
|
// matching the operator's StatefulSet volumeClaimTemplate naming
|
||||||
|
// ("world-<name>-0" for the sole replica).
|
||||||
|
func WorldPVCName(server string) string {
|
||||||
|
return worldVolumeName + "-" + server + "-0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hostname composes subdomain.rootDomain after validating the subdomain.
|
||||||
|
func Hostname(subdomain, rootDomain string) (string, error) {
|
||||||
|
if err := ValidateServerName(subdomain); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if rootDomain == "" {
|
||||||
|
return "", fmt.Errorf("naming: root domain is empty")
|
||||||
|
}
|
||||||
|
return subdomain + "." + rootDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateHostname enforces the §2 invariant that host is a single label
|
||||||
|
// directly under rootDomain.
|
||||||
|
func ValidateHostname(host, rootDomain string) error {
|
||||||
|
if rootDomain == "" {
|
||||||
|
return fmt.Errorf("naming: root domain is empty")
|
||||||
|
}
|
||||||
|
suffix := "." + rootDomain
|
||||||
|
if !strings.HasSuffix(host, suffix) {
|
||||||
|
return fmt.Errorf("naming: hostname %q must be under %q", host, rootDomain)
|
||||||
|
}
|
||||||
|
label := strings.TrimSuffix(host, suffix)
|
||||||
|
if label == "" || strings.Contains(label, ".") {
|
||||||
|
return fmt.Errorf("naming: hostname %q must be a single label under %q", host, rootDomain)
|
||||||
|
}
|
||||||
|
if !dnsLabelRE.MatchString(label) {
|
||||||
|
return fmt.Errorf("naming: invalid hostname label %q", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package naming_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/naming"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateServerName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"survival", true},
|
||||||
|
{"creative-2", true},
|
||||||
|
{"abc", true},
|
||||||
|
{"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", true}, // 32 chars
|
||||||
|
{"ab", false}, // too short
|
||||||
|
{"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q", false}, // 33 chars
|
||||||
|
{"Survival", false}, // uppercase
|
||||||
|
{"has_underscore", false}, // illegal char
|
||||||
|
{"has space", false}, // illegal char
|
||||||
|
{"-leading", false}, // leading hyphen
|
||||||
|
{"trailing-", false}, // trailing hyphen
|
||||||
|
{"lobby", false}, // reserved
|
||||||
|
{"admin", false}, // reserved
|
||||||
|
{"api", false}, // reserved
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := naming.ValidateServerName(c.name)
|
||||||
|
if c.ok && err != nil {
|
||||||
|
t.Errorf("ValidateServerName(%q) = %v, want ok", c.name, err)
|
||||||
|
}
|
||||||
|
if !c.ok && err == nil {
|
||||||
|
t.Errorf("ValidateServerName(%q) = nil, want error", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorldPVCName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"survival": "world-survival-0",
|
||||||
|
"creative-2": "world-creative-2-0",
|
||||||
|
}
|
||||||
|
for server, want := range cases {
|
||||||
|
if got := naming.WorldPVCName(server); got != want {
|
||||||
|
t.Errorf("WorldPVCName(%q) = %q, want %q", server, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostname(t *testing.T) {
|
||||||
|
const root = "mc.example.net"
|
||||||
|
got, err := naming.Hostname("survival", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hostname: %v", err)
|
||||||
|
}
|
||||||
|
if got != "survival.mc.example.net" {
|
||||||
|
t.Errorf("Hostname = %q, want survival.mc.example.net", got)
|
||||||
|
}
|
||||||
|
if _, err := naming.Hostname("lobby", root); err == nil {
|
||||||
|
t.Error("Hostname should reject a reserved subdomain")
|
||||||
|
}
|
||||||
|
if _, err := naming.Hostname("survival", ""); err == nil {
|
||||||
|
t.Error("Hostname should reject an empty root domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateHostname(t *testing.T) {
|
||||||
|
const root = "mc.example.net"
|
||||||
|
cases := []struct {
|
||||||
|
host string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"survival.mc.example.net", true},
|
||||||
|
{"a.mc.example.net", true},
|
||||||
|
{"deep.sub.mc.example.net", false}, // not a single label under root
|
||||||
|
{"survival.evil.example.org", false},
|
||||||
|
{"mc.example.net", false}, // bare root, no label
|
||||||
|
{".mc.example.net", false},
|
||||||
|
{"-bad.mc.example.net", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := naming.ValidateHostname(c.host, root)
|
||||||
|
if c.ok && err != nil {
|
||||||
|
t.Errorf("ValidateHostname(%q) = %v, want ok", c.host, err)
|
||||||
|
}
|
||||||
|
if !c.ok && err == nil {
|
||||||
|
t.Errorf("ValidateHostname(%q) = nil, want error", c.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := naming.ValidateHostname("x.mc.example.net", ""); err == nil {
|
||||||
|
t.Error("ValidateHostname should reject an empty root domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Package rcon implements a minimal Source RCON client (spec §5, §7).
|
||||||
|
//
|
||||||
|
// The operator uses it for two purposes:
|
||||||
|
// - Readiness probing: a successful Dial (TCP connect + auth) is the
|
||||||
|
// loader-agnostic "RCON 探通" gate. A status ping is never sufficient.
|
||||||
|
// - Graceful shutdown: Execute("save-all flush") then Execute("stop") from
|
||||||
|
// the operator-injected preStop hook.
|
||||||
|
//
|
||||||
|
// Multi-packet responses (a single command whose reply exceeds one ~4 KiB
|
||||||
|
// packet) are not reassembled; Phase-1 commands ("list", "save-all", "stop")
|
||||||
|
// always fit in one packet. This is intentional and documented rather than
|
||||||
|
// silently truncating large replies.
|
||||||
|
package rcon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source RCON packet types.
|
||||||
|
const (
|
||||||
|
typeResponseValue = 0 // SERVERDATA_RESPONSE_VALUE
|
||||||
|
typeExecCommand = 2 // SERVERDATA_EXECCOMMAND
|
||||||
|
typeAuthResponse = 2 // SERVERDATA_AUTH_RESPONSE (same id as EXECCOMMAND)
|
||||||
|
typeAuth = 3 // SERVERDATA_AUTH
|
||||||
|
)
|
||||||
|
|
||||||
|
// authFailedID is the request id a server returns when auth fails.
|
||||||
|
const authFailedID int32 = -1
|
||||||
|
|
||||||
|
// Packet length bounds. minPacketLen is 4 (id) + 4 (type) + 2 (terminators).
|
||||||
|
const (
|
||||||
|
minPacketLen = 10
|
||||||
|
maxPacketLen = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAuthFailed is returned by Dial when the RCON password is rejected.
|
||||||
|
var ErrAuthFailed = errors.New("rcon: authentication failed")
|
||||||
|
|
||||||
|
// DefaultPort is the conventional RCON port.
|
||||||
|
const DefaultPort = 25575
|
||||||
|
|
||||||
|
// Conn is an authenticated RCON connection. It is not safe for concurrent use.
|
||||||
|
type Conn struct {
|
||||||
|
conn net.Conn
|
||||||
|
reqID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a TCP connection to addr and authenticates with password. The
|
||||||
|
// timeout, if > 0, bounds the whole connect+auth handshake; it is cleared on
|
||||||
|
// the returned Conn so subsequent calls block unless SetDeadline is used.
|
||||||
|
func Dial(addr, password string, timeout time.Duration) (*Conn, error) {
|
||||||
|
var (
|
||||||
|
netConn net.Conn
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if timeout > 0 {
|
||||||
|
netConn, err = net.DialTimeout("tcp", addr, timeout)
|
||||||
|
} else {
|
||||||
|
netConn, err = net.Dial("tcp", addr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Conn{conn: netConn}
|
||||||
|
if timeout > 0 {
|
||||||
|
if err := netConn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||||
|
netConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.auth(password); err != nil {
|
||||||
|
netConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Clear the handshake deadline so the connection is reusable.
|
||||||
|
if err := netConn.SetDeadline(time.Time{}); err != nil {
|
||||||
|
netConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDeadline sets an absolute deadline for subsequent Execute calls.
|
||||||
|
func (c *Conn) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) }
|
||||||
|
|
||||||
|
// Close closes the underlying connection.
|
||||||
|
func (c *Conn) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
|
// Execute runs a single command and returns the server's reply body.
|
||||||
|
func (c *Conn) Execute(cmd string) (string, error) {
|
||||||
|
id := c.nextID()
|
||||||
|
if err := writePacket(c.conn, id, typeExecCommand, cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
respID, _, body, err := readPacket(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if respID != id {
|
||||||
|
return "", fmt.Errorf("rcon: response id mismatch: got %d want %d", respID, id)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth performs the SERVERDATA_AUTH handshake. Some servers emit an empty
|
||||||
|
// RESPONSE_VALUE before the auth response, so non-auth packets are skipped.
|
||||||
|
func (c *Conn) auth(password string) error {
|
||||||
|
id := c.nextID()
|
||||||
|
if err := writePacket(c.conn, id, typeAuth, password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
respID, respType, _, err := readPacket(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if respType != typeAuthResponse {
|
||||||
|
// Empty RESPONSE_VALUE echo; keep reading for the real answer.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if respID == authFailedID {
|
||||||
|
return ErrAuthFailed
|
||||||
|
}
|
||||||
|
if respID != id {
|
||||||
|
return fmt.Errorf("rcon: auth id mismatch: got %d want %d", respID, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) nextID() int32 {
|
||||||
|
c.reqID++
|
||||||
|
if c.reqID < 0 {
|
||||||
|
c.reqID = 1
|
||||||
|
}
|
||||||
|
return c.reqID
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePacket encodes one RCON packet: little-endian length, id, type, the
|
||||||
|
// null-terminated body, and a trailing null byte.
|
||||||
|
func writePacket(w io.Writer, id, typ int32, body string) error {
|
||||||
|
bodyBytes := []byte(body)
|
||||||
|
length := int32(4 + 4 + len(bodyBytes) + 2)
|
||||||
|
if length > maxPacketLen {
|
||||||
|
return fmt.Errorf("rcon: outgoing packet too large: %d bytes", length)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 4+length)
|
||||||
|
buf = appendInt32(buf, length)
|
||||||
|
buf = appendInt32(buf, id)
|
||||||
|
buf = appendInt32(buf, typ)
|
||||||
|
buf = append(buf, bodyBytes...)
|
||||||
|
buf = append(buf, 0, 0)
|
||||||
|
_, err := w.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPacket decodes one RCON packet.
|
||||||
|
func readPacket(r io.Reader) (id, typ int32, body string, err error) {
|
||||||
|
var lenBuf [4]byte
|
||||||
|
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
length := int32(binary.LittleEndian.Uint32(lenBuf[:]))
|
||||||
|
if length < minPacketLen || length > maxPacketLen {
|
||||||
|
return 0, 0, "", fmt.Errorf("rcon: invalid packet length %d", length)
|
||||||
|
}
|
||||||
|
payload := make([]byte, length)
|
||||||
|
if _, err = io.ReadFull(r, payload); err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
id = int32(binary.LittleEndian.Uint32(payload[0:4]))
|
||||||
|
typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
|
||||||
|
// Strip the two trailing null bytes from the body.
|
||||||
|
body = string(payload[8 : length-2])
|
||||||
|
return id, typ, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendInt32(buf []byte, v int32) []byte {
|
||||||
|
var b [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return append(buf, b[:]...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package rcon_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRCON is a minimal Source RCON server used to exercise the client
|
||||||
|
// hermetically (no real Minecraft server, no network beyond loopback).
|
||||||
|
type fakeRCON struct {
|
||||||
|
ln net.Listener
|
||||||
|
password string
|
||||||
|
replies map[string]string
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func startFakeRCON(t *testing.T, password string, replies map[string]string) *fakeRCON {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
f := &fakeRCON{ln: ln, password: password, replies: replies}
|
||||||
|
f.wg.Add(1)
|
||||||
|
go f.serve()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRCON) addr() string { return f.ln.Addr().String() }
|
||||||
|
|
||||||
|
func (f *fakeRCON) stop() {
|
||||||
|
f.ln.Close()
|
||||||
|
f.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRCON) serve() {
|
||||||
|
defer f.wg.Done()
|
||||||
|
for {
|
||||||
|
conn, err := f.ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go f.handle(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRCON) handle(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
authed := false
|
||||||
|
for {
|
||||||
|
id, typ, body, err := readFramePacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case 3: // AUTH
|
||||||
|
if body == f.password {
|
||||||
|
authed = true
|
||||||
|
_ = writeFramePacket(conn, id, 2, "") // AUTH_RESPONSE, echo id
|
||||||
|
} else {
|
||||||
|
_ = writeFramePacket(conn, -1, 2, "") // failure
|
||||||
|
}
|
||||||
|
case 2: // EXECCOMMAND
|
||||||
|
if !authed {
|
||||||
|
_ = writeFramePacket(conn, -1, 0, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reply := f.replies[body]
|
||||||
|
_ = writeFramePacket(conn, id, 0, reply)
|
||||||
|
default:
|
||||||
|
_ = writeFramePacket(conn, id, 0, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFramePacket(w io.Writer, id, typ int32, body string) error {
|
||||||
|
b := []byte(body)
|
||||||
|
length := int32(4 + 4 + len(b) + 2)
|
||||||
|
buf := make([]byte, 0, 4+length)
|
||||||
|
buf = putI32(buf, length)
|
||||||
|
buf = putI32(buf, id)
|
||||||
|
buf = putI32(buf, typ)
|
||||||
|
buf = append(buf, b...)
|
||||||
|
buf = append(buf, 0, 0)
|
||||||
|
_, err := w.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFramePacket(r io.Reader) (id, typ int32, body string, err error) {
|
||||||
|
var lenBuf [4]byte
|
||||||
|
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
length := int32(binary.LittleEndian.Uint32(lenBuf[:]))
|
||||||
|
payload := make([]byte, length)
|
||||||
|
if _, err = io.ReadFull(r, payload); err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
id = int32(binary.LittleEndian.Uint32(payload[0:4]))
|
||||||
|
typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
|
||||||
|
body = string(payload[8 : length-2])
|
||||||
|
return id, typ, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func putI32(buf []byte, v int32) []byte {
|
||||||
|
var b [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return append(buf, b[:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialAndExecute(t *testing.T) {
|
||||||
|
want := "There are 0 of a max of 20 players online:"
|
||||||
|
f := startFakeRCON(t, "s3cret", map[string]string{"list": want})
|
||||||
|
defer f.stop()
|
||||||
|
|
||||||
|
c, err := rcon.Dial(f.addr(), "s3cret", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
got, err := c.Execute("list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Execute(list) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialAuthFailure(t *testing.T) {
|
||||||
|
f := startFakeRCON(t, "correct-horse", nil)
|
||||||
|
defer f.stop()
|
||||||
|
|
||||||
|
_, err := rcon.Dial(f.addr(), "wrong-password", 2*time.Second)
|
||||||
|
if !errors.Is(err, rcon.ErrAuthFailed) {
|
||||||
|
t.Fatalf("Dial with wrong password: got %v, want ErrAuthFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialUnreachable(t *testing.T) {
|
||||||
|
// Reserved TEST-NET-1 address: connect should fail fast within the timeout.
|
||||||
|
_, err := rcon.Dial("192.0.2.1:25575", "x", 200*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Dial to unreachable host: expected error, got nil")
|
||||||
|
}
|
||||||
|
if errors.Is(err, rcon.ErrAuthFailed) {
|
||||||
|
t.Fatalf("Dial to unreachable host: got ErrAuthFailed, want a dial error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteGracefulShutdownSequence(t *testing.T) {
|
||||||
|
// Mirrors the operator preStop hook: save then stop.
|
||||||
|
f := startFakeRCON(t, "pw", map[string]string{
|
||||||
|
"save-all flush": "Saved the game",
|
||||||
|
"stop": "Stopping the server",
|
||||||
|
})
|
||||||
|
defer f.stop()
|
||||||
|
|
||||||
|
c, err := rcon.Dial(f.addr(), "pw", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if out, err := c.Execute("save-all flush"); err != nil || out != "Saved the game" {
|
||||||
|
t.Fatalf("save-all flush = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if out, err := c.Execute("stop"); err != nil || out != "Stopping the server" {
|
||||||
|
t.Fatalf("stop = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package store owns the Felis business-layer database (spec §6): the embedded
|
||||||
|
// schema migrations and the typed access layer. Migrations are applied by
|
||||||
|
// `felis migrate up` under a Postgres advisory lock so concurrent api/operator
|
||||||
|
// replicas cannot race each other.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdvisoryLockKey is the fixed pg_advisory_lock key guarding migrations. The
|
||||||
|
// value is the ASCII bytes of "felis"; any replica running migrations contends
|
||||||
|
// on the same key.
|
||||||
|
const AdvisoryLockKey int64 = 0x66656c6973 // "felis"
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Migration is a single ordered schema step loaded from the embedded FS.
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver is the database-facing seam the migration engine drives. Splitting it
|
||||||
|
// out lets the ordering/idempotency/lock logic be tested without a live
|
||||||
|
// Postgres; PostgresDriver is the production implementation.
|
||||||
|
type Driver interface {
|
||||||
|
// Lock acquires the migration advisory lock, blocking until held.
|
||||||
|
Lock(ctx context.Context) error
|
||||||
|
// Unlock releases the advisory lock.
|
||||||
|
Unlock(ctx context.Context) error
|
||||||
|
// EnsureVersionTable creates the schema_migrations bookkeeping table.
|
||||||
|
EnsureVersionTable(ctx context.Context) error
|
||||||
|
// AppliedVersions returns the set of versions already applied.
|
||||||
|
AppliedVersions(ctx context.Context) (map[int]struct{}, error)
|
||||||
|
// Apply runs one migration and records it, atomically.
|
||||||
|
Apply(ctx context.Context, m Migration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMigrations parses the embedded migrations into an ascending, gap-tolerant
|
||||||
|
// but duplicate-free list.
|
||||||
|
func LoadMigrations() ([]Migration, error) {
|
||||||
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read embedded migrations: %w", err)
|
||||||
|
}
|
||||||
|
var ms []Migration
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version, name, err := parseMigrationName(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read migration %q: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(data)) == "" {
|
||||||
|
return nil, fmt.Errorf("migration %q is empty", e.Name())
|
||||||
|
}
|
||||||
|
ms = append(ms, Migration{Version: version, Name: name, SQL: string(data)})
|
||||||
|
}
|
||||||
|
sort.Slice(ms, func(i, j int) bool { return ms[i].Version < ms[j].Version })
|
||||||
|
for i := 1; i < len(ms); i++ {
|
||||||
|
if ms[i].Version == ms[i-1].Version {
|
||||||
|
return nil, fmt.Errorf("duplicate migration version %d (%s, %s)", ms[i].Version, ms[i-1].Name, ms[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ms) == 0 {
|
||||||
|
return nil, fmt.Errorf("no migrations found")
|
||||||
|
}
|
||||||
|
return ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up applies every pending migration in ascending order, exactly once, under
|
||||||
|
// the advisory lock. It is safe to run concurrently from multiple replicas: the
|
||||||
|
// lock serializes them and AppliedVersions makes the work idempotent.
|
||||||
|
func Up(ctx context.Context, d Driver, migrations []Migration) (applied []int, err error) {
|
||||||
|
if err := d.Lock(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire migration lock: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if uerr := d.Unlock(ctx); uerr != nil && err == nil {
|
||||||
|
err = fmt.Errorf("release migration lock: %w", uerr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := d.EnsureVersionTable(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure version table: %w", err)
|
||||||
|
}
|
||||||
|
done, err := d.AppliedVersions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read applied versions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := append([]Migration(nil), migrations...)
|
||||||
|
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
|
||||||
|
for _, m := range ordered {
|
||||||
|
if _, ok := done[m.Version]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := d.Apply(ctx, m); err != nil {
|
||||||
|
return applied, fmt.Errorf("apply migration %04d_%s: %w", m.Version, m.Name, err)
|
||||||
|
}
|
||||||
|
applied = append(applied, m.Version)
|
||||||
|
}
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMigrationName turns "0001_init.sql" into (1, "init").
|
||||||
|
func parseMigrationName(filename string) (int, string, error) {
|
||||||
|
base := strings.TrimSuffix(filename, ".sql")
|
||||||
|
idx := strings.IndexByte(base, '_')
|
||||||
|
if idx <= 0 {
|
||||||
|
return 0, "", fmt.Errorf("migration %q must be named NNNN_name.sql", filename)
|
||||||
|
}
|
||||||
|
version, err := strconv.Atoi(base[:idx])
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("migration %q has a non-numeric version: %w", filename, err)
|
||||||
|
}
|
||||||
|
name := base[idx+1:]
|
||||||
|
if name == "" {
|
||||||
|
return 0, "", fmt.Errorf("migration %q is missing a name", filename)
|
||||||
|
}
|
||||||
|
return version, name, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMigrationsOrderedAndWellFormed(t *testing.T) {
|
||||||
|
ms, err := store.LoadMigrations()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMigrations: %v", err)
|
||||||
|
}
|
||||||
|
if len(ms) == 0 {
|
||||||
|
t.Fatal("expected at least one migration")
|
||||||
|
}
|
||||||
|
if ms[0].Version != 1 || ms[0].Name != "init" {
|
||||||
|
t.Errorf("first migration = %d_%s, want 0001_init", ms[0].Version, ms[0].Name)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(ms); i++ {
|
||||||
|
if ms[i].Version <= ms[i-1].Version {
|
||||||
|
t.Errorf("migrations not strictly ascending at %d: %d then %d", i, ms[i-1].Version, ms[i].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The init migration must define the core business tables (spec §6).
|
||||||
|
for _, want := range []string{"CREATE TABLE users", "CREATE TABLE servers", "CREATE TABLE world_backups"} {
|
||||||
|
if !strings.Contains(ms[0].SQL, want) {
|
||||||
|
t.Errorf("init migration missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingDriver captures the migration engine's calls without a database.
|
||||||
|
type recordingDriver struct {
|
||||||
|
already map[int]struct{}
|
||||||
|
applied []int
|
||||||
|
locked bool
|
||||||
|
unlocked bool
|
||||||
|
ensured bool
|
||||||
|
appliedWhileUnsafe bool // true if Apply ran while not locked or already unlocked
|
||||||
|
failOn int // version whose Apply should fail (0 = never)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *recordingDriver) Lock(context.Context) error { d.locked = true; return nil }
|
||||||
|
func (d *recordingDriver) Unlock(context.Context) error { d.unlocked = true; return nil }
|
||||||
|
func (d *recordingDriver) EnsureVersionTable(context.Context) error {
|
||||||
|
if !d.locked || d.unlocked {
|
||||||
|
d.appliedWhileUnsafe = true
|
||||||
|
}
|
||||||
|
d.ensured = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (d *recordingDriver) AppliedVersions(context.Context) (map[int]struct{}, error) {
|
||||||
|
if d.already == nil {
|
||||||
|
return map[int]struct{}{}, nil
|
||||||
|
}
|
||||||
|
return d.already, nil
|
||||||
|
}
|
||||||
|
func (d *recordingDriver) Apply(_ context.Context, m store.Migration) error {
|
||||||
|
if !d.locked || d.unlocked {
|
||||||
|
d.appliedWhileUnsafe = true
|
||||||
|
}
|
||||||
|
if d.failOn != 0 && m.Version == d.failOn {
|
||||||
|
return errors.New("boom")
|
||||||
|
}
|
||||||
|
d.applied = append(d.applied, m.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpAppliesAllPendingInOrder(t *testing.T) {
|
||||||
|
d := &recordingDriver{}
|
||||||
|
ms := []store.Migration{
|
||||||
|
{Version: 3, Name: "c", SQL: "x"},
|
||||||
|
{Version: 1, Name: "a", SQL: "y"},
|
||||||
|
{Version: 2, Name: "b", SQL: "z"},
|
||||||
|
}
|
||||||
|
applied, err := store.Up(context.Background(), d, ms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Up: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.Trim(strings.Join(intsToStrings(applied), ","), ""); got != "1,2,3" {
|
||||||
|
t.Errorf("applied = %v, want [1 2 3]", applied)
|
||||||
|
}
|
||||||
|
if !d.locked || !d.unlocked || !d.ensured {
|
||||||
|
t.Errorf("lifecycle flags: locked=%v unlocked=%v ensured=%v", d.locked, d.unlocked, d.ensured)
|
||||||
|
}
|
||||||
|
if d.appliedWhileUnsafe {
|
||||||
|
t.Error("work ran outside the advisory lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpSkipsAlreadyApplied(t *testing.T) {
|
||||||
|
d := &recordingDriver{already: map[int]struct{}{1: {}}}
|
||||||
|
ms := []store.Migration{
|
||||||
|
{Version: 1, Name: "a", SQL: "y"},
|
||||||
|
{Version: 2, Name: "b", SQL: "z"},
|
||||||
|
}
|
||||||
|
applied, err := store.Up(context.Background(), d, ms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Up: %v", err)
|
||||||
|
}
|
||||||
|
if len(applied) != 1 || applied[0] != 2 {
|
||||||
|
t.Errorf("applied = %v, want [2]", applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpStopsOnErrorButStillUnlocks(t *testing.T) {
|
||||||
|
d := &recordingDriver{failOn: 2}
|
||||||
|
ms := []store.Migration{
|
||||||
|
{Version: 1, Name: "a", SQL: "y"},
|
||||||
|
{Version: 2, Name: "b", SQL: "z"},
|
||||||
|
{Version: 3, Name: "c", SQL: "x"},
|
||||||
|
}
|
||||||
|
applied, err := store.Up(context.Background(), d, ms)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when a migration fails")
|
||||||
|
}
|
||||||
|
if len(applied) != 1 || applied[0] != 1 {
|
||||||
|
t.Errorf("applied = %v, want only [1] before the failure", applied)
|
||||||
|
}
|
||||||
|
if !d.unlocked {
|
||||||
|
t.Error("advisory lock must be released even when a migration fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intsToStrings(in []int) []string {
|
||||||
|
out := make([]string, len(in))
|
||||||
|
for i, v := range in {
|
||||||
|
out[i] = itoa(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := v < 0
|
||||||
|
if neg {
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Felis business-layer schema (spec §6). The CRD is the lifecycle
|
||||||
|
-- source-of-truth; this database owns only what the CRD cannot express:
|
||||||
|
-- ownership/claim, account links, quotas, image admission, builds, backups,
|
||||||
|
-- audit. Authoritative CRD fields are never duplicated here.
|
||||||
|
|
||||||
|
CREATE TYPE user_role AS ENUM ('admin','user');
|
||||||
|
CREATE TYPE build_status AS ENUM ('pending','building','succeeded','failed','cancelled');
|
||||||
|
CREATE TYPE backup_status AS ENUM ('present','expired','deleted');
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id text PRIMARY KEY, username text UNIQUE NOT NULL, email text,
|
||||||
|
role user_role NOT NULL DEFAULT 'user', created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity bridge: web identity <-> MC UUID (claim/owner-only/allowlist rely on it).
|
||||||
|
CREATE TABLE account_links (
|
||||||
|
user_id text NOT NULL REFERENCES users(id), mc_uuid uuid NOT NULL,
|
||||||
|
verified_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, mc_uuid), UNIQUE (mc_uuid)
|
||||||
|
);
|
||||||
|
CREATE TABLE account_link_codes ( code text PRIMARY KEY, mc_uuid uuid NOT NULL, expires_at timestamptz NOT NULL );
|
||||||
|
|
||||||
|
CREATE TABLE quotas (
|
||||||
|
user_id text PRIMARY KEY REFERENCES users(id),
|
||||||
|
max_servers int, max_cpu_milli int, max_memory_mb int, max_storage_gb int
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Business projection: the CRD lives in K8s; this stores only the
|
||||||
|
-- ownership/activity/warning that the CRD cannot express, plus a fast-query cache.
|
||||||
|
CREATE TABLE servers (
|
||||||
|
name text PRIMARY KEY, -- matches CRD metadata.name
|
||||||
|
owner_id text REFERENCES users(id), -- NULL until claimed; reaper resets to NULL
|
||||||
|
claimed_at timestamptz,
|
||||||
|
last_active_at timestamptz NOT NULL DEFAULT now(), -- max(last human join, created_at)
|
||||||
|
warned_3d_at timestamptz, warned_1d_at timestamptz, -- reaper warning dedup; cleared on renewal
|
||||||
|
cached_phase text, -- CRD status projection, non-authoritative
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE TABLE server_aliases ( subdomain text PRIMARY KEY, server_name text NOT NULL REFERENCES servers(name) );
|
||||||
|
CREATE TABLE server_allowlist ( -- autostartPolicy=allowlist; first join auto-appends
|
||||||
|
server_name text NOT NULL REFERENCES servers(name), mc_uuid uuid NOT NULL,
|
||||||
|
added_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (server_name, mc_uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Image admission (dynamic, auditable -> DB, not toml).
|
||||||
|
CREATE TABLE image_whitelist (
|
||||||
|
image_ref text PRIMARY KEY, -- registry/foo:1.0 or registry/foo:*
|
||||||
|
source text NOT NULL DEFAULT 'built', -- built (cluster build) | external (pushed)
|
||||||
|
build_id text, added_by text NOT NULL, enabled boolean NOT NULL DEFAULT true,
|
||||||
|
added_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE image_builds (
|
||||||
|
id text PRIMARY KEY, image_ref text NOT NULL, status build_status NOT NULL DEFAULT 'pending',
|
||||||
|
dockerfile text NOT NULL, -- archived for audit
|
||||||
|
context_ref text, base_image text, -- resolved FROM, audit
|
||||||
|
requested_by text NOT NULL, job_name text, log_ref text, error text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- World backups (reaper output; not FK'd to servers, which may be reset/deleted).
|
||||||
|
CREATE TABLE world_backups (
|
||||||
|
id text PRIMARY KEY, server_name text NOT NULL, former_owner text,
|
||||||
|
backup_ref text NOT NULL, -- WorldArchiver location (ArchiveRef)
|
||||||
|
size_bytes bigint, reason text NOT NULL, -- inactive_15d | manual
|
||||||
|
status backup_status NOT NULL DEFAULT 'present',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz NOT NULL, -- created_at + 3mo
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id bigserial PRIMARY KEY, actor text NOT NULL, source text NOT NULL, action text NOT NULL,
|
||||||
|
server_name text, request_id text, payload jsonb, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE tokens ( id text PRIMARY KEY, name text NOT NULL, token_hash text NOT NULL, scope jsonb NOT NULL, expires_at timestamptz );
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- User-submitted modpack approval lane (a user-directed extension over the §16
|
||||||
|
-- build subsystem; see internal/submit for provenance). This is the UNTRUSTED-
|
||||||
|
-- origin counterpart to the admin build path (POST /images/build): an ordinary
|
||||||
|
-- user may upload a modpack but cannot start a build directly. Each upload lands
|
||||||
|
-- here as pending_review; an admin must approve it before anything is built, and
|
||||||
|
-- the approved submission then routes through the SAME Trivy-gated Kaniko build
|
||||||
|
-- as an admin build (build subsystem §16). Approval is a human gate layered in
|
||||||
|
-- FRONT of the automatic scan, never instead of it — a CRITICAL CVE still fails
|
||||||
|
-- the build and nothing is admitted even after a human approved.
|
||||||
|
--
|
||||||
|
-- Source of truth (spec §1): this row is the Postgres BUSINESS authority for the
|
||||||
|
-- approval (verdict + reviewer); the build EXECUTION lives in image_builds,
|
||||||
|
-- linked by build_id once Builder.Submit succeeds. The approval never copies the
|
||||||
|
-- build's authoritative fields.
|
||||||
|
--
|
||||||
|
-- Trust note: the platform derives BOTH the push target (image_ref) and the
|
||||||
|
-- build context (context_ref) from the submission id — neither is free-form user
|
||||||
|
-- input — so an untrusted submitter can never point the build at an arbitrary
|
||||||
|
-- source or collide with the platform image namespace. There is deliberately no
|
||||||
|
-- `origin` column: image_submissions is ONLY the user-upload lane (the platform
|
||||||
|
-- uses the direct build path), and submitted_by already records the origin.
|
||||||
|
|
||||||
|
CREATE TYPE submission_status AS ENUM ('pending_review','approved','rejected');
|
||||||
|
|
||||||
|
CREATE TABLE image_submissions (
|
||||||
|
id text PRIMARY KEY, -- lowercase, namespaces the derived image/context refs
|
||||||
|
submitted_by text NOT NULL, -- uploading user's id (untrusted origin)
|
||||||
|
display_name text NOT NULL, -- human-friendly label for the modpack
|
||||||
|
context_ref text NOT NULL, -- DERIVED pinned build context (not user-supplied)
|
||||||
|
status submission_status NOT NULL DEFAULT 'pending_review',
|
||||||
|
image_ref text, -- DERIVED {registry}/user-uploads/{id}:latest, set at approve
|
||||||
|
build_id text, -- image_builds.id, set only after Builder.Submit succeeds
|
||||||
|
reviewed_by text, -- admin who approved/rejected
|
||||||
|
reject_reason text, -- set on rejection
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
reviewed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The admin review queue scans by status (pending first); the per-user index
|
||||||
|
-- serves the "my submissions" list.
|
||||||
|
CREATE INDEX image_submissions_status_idx ON image_submissions (status, created_at);
|
||||||
|
CREATE INDEX image_submissions_submitted_by_idx ON image_submissions (submitted_by, created_at DESC);
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostgresDriver is the production Driver, backed by a database/sql pool using
|
||||||
|
// the pgx stdlib driver.
|
||||||
|
type PostgresDriver struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open dials dsn and returns a PostgresDriver. The caller owns Close.
|
||||||
|
func Open(ctx context.Context, dsn string) (*PostgresDriver, error) {
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open postgres: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||||
|
}
|
||||||
|
return &PostgresDriver{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB exposes the underlying pool for the access layer.
|
||||||
|
func (d *PostgresDriver) DB() *sql.DB { return d.db }
|
||||||
|
|
||||||
|
// Close releases the pool.
|
||||||
|
func (d *PostgresDriver) Close() error { return d.db.Close() }
|
||||||
|
|
||||||
|
// Lock takes the session-level advisory lock that serializes migrations.
|
||||||
|
func (d *PostgresDriver) Lock(ctx context.Context) error {
|
||||||
|
_, err := d.db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", AdvisoryLockKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock releases the advisory lock.
|
||||||
|
func (d *PostgresDriver) Unlock(ctx context.Context) error {
|
||||||
|
_, err := d.db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", AdvisoryLockKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureVersionTable creates the bookkeeping table if absent.
|
||||||
|
func (d *PostgresDriver) EnsureVersionTable(ctx context.Context) error {
|
||||||
|
const ddl = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version int PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`
|
||||||
|
_, err := d.db.ExecContext(ctx, ddl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppliedVersions reads the set of recorded versions.
|
||||||
|
func (d *PostgresDriver) AppliedVersions(ctx context.Context) (map[int]struct{}, error) {
|
||||||
|
rows, err := d.db.QueryContext(ctx, "SELECT version FROM schema_migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[int]struct{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[v] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply runs the migration body and records it in one transaction, so a failure
|
||||||
|
// never leaves a half-applied version marked as done.
|
||||||
|
func (d *PostgresDriver) Apply(ctx context.Context, m Migration) error {
|
||||||
|
tx, err := d.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // rollback after a successful commit is a no-op
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
|
||||||
|
return fmt.Errorf("exec body: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_migrations (version, name) VALUES ($1, $2)", m.Version, m.Name); err != nil {
|
||||||
|
return fmt.Errorf("record version: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user