feat(build): make the user-modpack build lane read its context (closes the last functional gap)
A submitted modpack was durable but unreadable: the uploads PVC cannot cross
namespaces (felis-api mounts it; Kaniko runs in felis-build) and the s3 lane
handed the sandboxed build Pod no credentials, so NO user build could ever
consume its context. The transport is now the API itself:
- submit: derived context refs become the internal-face URL
/api/v1/internal/submissions/{id}/context (service-token gated), and Blobs
gains Open (local + s3) with an ErrBlobNotFound sentinel for the route's 404.
- api: serves that route on the internal face only (openapi.yaml updated; the
route-coverage test enforces it).
- build: an http(s) context renders a context-fetch initContainer (the felis
image's new fetch-context entrypoint) that streams the blob with the
namespace-local service-token Secret — never mounted into Kaniko — and
extracts it under a zip-slip guard into a size-limited emptyDir that Kaniko
reads read-only as --context=/context.
- platform/install: the api Deployment carries its own internal base URL; the
build namespace gets the token Secret through the existing replica mechanism
(bootstrap.sh + felis setup); the build egress lock opens exactly the control
namespace on the internal port.
- cmd/felis: fetch-context entrypoint (registered, documented, unit-tested for
escapes/symlinks/non-gzip).
Tests cover rendering, hardening, the s3/local Open paths, and the route's
404/503 mapping. Verified next on the real single-node cluster with Kaniko.
This commit is contained in:
26 files changed
+1080
-72
No files matched your search
@@ -215,6 +215,11 @@ type Config struct {
|
||||
// RegistryURL is the internal registry the build pushes to and Trivy scans
|
||||
// (spec §17). Image refs are validated to be under it.
|
||||
RegistryURL string
|
||||
// FelisImage is the platform image whose `fetch-context` entrypoint streams a
|
||||
// submission's context from the internal face into the build Pod. Required
|
||||
// only when a build's ContextRef is an http(s) URL (the submit lane's derived
|
||||
// shape); an install that never builds user submissions can leave it empty.
|
||||
FelisImage string
|
||||
// KanikoImage / TrivyImage are the executor images.
|
||||
KanikoImage string
|
||||
TrivyImage string
|
||||
@@ -355,6 +360,7 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams {
|
||||
Namespace: cfg.Namespace,
|
||||
ServiceAccount: cfg.ServiceAccount,
|
||||
RegistryURL: cfg.RegistryURL,
|
||||
FelisImage: cfg.FelisImage,
|
||||
KanikoImage: cfg.KanikoImage,
|
||||
TrivyImage: cfg.TrivyImage,
|
||||
Deadline: cfg.Deadline,
|
||||
|
||||
+127
-7
@@ -2,8 +2,10 @@ package build
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/naming"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
@@ -32,8 +34,23 @@ const (
|
||||
const (
|
||||
ContainerKaniko = "kaniko"
|
||||
ContainerTrivy = "trivy"
|
||||
// ContainerFetch is the initContainer that pulls a submission's build context
|
||||
// from the felis-api internal face and extracts it into the shared emptyDir.
|
||||
// It exists only for an http(s) ContextRef (see BuildJob); a ref Kaniko can
|
||||
// read natively (s3://) or a pre-mounted path renders no such container.
|
||||
ContainerFetch = "context-fetch"
|
||||
|
||||
// contextVolume/contextMountPath carry a fetched build context: the fetch
|
||||
// initContainer writes the extracted tree there, Kaniko reads it read-only.
|
||||
contextVolume = "context"
|
||||
contextMountPath = "/context"
|
||||
)
|
||||
|
||||
// contextSizeLimit bounds the extracted (attacker-controlled) context tree so a
|
||||
// tarball bomb wedges the build pod instead of the node's disk. The compressed
|
||||
// upload is capped at 1 GiB by the submit lane; 4 GiB leaves expansion room.
|
||||
var contextSizeLimit = resource.MustParse("4Gi")
|
||||
|
||||
// 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.
|
||||
@@ -44,11 +61,14 @@ type JobParams struct {
|
||||
Namespace string
|
||||
ServiceAccount string
|
||||
RegistryURL string
|
||||
KanikoImage string
|
||||
TrivyImage string
|
||||
Deadline time.Duration
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
// FelisImage runs the context-fetch initContainer (the felis binary's
|
||||
// fetch-context entrypoint). Required when ContextRef is an http(s) URL.
|
||||
FelisImage string
|
||||
KanikoImage string
|
||||
TrivyImage string
|
||||
Deadline time.Duration
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
}
|
||||
|
||||
// BuildJobName is the deterministic Job name for a build id.
|
||||
@@ -100,21 +120,75 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||
}
|
||||
|
||||
// The context Kaniko reads. An http(s) ref (the submit lane's derived ref: the
|
||||
// API streams the blob on its internal face, because the build Pod can neither
|
||||
// mount the control-plane uploads PVC across namespaces nor hold object-store
|
||||
// credentials) is first fetched into a shared emptyDir; a ref Kaniko can read
|
||||
// in place (s3://, or a path an installer pre-mounted) passes through untouched.
|
||||
contextPath := p.ContextRef
|
||||
initContainers := []corev1.Container{}
|
||||
var kanikoMounts []corev1.VolumeMount
|
||||
var podVolumes []corev1.Volume
|
||||
if isHTTPContextRef(p.ContextRef) {
|
||||
if p.FelisImage == "" {
|
||||
return nil, fmt.Errorf("build: context ref %q needs FelisImage for the fetch initContainer", p.ContextRef)
|
||||
}
|
||||
contextPath = contextMountPath
|
||||
fetch := corev1.Container{
|
||||
Name: ContainerFetch,
|
||||
Image: p.FelisImage,
|
||||
Args: []string{
|
||||
"fetch-context",
|
||||
"--url=" + p.ContextRef,
|
||||
"--out=" + contextMountPath,
|
||||
},
|
||||
// The internal face is service-token gated, and the token is read from a
|
||||
// Secret the installer materializes in THIS namespace (secretKeyRef is
|
||||
// namespace-local). It is mounted into this initContainer only: the Kaniko
|
||||
// container executes the untrusted Dockerfile and must never hold it, and
|
||||
// pod containers share neither environment nor PID namespace.
|
||||
Env: []corev1.EnvVar{{
|
||||
Name: "FELIS_SERVICE_TOKEN",
|
||||
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
|
||||
Key: naming.ServiceTokenSecretKey,
|
||||
}},
|
||||
}},
|
||||
VolumeMounts: []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath}},
|
||||
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
||||
SecurityContext: sec,
|
||||
}
|
||||
initContainers = append(initContainers, fetch)
|
||||
kanikoMounts = []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath, ReadOnly: true}}
|
||||
podVolumes = []corev1.Volume{{
|
||||
Name: contextVolume,
|
||||
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{
|
||||
// The extracted tree is attacker-controlled; bound it so a tarball
|
||||
// bomb wedges THIS pod (admitted failure) instead of filling the
|
||||
// node's disk. The compressed upload is capped at 1 GiB by the
|
||||
// submit lane, and 4 GiB leaves room for a typical expansion.
|
||||
SizeLimit: sizeLimitPtr(),
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
kaniko := corev1.Container{
|
||||
Name: ContainerKaniko,
|
||||
Image: p.KanikoImage,
|
||||
Args: []string{
|
||||
"--dockerfile=Dockerfile",
|
||||
"--context=" + p.ContextRef,
|
||||
"--context=" + contextPath,
|
||||
"--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",
|
||||
},
|
||||
VolumeMounts: kanikoMounts,
|
||||
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
||||
SecurityContext: sec,
|
||||
}
|
||||
initContainers = append(initContainers, kaniko)
|
||||
|
||||
trivy := corev1.Container{
|
||||
Name: ContainerTrivy,
|
||||
@@ -147,8 +221,9 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: p.ServiceAccount,
|
||||
AutomountServiceAccountToken: boolPtr(false),
|
||||
InitContainers: []corev1.Container{kaniko},
|
||||
InitContainers: initContainers,
|
||||
Containers: []corev1.Container{trivy},
|
||||
Volumes: podVolumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -156,11 +231,24 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// isHTTPContextRef reports whether ref is an http(s) URL — the shape the submit
|
||||
// lane derives when the API is the blob transport — i.e. a context only the
|
||||
// fetch initContainer can turn into a local path for Kaniko.
|
||||
func isHTTPContextRef(ref string) bool {
|
||||
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://")
|
||||
}
|
||||
|
||||
// NetPolParams parameterises the build-namespace egress lock.
|
||||
type NetPolParams struct {
|
||||
Namespace string
|
||||
RegistryNamespace string
|
||||
RegistryPort int32
|
||||
// ControlNamespace and APIPort are where the felis-api internal face lives:
|
||||
// the fetch initContainer's only egress besides DNS and the registry. Both
|
||||
// defaults (felis, 8081) match platform.DefaultControlNamespace and the
|
||||
// internal listener, so an unset Params is still the safe shape.
|
||||
ControlNamespace string
|
||||
APIPort 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 (默认拒外网).
|
||||
@@ -177,10 +265,19 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
|
||||
if port == 0 {
|
||||
port = 5000
|
||||
}
|
||||
controlNS := p.ControlNamespace
|
||||
if controlNS == "" {
|
||||
controlNS = "felis"
|
||||
}
|
||||
apiPort := p.APIPort
|
||||
if apiPort == 0 {
|
||||
apiPort = 8081
|
||||
}
|
||||
dnsUDP := corev1.ProtocolUDP
|
||||
dnsTCP := corev1.ProtocolTCP
|
||||
dns53 := intstr.FromInt32(53)
|
||||
regPort := intstr.FromInt32(port)
|
||||
ctxPort := intstr.FromInt32(apiPort)
|
||||
|
||||
egress := []networkingv1.NetworkPolicyEgressRule{
|
||||
// DNS resolution: port-restricted to 53, so this is not an open-internet
|
||||
@@ -203,6 +300,21 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
|
||||
{Protocol: &dnsTCP, Port: ®Port},
|
||||
},
|
||||
},
|
||||
// felis-api's internal face, where the fetch initContainer streams the
|
||||
// submission's build context from. Without this rule the build Pod could
|
||||
// not read the context and every user build would fail in its first init
|
||||
// step — the default-deny here is exactly why the transport had to be
|
||||
// planned, not assumed.
|
||||
{
|
||||
To: []networkingv1.NetworkPolicyPeer{{
|
||||
NamespaceSelector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"kubernetes.io/metadata.name": controlNS},
|
||||
},
|
||||
}},
|
||||
Ports: []networkingv1.NetworkPolicyPort{
|
||||
{Protocol: &dnsTCP, Port: &ctxPort},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Explicit package-mirror CIDRs, when configured. No CIDR ⇒ no internet.
|
||||
for _, cidr := range p.PackageSourceCIDRs {
|
||||
@@ -281,4 +393,12 @@ func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
|
||||
// sizeLimitPtr returns a copy of contextSizeLimit for a VolumeSource (the API
|
||||
// object only ever gets serialized, but a shared pointer across rendered Jobs
|
||||
// invites accidental aliasing).
|
||||
func sizeLimitPtr() *resource.Quantity {
|
||||
q := contextSizeLimit
|
||||
return &q
|
||||
}
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
@@ -177,6 +177,132 @@ func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) {
|
||||
if !egressAllowsPort(np, 53) {
|
||||
t.Error("egress must allow DNS (port 53)")
|
||||
}
|
||||
// The context fetch: build Pods stream submissions from the control
|
||||
// namespace's internal face (defaults: felis + 8081).
|
||||
if !egressAllowsNamespace(np, "felis") {
|
||||
t.Error("egress must allow the control namespace (context fetch)")
|
||||
}
|
||||
if !egressAllowsPort(np, 8081) {
|
||||
t.Error("egress must allow the internal face's port (8081)")
|
||||
}
|
||||
}
|
||||
|
||||
// An http(s) context ref — the submit lane's derived shape — must render the
|
||||
// fetch initContainer ahead of Kaniko, with the service token mounted ONLY into
|
||||
// that container, and hand Kaniko the extracted local directory.
|
||||
func TestBuildJobFetchesHTTPContext(t *testing.T) {
|
||||
p := sampleJobParams()
|
||||
p.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-abc/context"
|
||||
p.FelisImage = "felis:test"
|
||||
job, err := BuildJob(p)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildJob: %v", err)
|
||||
}
|
||||
inits := job.Spec.Template.Spec.InitContainers
|
||||
if len(inits) != 2 || inits[0].Name != ContainerFetch || inits[1].Name != ContainerKaniko {
|
||||
t.Fatalf("initContainers = %v, want [%s %s]", initNames(inits), ContainerFetch, ContainerKaniko)
|
||||
}
|
||||
fetch, kaniko := inits[0], inits[1]
|
||||
if fetch.Image != p.FelisImage {
|
||||
t.Errorf("fetch image = %q, want the platform image %q", fetch.Image, p.FelisImage)
|
||||
}
|
||||
if !hasArg(fetch.Args, "fetch-context") || !hasArg(fetch.Args, "--url="+p.ContextRef) ||
|
||||
!hasArg(fetch.Args, "--out="+contextMountPath) {
|
||||
t.Errorf("fetch args = %v, want fetch-context --url=%s --out=%s", fetch.Args, p.ContextRef, contextMountPath)
|
||||
}
|
||||
// The token comes from the namespace-local Secret and is mounted into the
|
||||
// fetch container only — never into Kaniko, which executes the untrusted
|
||||
// Dockerfile.
|
||||
var fetchToken *corev1.EnvVar
|
||||
for i := range fetch.Env {
|
||||
if fetch.Env[i].Name == "FELIS_SERVICE_TOKEN" {
|
||||
fetchToken = &fetch.Env[i]
|
||||
}
|
||||
}
|
||||
if fetchToken == nil || fetchToken.ValueFrom == nil || fetchToken.ValueFrom.SecretKeyRef == nil {
|
||||
t.Fatalf("fetch container must read FELIS_SERVICE_TOKEN from a secretKeyRef, got %#v", fetchToken)
|
||||
}
|
||||
if fetchToken.Value != "" {
|
||||
t.Error("fetch container must not carry a literal token")
|
||||
}
|
||||
if len(kaniko.Env) != 0 {
|
||||
t.Errorf("kaniko must carry no env (especially no token), got %v", kaniko.Env)
|
||||
}
|
||||
if !hasArg(kaniko.Args, "--context="+contextMountPath) {
|
||||
t.Errorf("kaniko context = %v, want the fetched local dir %s", kaniko.Args, contextMountPath)
|
||||
}
|
||||
// The shared emptyDir must exist, be bounded, and be read-only to Kaniko.
|
||||
var ctxVol *corev1.Volume
|
||||
for i := range job.Spec.Template.Spec.Volumes {
|
||||
if job.Spec.Template.Spec.Volumes[i].Name == contextVolume {
|
||||
ctxVol = &job.Spec.Template.Spec.Volumes[i]
|
||||
}
|
||||
}
|
||||
if ctxVol == nil || ctxVol.EmptyDir == nil || ctxVol.EmptyDir.SizeLimit == nil {
|
||||
t.Fatalf("context volume must be a size-limited emptyDir, got %#v", ctxVol)
|
||||
}
|
||||
mountedRO := false
|
||||
for _, m := range kaniko.VolumeMounts {
|
||||
if m.Name == contextVolume && m.MountPath == contextMountPath && m.ReadOnly {
|
||||
mountedRO = true
|
||||
}
|
||||
}
|
||||
if !mountedRO {
|
||||
t.Errorf("kaniko must mount the context read-only at %s, got %v", contextMountPath, kaniko.VolumeMounts)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the platform image the fetch initContainer cannot run, so rendering an
|
||||
// http(s) context must fail loudly at Job-creation time, not with an ImagePull
|
||||
// error at 3am.
|
||||
func TestBuildJobHTTPContextNeedsFelisImage(t *testing.T) {
|
||||
p := sampleJobParams()
|
||||
p.ContextRef = "https://example.invalid/sub-abc/context"
|
||||
if _, err := BuildJob(p); err == nil {
|
||||
t.Fatal("http(s) context without FelisImage must fail to render")
|
||||
}
|
||||
}
|
||||
|
||||
// A ref Kaniko reads natively (or an installer pre-mounted) must NOT grow the
|
||||
// fetch initContainer: the transport is for http(s) only.
|
||||
func TestBuildJobNativeContextNeedsNoFetch(t *testing.T) {
|
||||
p := sampleJobParams()
|
||||
p.ContextRef = "s3://bucket/prefix/context.tar.gz"
|
||||
job, err := BuildJob(p)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildJob: %v", err)
|
||||
}
|
||||
if len(job.Spec.Template.Spec.InitContainers) != 1 || job.Spec.Template.Spec.InitContainers[0].Name != ContainerKaniko {
|
||||
t.Errorf("a native ref must render just kaniko, got %v", initNames(job.Spec.Template.Spec.InitContainers))
|
||||
}
|
||||
if len(job.Spec.Template.Spec.Volumes) != 0 {
|
||||
t.Errorf("a native ref must render no context volume, got %v", job.Spec.Template.Spec.Volumes)
|
||||
}
|
||||
}
|
||||
|
||||
func initNames(cs []corev1.Container) []string {
|
||||
names := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// An explicit control namespace/port override must reach the egress rule (a
|
||||
// renamed control namespace otherwise silently blocks every context fetch).
|
||||
func TestBuildNetworkPolicyHonoursControlNamespaceOverride(t *testing.T) {
|
||||
np := BuildNetworkPolicy(NetPolParams{
|
||||
Namespace: "felis-build",
|
||||
RegistryNamespace: "felis-system",
|
||||
ControlNamespace: "control-plane",
|
||||
APIPort: 9081,
|
||||
})
|
||||
if !egressAllowsNamespace(np, "control-plane") {
|
||||
t.Error("egress must allow the overridden control namespace")
|
||||
}
|
||||
if !egressAllowsPort(np, 9081) {
|
||||
t.Error("egress must allow the overridden api port")
|
||||
}
|
||||
}
|
||||
|
||||
// With no package-source CIDRs configured, there must be zero IPBlock egress —
|
||||
|
||||
Reference in new issue
Block a user