Files
Felis/internal/build/jobspec_test.go
T
flyemoji 708cdfc5b8 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.
2026-06-26 23:31:58 +09:00

307 lines
10 KiB
Go

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")
}
}