Files
Felis/internal/build/build_test.go
T

594 lines
18 KiB
Go

package build
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/metrics"
"github.com/prometheus/client_golang/prometheus/testutil"
)
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")
}
}
// The platform's own images and the scanner's DB mirrors live under felis/ and
// mirror/; the registry gate refuses the build principal there, and Validate turns
// that into a 400 before a Job spends minutes building an image it cannot push.
func TestValidateRejectsReservedRepos(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000"}
for _, ref := range []string{
"registry.felis.svc:5000/felis/felis:v0.1.0",
"registry.felis.svc:5000/felis:latest",
"registry.felis.svc:5000/felis",
"registry.felis.svc:5000/mirror/trivy-db:2",
} {
req := goodRequest()
req.ImageRef = ref
if err := Validate(req, cfg); !errors.Is(err, ErrInvalid) {
t.Errorf("Validate(%q) = %v, want ErrInvalid", ref, err)
}
}
for _, ref := range []string{
"registry.felis.svc:5000/user-uploads/s1:latest",
"registry.felis.svc:5000/felis-pack:1",
"registry.felis.svc:5000/builds/felis:1",
} {
req := goodRequest()
req.ImageRef = ref
if err := Validate(req, cfg); err != nil {
t.Errorf("Validate(%q) = %v, want accepted", ref, err)
}
}
}
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)
}
}
// TestImageBuildFailuresMetricCountsFailedBuilds asserts felis_image_build_failures_total
// (spec §23) advances exactly once per failed build from BOTH terminal-failure
// producers, and never on a successful build. There are two distinct Inc sites —
// finishAt (the Sync verdict) and Submit's job-creation bypass — so each is
// exercised separately; the success case is the negative control proving the
// StatusFailed guard discriminates rather than firing on every terminal write.
// Deltas are read around each action because the counter is a process-global
// singleton these package tests share (they run sequentially).
func TestImageBuildFailuresMetricCountsFailedBuilds(t *testing.T) {
read := func() float64 { return testutil.ToFloat64(metrics.ImageBuildFailuresTotal) }
t.Run("sync job-failed verdict increments via finishAt", func(t *testing.T) {
b, _, jb := newBuilder()
bld, _ := b.Submit(context.Background(), goodRequest())
before := read()
jb.phase = JobFailed
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
t.Fatalf("Sync: %v", err)
}
if got := read() - before; got != 1 {
t.Errorf("failures delta = %v, want 1", got)
}
})
t.Run("submit job-create failure increments via bypass", func(t *testing.T) {
b, _, jb := newBuilder()
jb.createErr = errors.New("apiserver down")
before := read()
if _, err := b.Submit(context.Background(), goodRequest()); err == nil {
t.Fatal("expected Submit error when job creation fails")
}
if got := read() - before; got != 1 {
t.Errorf("failures delta = %v, want 1", got)
}
})
t.Run("successful build does not increment", func(t *testing.T) {
b, _, jb := newBuilder()
bld, _ := b.Submit(context.Background(), goodRequest())
before := read()
jb.phase = JobSucceeded
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
t.Fatalf("Sync: %v", err)
}
if got := read() - before; got != 0 {
t.Errorf("failures delta on success = %v, want 0", got)
}
})
}
// ---- 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)
}
}
}
// A 'recommended' row (0018) is curation, not capability: it must be admitted by
// exactly the rule that governs every other source, and it must not become a way
// to bypass the disable switch. Both halves are pinned here because the failure
// modes are silent and opposite — make admission source-aware in one direction
// and the curated images quietly vanish from the create-server form; in the
// other, a disabled recommendation stays creatable after an admin pulled it.
func TestRecommendedImageAdmittedLikeAnyOtherSource(t *testing.T) {
b, st, _ := newBuilder()
st.images["felis-lobby:demo"] = Image{
ImageRef: "felis-lobby:demo", Source: SourceRecommended, Enabled: true,
}
st.images["felis-lobby:pulled"] = Image{
ImageRef: "felis-lobby:pulled", Source: SourceRecommended, Enabled: false,
}
admitted, err := b.ImageAdmitted(context.Background(), "felis-lobby:demo")
if err != nil {
t.Fatalf("ImageAdmitted: %v", err)
}
if !admitted {
t.Error("an enabled recommended image must be admitted; curation must not cost admission")
}
admitted, err = b.ImageAdmitted(context.Background(), "felis-lobby:pulled")
if err != nil {
t.Fatalf("ImageAdmitted: %v", err)
}
if admitted {
t.Error("a disabled recommended image must not be admitted; curation is not a disable bypass")
}
}