Unverified Commit 708cdfc5 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 7fbebfe8
Loading
Loading
Loading
Loading
+505 −0
Changes for internal/build/build.go: 505 added lines, 0 removed lines.
Original line number Diff line number Diff line
// 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
}
+478 −0

File added.

Preview size limit exceeded, changes collapsed.

+284 −0

File added.

Preview size limit exceeded, changes collapsed.

+306 −0

File added.

Preview size limit exceeded, changes collapsed.

+87 −0
Changes for internal/build/k8sjobs.go: 87 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
}
Loading