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:
Lemon-miaow committed 2026-09-22 22:45:09 +08:00
1 parent 0c8e29b05a
commit f79e5ebb5e
26 files changed
+1067 -59

No files matched your search

+31 -9
View File
@@ -18,6 +18,7 @@ import (
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
"felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/passkey"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
@@ -142,10 +143,14 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// build namespace and pushes to the internal registry. The build Pod never // build namespace and pushes to the internal registry. The build Pod never
// holds DB credentials — felis-api owns the PG store and admits scanned // holds DB credentials — felis-api owns the PG store and admits scanned
// images, so the Builder is constructed here with both bindings. // images, so the Builder is constructed here with both bindings.
buildCfg := buildConfig(cfg)
// The fetch initContainer runs THIS image's fetch-context entrypoint, so the
// build config carries the api's own image (the platform sets FELIS_IMAGE).
buildCfg.FelisImage = os.Getenv("FELIS_IMAGE")
builder := &build.Builder{ builder := &build.Builder{
Store: build.NewPGStore(drv.DB()), Store: build.NewPGStore(drv.DB()),
Jobs: build.NewK8sJobs(cl, buildConfig(cfg)), Jobs: build.NewK8sJobs(cl, buildCfg),
Config: buildConfig(cfg), Config: buildCfg,
} }
// User-modpack approval lane (user-directed extension over §16; see // User-modpack approval lane (user-directed extension over §16; see
@@ -159,14 +164,19 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// The blob upload transport is selected by the shape of user_uploads_context — // The blob upload transport is selected by the shape of user_uploads_context —
// the two backends the setup wizard chooses between. A local path wires // the two backends the setup wizard chooses between. A local path wires
// LocalContextStore (the mounted uploads PVC); an s3:// base wires // LocalContextStore (the mounted uploads PVC); an s3:// base wires
// S3ContextStore when its credentials resolve. Either way the store's target is // S3ContextStore when its credentials resolve. Anything else — or an s3:// base
// derived from the SAME config field the context ref uses, so the blob lands // with no credentials configured — leaves Blobs nil so POST
// exactly where Kaniko's --context points. Anything else — or an s3:// base with
// no credentials configured — leaves Blobs nil so POST
// /me/submissions/{id}/context returns 503, honest like the restore executor // /me/submissions/{id}/context returns 503, honest like the restore executor
// when its PVC is not supplied. (Letting the sandboxed Kaniko build Pod READ the // when its PVC is not supplied.
// context — PVC mount for local, creds+egress for S3 — is a separate deployment //
// integration.) // Reading the blob back is the API's job, not Kaniko's: the build Pod runs in
// another namespace and can neither mount the uploads PVC (a PVC does not cross
// namespaces) nor hold object-store credentials, so ContextBaseURL makes the
// derived context ref an internal-face URL that the build Job's fetch
// initContainer streams (cmd/felis fetch-context). The platform renders this
// address into the api Deployment (felis API base URL env); the fallback keeps
// a hand-rolled deployment working under the platform's default control
// namespace.
contextBase := cfg.Registry.UserUploadsContext contextBase := cfg.Registry.UserUploadsContext
var blobs submit.Blobs var blobs submit.Blobs
switch { switch {
@@ -190,6 +200,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Builds: builder, Builds: builder,
Registry: cfg.Registry.URL, Registry: cfg.Registry.URL,
ContextStore: contextBase, ContextStore: contextBase,
ContextBaseURL: internalAPIBaseURL(),
Blobs: blobs, Blobs: blobs,
} }
@@ -413,6 +424,17 @@ func buildConfig(cfg *config.Config) build.Config {
} }
} }
// internalAPIBaseURL resolves the platform's internal-face base URL: the address
// the platform rendered into this pod (felis API base URL env), or — for a
// hand-rolled deployment that set none — the platform default control namespace,
// the same fallback setup.go uses to hand the login gate its address.
func internalAPIBaseURL() string {
if base := os.Getenv(naming.EnvAPIBaseURL); base != "" {
return base
}
return platform.InternalAPIBaseURL(platform.DefaultControlNamespace)
}
// uploadsSchemeRE matches a leading URL scheme like "s3://" or "gs://". // uploadsSchemeRE matches a leading URL scheme like "s3://" or "gs://".
var uploadsSchemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) var uploadsSchemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
// cmdFetchContext is the in-Pod entrypoint the build Job's context-fetch
// initContainer runs. It performs one read against the felis-api INTERNAL face —
// the blob the platform stored for a submission — and extracts it into the shared
// emptyDir the Kaniko container then builds from.
//
// Why this exists: the build Pod runs in the build namespace, where it can neither
// mount the control-plane uploads PVC (a PVC does not cross namespaces) nor hold
// object-store credentials, so the API that WROTE the blob is the transport. The
// route is service-token-gated; the token arrives through a namespace-local Secret
// mounted only into this initContainer, never into Kaniko's — so the untrusted
// Dockerfile's build steps have no credential to read (their containers share no
// environment, no PID namespace, and Kaniko itself mounts the context read-only).
//
// The extraction is deliberately paranoid: the tarball is attacker-controlled
// input, so absolute paths, ".." escapes, links, and special files are refused
// rather than sanitized. Kaniko treats the extracted tree as hostile regardless
// (spec §16), but the pod's own filesystem still must not be written outside the
// context directory it was given.
func cmdFetchContext(args []string, _, stderr io.Writer) int {
fs := flag.NewFlagSet("fetch-context", flag.ContinueOnError)
fs.SetOutput(stderr)
url := fs.String("url", "", "internal-face URL of the submission's build-context tarball")
out := fs.String("out", "/context", "directory to extract the build context into")
if err := fs.Parse(args); err != nil {
return 2
}
if *url == "" {
fmt.Fprintln(stderr, "felis fetch-context: --url is required")
return 2
}
token := os.Getenv("FELIS_SERVICE_TOKEN")
if token == "" {
fmt.Fprintln(stderr, "felis fetch-context: FELIS_SERVICE_TOKEN is empty — the internal face rejects anonymous reads")
return 2
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, *url, nil)
if err != nil {
fmt.Fprintf(stderr, "felis fetch-context: bad --url: %v\n", err)
return 2
}
req.Header.Set("Authorization", "Bearer "+token)
// No overall client timeout: a legitimate modpack context can be large and the
// Job's activeDeadlineSeconds is the real bound. The header timeout catches a
// wedged endpoint without capping a healthy download.
client := &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: time.Minute}}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(stderr, "felis fetch-context: GET failed: %v\n", err)
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(stderr, "felis fetch-context: %s\n", resp.Status)
return 1
}
if err := extractTarGz(resp.Body, *out); err != nil {
fmt.Fprintf(stderr, "felis fetch-context: %v\n", err)
return 1
}
return 0
}
// extractTarGz streams a gzip'd tarball into root, creating directories as
// needed. Every entry is vetted BEFORE anything is written: a path that is
// absolute or escapes root (via ".."), a link (symlink or hardlink), or any
// special file kind aborts the whole extraction. Refusing rather than skipping is
// deliberate — a context that needs one of those constructs is not a context this
// transport carries, and silently dropping entries would build from a corpus the
// submitter did not upload.
func extractTarGz(r io.Reader, root string) error {
if err := os.MkdirAll(root, 0o755); err != nil {
return fmt.Errorf("create context dir: %w", err)
}
zr, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("context is not a valid gzip tarball: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read context tarball: %w", err)
}
name := filepath.Clean(hdr.Name)
if name == "." {
continue
}
// The zip-slip guard: reject, never rewrite. filepath.Clean collapses any
// "a/../../b", so these two checks are sufficient once Clean has run.
if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return fmt.Errorf("context entry %q escapes the context directory", hdr.Name)
}
target := filepath.Join(root, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("create %q: %w", name, err)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create parent of %q: %w", name, err)
}
mode := os.FileMode(0o644)
if hdr.FileInfo().Mode()&0o111 != 0 {
mode = 0o755 // preserve executability (entrypoint scripts), nothing else
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("create %q: %w", name, err)
}
if _, err := io.Copy(f, tr); err != nil {
_ = f.Close()
return fmt.Errorf("write %q: %w", name, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close %q: %w", name, err)
}
default:
return fmt.Errorf("context entry %q has unsupported type %q (links and special files are refused)", hdr.Name, string(hdr.Typeflag))
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
type tarEntry struct {
name string
body string
mode int64
typ byte
linkname string
}
// tgzBody builds an in-memory .tar.gz from entries, preserving each entry's type
// and mode so the tests can exercise the guards with exactly the bytes an
// attacker could upload.
func tgzBody(t *testing.T, entries ...tarEntry) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, e := range entries {
typ := e.typ
if typ == 0 {
typ = tar.TypeReg
}
mode := e.mode
if mode == 0 {
mode = 0o644
}
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: mode, Size: int64(len(e.body))}
if typ == tar.TypeSymlink {
hdr.Linkname = e.linkname
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header %q: %v", e.name, err)
}
if hdr.Size > 0 {
if _, err := tw.Write([]byte(e.body)); err != nil {
t.Fatalf("write body %q: %v", e.name, err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// A normal context extracts with its tree intact, and the executable bit that
// modpack entrypoints rely on survives.
func TestExtractTarGzRoundTrip(t *testing.T) {
dir := t.TempDir()
body := tgzBody(t,
tarEntry{name: "Dockerfile", body: "FROM scratch\n"},
tarEntry{name: "mods/example.jar", body: "jar-bytes"},
tarEntry{name: "start.sh", body: "#!/bin/sh\n", mode: 0o755},
tarEntry{name: "mods/", typ: tar.TypeDir, mode: 0o755},
)
if err := extractTarGz(bytes.NewReader(body), dir); err != nil {
t.Fatalf("extract: %v", err)
}
for name, want := range map[string]string{
"Dockerfile": "FROM scratch\n",
"mods/example.jar": "jar-bytes",
} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || string(got) != want {
t.Fatalf("%s = (%q, %v), want %q", name, got, err, want)
}
}
fi, err := os.Stat(filepath.Join(dir, "start.sh"))
if err != nil || fi.Mode()&0o111 == 0 {
t.Fatalf("entrypoint script lost its exec bit: %v (%v)", fi, err)
}
}
// The guards: "..", absolute paths, symlinks, and special files are refused whole
// — nothing escapes, and nothing is silently skipped.
func TestExtractTarGzRefusesEscapes(t *testing.T) {
cases := []struct {
name string
entries []tarEntry
}{
{"dotdot", []tarEntry{{name: "../outside", body: "x"}}},
{"nested dotdot", []tarEntry{{name: "a/../../outside", body: "x"}}},
{"absolute", []tarEntry{{name: "/etc/outside", body: "x"}}},
{"symlink", []tarEntry{{name: "link", typ: tar.TypeSymlink, linkname: "/etc"}}},
{"hardlink", []tarEntry{{name: "hard", typ: tar.TypeLink, linkname: "somewhere"}}},
{"device", []tarEntry{{name: "dev", typ: tar.TypeChar}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := extractTarGz(bytes.NewReader(tgzBody(t, tc.entries...)), dir); err == nil {
t.Fatal("extract accepted a hostile entry, want an error")
}
// Nothing may have been written outside the target (or at all).
entries, _ := os.ReadDir(dir)
if len(entries) != 0 {
t.Fatalf("hostile archive left %d entries behind", len(entries))
}
})
}
}
// The command end to end: it dials the URL with the bearer token from the
// environment, and refuses to run without it (the internal face would 401
// anyway; failing at parse time is the honest earlier error).
func TestCmdFetchContextFetchAndExtract(t *testing.T) {
body := tgzBody(t, tarEntry{name: "Dockerfile", body: "FROM scratch\n"})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/gzip")
_, _ = w.Write(body)
}))
defer srv.Close()
dir := t.TempDir()
t.Setenv("FELIS_SERVICE_TOKEN", "test-token")
if code := cmdFetchContext([]string{"--url=" + srv.URL + "/sub-1/context", "--out=" + dir}, io.Discard, io.Discard); code != 0 {
t.Fatalf("cmdFetchContext exit = %d, want 0", code)
}
if got, err := os.ReadFile(filepath.Join(dir, "Dockerfile")); err != nil || string(got) != "FROM scratch\n" {
t.Fatalf("extracted Dockerfile = (%q, %v)", got, err)
}
// No token: refuse before dialing.
t.Setenv("FELIS_SERVICE_TOKEN", "")
var stderr bytes.Buffer
if code := cmdFetchContext([]string{"--url=" + srv.URL + "/sub-1/context", "--out=" + t.TempDir()}, io.Discard, &stderr); code != 2 {
t.Fatalf("missing token exit = %d, want 2 (stderr %q)", code, stderr.String())
}
// A non-200 answer (e.g. the route's 404 for a never-uploaded context) fails.
srv404 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv404.Close()
t.Setenv("FELIS_SERVICE_TOKEN", "test-token")
if code := cmdFetchContext([]string{"--url=" + srv404.URL + "/sub-1/context", "--out=" + t.TempDir()}, io.Discard, io.Discard); code != 1 {
t.Fatalf("404 exit = %d, want 1", code)
}
}
// A body that is not a gzip tarball must fail the extraction rather than produce
// an empty (or partial) context Kaniko would then try to build.
func TestExtractTarGzRejectsNonGzip(t *testing.T) {
dir := t.TempDir()
err := extractTarGz(strings.NewReader("not a tarball"), dir)
if err == nil || !strings.Contains(err.Error(), "gzip") {
t.Fatalf("err = %v, want a gzip complaint", err)
}
}
+2
View File
@@ -19,6 +19,7 @@ Commands:
restore Extract a world archive into a world volume (internal Job entrypoint) restore Extract a world archive into a world volume (internal Job entrypoint)
backup Archive a world into the backup store and record it (internal Job entrypoint) backup Archive a world into the backup store and record it (internal Job entrypoint)
files List/read/write one file in a stopped server's world (internal Job entrypoint) files List/read/write one file in a stopped server's world (internal Job entrypoint)
fetch-context Fetch and extract a submission's build context (internal Job entrypoint)
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo) setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
@@ -47,6 +48,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"restore": cmdRestore, "restore": cmdRestore,
"backup": cmdBackup, "backup": cmdBackup,
"files": cmdFiles, "files": cmdFiles,
"fetch-context": cmdFetchContext,
"manifests": cmdManifests, "manifests": cmdManifests,
"apply": cmdApply, "apply": cmdApply,
"setup": cmdSetup, "setup": cmdSetup,
+15 -3
View File
@@ -233,13 +233,25 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
// (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to // (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to
// self-record its world_backups row; without the replica the Job's volume // self-record its world_backups row; without the replica the Job's volume
// mount fails and every backup request strands in the cluster). // mount fails and every backup request strands in the cluster).
// An empty build_namespace means the build system's compiled-in default; the
// replica must target the namespace the Jobs actually run in.
buildNS := cfg.Registry.BuildNamespace
if buildNS == "" {
buildNS = platform.DefaultBuildNamespace
}
secretOutcomes := []systemServerOutcome{ secretOutcomes := []systemServerOutcome{
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace, ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token"), naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "minecraft ns"),
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace, ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"), naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns"),
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace, ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
"felis-config", "felis.toml", "config"), "felis-config", "felis.toml", "config", "minecraft ns"),
// The build namespace needs the same token: the build Job's fetch
// initContainer reads the submission context from the internal face. Best
// effort — a deployment that only installs the control plane simply never
// builds a user submission.
ensureSecretReplica(ctx, cl, controlNS, buildNS,
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "felis-build ns"),
} }
outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname))
outcomes = append(secretOutcomes, outcomes...) outcomes = append(secretOutcomes, outcomes...)
+15 -14
View File
@@ -95,7 +95,7 @@ const felisLimboHealthPort int32 = 8080
// fail-safes to readiness-only, so a login pod that has the URL/domain but not yet // fail-safes to readiness-only, so a login pod that has the URL/domain but not yet
// the token is safe (it simply does not authenticate) rather than broken. // the token is safe (it simply does not authenticate) rather than broken.
const ( const (
envAPIBaseURL = "FELIS_API_BASE_URL" envAPIBaseURL = naming.EnvAPIBaseURL
envRootDomain = "FELIS_ROOT_DOMAIN" envRootDomain = "FELIS_ROOT_DOMAIN"
envPanelHostname = "FELIS_PANEL_HOSTNAME" envPanelHostname = "FELIS_PANEL_HOSTNAME"
envLobbyServer = "FELIS_LOBBY_SERVER" envLobbyServer = "FELIS_LOBBY_SERVER"
@@ -471,26 +471,27 @@ func phaseOrPending(p v1alpha1.Phase) string {
return string(p) return string(p)
} }
// ensureSecretReplica copies one Secret from the control namespace into the minecraft // ensureSecretReplica copies one Secret from the control namespace into a workload
// namespace so a backend pod can mount it via secretKeyRef. A secretKeyRef is // namespace (minecraft — or the build namespace, whose fetch initContainer reads the
// namespace-local, but the backends run in the minecraft namespace while the sources // context from the felis-api internal face with the same token) so a pod can mount it
// of truth live beside the control plane — so without this replica the operator's // via secretKeyRef. A secretKeyRef is namespace-local, but those workloads do not run
// injected secretKeyRef would dangle and wedge the pod in CreateContainerConfigError. // beside the control plane — so without this replica the secretKeyRef would dangle and
// wedge the pod in CreateContainerConfigError.
// //
// Two Secrets need it, for different reasons: the service token (login only — it // Two Secrets need it, for different reasons: the service token (the login limbo and
// authenticates the limbo plugin to the felis-api internal face) and the Velocity // the build Pod's context fetch — both authenticate to the felis-api internal face)
// modern-forwarding secret (every backend — it is how a backend knows a login really // and the Velocity modern-forwarding secret (every backend — it is how a backend knows
// came from the proxy, and so that the player's UUID is Mojang-verified rather than // a login really came from the proxy, and so that the player's UUID is Mojang-verified
// offline-derived). // rather than offline-derived).
// //
// It is create-if-absent: an existing replica is left untouched so a hand-rotated // It is create-if-absent: an existing replica is left untouched so a hand-rotated
// value in the minecraft namespace is never clobbered (to rotate, delete the replica // value in the workload namespace is never clobbered (to rotate, delete the replica
// and re-run setup). Best-effort like the rest of the provisioner: a missing source or // and re-run setup). Best-effort like the rest of the provisioner: a missing source or
// a create failure degrades to a reported outcome, never a hard setup failure. It // a create failure degrades to a reported outcome, never a hard setup failure. It
// copies only Type and Data — never labels/annotations/ownerRefs — so the replica // copies only Type and Data — never labels/annotations/ownerRefs — so the replica
// carries no accidental GC owner or managed-by lineage. // carries no accidental GC owner or managed-by lineage.
func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label string) systemServerOutcome { func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label, where string) systemServerOutcome {
name := label + " (minecraft ns)" name := label + " (" + where + ")"
validate := func(secret *corev1.Secret, location, skipped string) systemServerOutcome { validate := func(secret *corev1.Secret, location, skipped string) systemServerOutcome {
if len(secret.Data[secretKey]) == 0 { if len(secret.Data[secretKey]) == 0 {
return systemServerOutcome{name: name, skipped: fmt.Sprintf( return systemServerOutcome{name: name, skipped: fmt.Sprintf(
+1 -1
View File
@@ -156,7 +156,7 @@ func TestEnsureSecretReplica(t *testing.T) {
} }
replicate := func(cl client.Client, controlNS, mcNS string) systemServerOutcome { replicate := func(cl client.Client, controlNS, mcNS string) systemServerOutcome {
return ensureSecretReplica(ctx, cl, controlNS, mcNS, return ensureSecretReplica(ctx, cl, controlNS, mcNS,
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token") naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "minecraft ns")
} }
t.Run("replicates when absent", func(t *testing.T) { t.Run("replicates when absent", func(t *testing.T) {
+4
View File
@@ -2148,6 +2148,10 @@ deploy_bundle() {
--from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \ --from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \
--dry-run=client -o yaml | kube apply -f - --dry-run=client -o yaml | kube apply -f -
apply_literal_secret "$CONTROL_NS" felis-service-token token "$SERVICE_TOKEN" apply_literal_secret "$CONTROL_NS" felis-service-token token "$SERVICE_TOKEN"
# The build namespace needs the same token: the build Job's fetch initContainer
# streams a submission's build context from the felis-api internal face, and a
# secretKeyRef is namespace-local (a PVC cannot carry it across either).
apply_literal_secret "$BUILD_NS" felis-service-token token "$SERVICE_TOKEN"
# The forwarding key every backend verifies the proxy's handshake with. `felis setup` # The forwarding key every backend verifies the proxy's handshake with. `felis setup`
# replicates it into the minecraft namespace (ensureSecretReplica) before it creates # replicates it into the minecraft namespace (ensureSecretReplica) before it creates
# the pods that mount it; the operator injects it into EVERY backend, because Velocity's # the pods that mount it; the operator injects it into EVERY backend, because Velocity's
+16 -15
View File
@@ -42,21 +42,22 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams.
does not exist. `felis update` runs with a zero window, under which every does not exist. `felis update` runs with a zero window, under which every
`Scheduled` component degrades to a notify, so no path can currently claim an `Scheduled` component degrades to a notify, so no path can currently claim an
apply is under way. apply is under way.
- `internal/submit/blobstore.go:40` — the uploads PVC is mounted into felis-api but - `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot
not into the Kaniko build Pod, so a submitted context is durable at the derived cross namespaces, so the transport went through the API instead of a mount: the
location without yet being readable by the build that consumes it. Audited derived context ref is now the internal-face URL
2026-09-22: this is not a missing volume line — a PVC cannot cross namespaces (`/api/v1/internal/submissions/{id}/context`, service-token gated), the build
(uploads live in the control namespace; build Pods run in `felis-build`), so the Job's `context-fetch` initContainer streams it with `felis fetch-context` and
fix is a transport, not a mount. The `s3://` lane does not close it either: the extracts under a zip-slip guard into a size-limited emptyDir, and Kaniko builds
build Job carries no AWS credentials (no env, and the weak SA's token is `--context=/context`. The token reaches the build namespace through the same
deliberately unmounted, so no IAM either). Options on the table: (a) object Secret-replica mechanism the login gate uses (bootstrap + `felis setup`), and the
storage with credentials plumbed into the build Pod as a per-build Secret plus an build egress lock allows exactly the control namespace on the internal port.
egress allowance; (b) a context-handoff PVC/Job pair in `felis-build` fed from Uniform for local and s3:// stores — neither hands the sandboxed build Pod a
the API side; (c) a node-local path both sides mount (single-node only, and it filesystem view or object-store credentials. Kaniko/Trivy images are
hands an arbitrary Dockerfile a filesystem view — needs its own security review). external-only by default; `[registry] kaniko_image / trivy_image /
Kaniko/Trivy images are external-only by default; `[registry] kaniko_image / build_cpu_limit / build_mem_limit` override them for mirrored or air-gapped
trivy_image / build_cpu_limit / build_mem_limit` now override them for mirrored installs, and Trivy's vulnerability DB download needs the same treatment (a
or air-gapped installs. `package_source_cidrs` allowance or an internal `TRIVY_DB_REPOSITORY` mirror) or
the scan step fails closed on an egress-locked install.
## Built; only its I/O is unverifiable from this repo ## Built; only its I/O is unverifiable from this repo
+28
View File
@@ -607,6 +607,34 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
/api/v1/internal/submissions/{id}/context:
get:
tags: [submissions-internal]
operationId: internalSubmissionContext
summary: Stream a submission's stored build-context tarball to the build Pod.
description: >-
The build Job's fetch initContainer cannot mount the control-plane uploads
PVC (a PVC does not cross namespaces) and holds no object-store
credentials, so the API that stored the blob streams it here. Served on
the internal face (service token, no Zero Trust).
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The stored gzip tarball, verbatim.
content:
application/gzip:
schema: { type: string, format: binary }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/internal/servers/{name}/join-event: /api/v1/internal/servers/{name}/join-event:
post: post:
tags: [servers-internal] tags: [servers-internal]
+3
View File
@@ -271,6 +271,9 @@ func (a *API) internalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers}, {Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
// The build Pod's context-fetch initContainer streams a submission's stored
// modpack through this route (build namespace cannot mount the uploads PVC).
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", h: a.handleInternalSubmissionContext},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady}, {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent}, {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls // Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
+34
View File
@@ -42,6 +42,11 @@ type SubmissionService interface {
// Reject is the admin's other verdict: pending_review -> rejected with a // Reject is the admin's other verdict: pending_review -> rejected with a
// required reason; it starts no build. // required reason; it starts no build.
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error) Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
// OpenContext returns the stored build-context blob for the internal
// context-fetch route: the build Pod's initContainer cannot mount the uploads
// PVC across namespaces and holds no object-store credentials, so it streams
// the blob from the API over the service-token-gated internal face instead.
OpenContext(ctx context.Context, id string) (io.ReadCloser, error)
} }
// createSubmissionRequest is the POST /me/submissions body. The user // createSubmissionRequest is the POST /me/submissions body. The user
@@ -211,6 +216,8 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, submit.ErrAlreadyReviewed): case errors.Is(err, submit.ErrAlreadyReviewed):
writeError(w, r, newError(http.StatusConflict, "already_reviewed", writeError(w, r, newError(http.StatusConflict, "already_reviewed",
"submission has already been reviewed")) "submission has already been reviewed"))
case errors.Is(err, submit.ErrBlobNotFound):
writeError(w, r, newError(http.StatusNotFound, "not_found", "no context uploaded for this submission"))
case errors.Is(err, submit.ErrUploadsUnavailable): case errors.Is(err, submit.ErrUploadsUnavailable):
writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable", writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable",
"modpack upload transport is not configured")) "modpack upload transport is not configured"))
@@ -219,5 +226,32 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
} }
} }
// handleInternalSubmissionContext streams a submission's stored build-context
// tarball to the build Pod's `felis fetch-context` initContainer. It lives on the
// internal face (service-token, no Zero Trust) because its only caller is
// in-cluster infrastructure: the build Job runs in the build namespace, where it
// can neither mount the uploads PVC nor hold object-store credentials, so the API
// — which wrote the blob — is the transport. The blob is served verbatim; the
// fetcher extracts it under a zip-slip guard, and Kaniko treats the result as
// hostile regardless (spec §16).
func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
}
rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id"))
if err != nil {
writeSubmitError(w, r, err)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/gzip")
if _, err := io.Copy(w, rc); err != nil {
// The status is already committed; the client sees a truncated stream and
// the fetch fails on size/extract, so there is nothing left to write here.
return
}
}
// Compile-time proof that the production Manager satisfies the API interface. // Compile-time proof that the production Manager satisfies the API interface.
var _ SubmissionService = (*submit.Manager)(nil) var _ SubmissionService = (*submit.Manager)(nil)
+57
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"testing" "testing"
"felis.lolicon.best/internal/submit" "felis.lolicon.best/internal/submit"
@@ -35,6 +36,9 @@ type fakeSubmissions struct {
rejectedBy string rejectedBy string
rejectReas string rejectReas string
rejectErr error rejectErr error
openedID string
openBody string
openErr error
} }
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) { func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
@@ -82,6 +86,16 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
} }
// openErr injects the OpenContext outcome; the body recorder lets the internal
// route test assert byte-exact streaming and the 404 mapping.
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
f.openedID = id
if f.openErr != nil {
return nil, f.openErr
}
return io.NopCloser(strings.NewReader(f.openBody)), nil
}
// appSubAPI wires a submissions service behind an ordinary user principal (the // appSubAPI wires a submissions service behind an ordinary user principal (the
// app tier — /me/submissions). [email protected] / .test are deliberately not the // app tier — /me/submissions). [email protected] / .test are deliberately not the
// deployment domain. // deployment domain.
@@ -396,3 +410,46 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
t.Fatalf("admin route: code = %d, want 503", w.Code) t.Fatalf("admin route: code = %d, want 503", w.Code)
} }
} }
// The internal context route is the build Pod's only read path to a submission's
// blob: it streams the bytes verbatim, and its error mapping distinguishes a
// missing blob (404) from an unwired transport (503).
func TestInternalSubmissionContextRoute(t *testing.T) {
newAPI := func(s SubmissionService) *API {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.Submissions = s
return api
}
t.Run("streams the blob", func(t *testing.T) {
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if w.Body.String() != fs.openBody {
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
}
if fs.openedID != "sub-7" {
t.Fatalf("opened id = %q, want the path id", fs.openedID)
}
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
t.Fatalf("content-type = %q, want application/gzip", ct)
}
})
t.Run("missing blob is 404", func(t *testing.T) {
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("unwired transport is 503", func(t *testing.T) {
w := do(newAPI(nil).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503", w.Code)
}
})
}
+6
View File
@@ -215,6 +215,11 @@ type Config struct {
// RegistryURL is the internal registry the build pushes to and Trivy scans // RegistryURL is the internal registry the build pushes to and Trivy scans
// (spec §17). Image refs are validated to be under it. // (spec §17). Image refs are validated to be under it.
RegistryURL string 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 / TrivyImage are the executor images.
KanikoImage string KanikoImage string
TrivyImage string TrivyImage string
@@ -355,6 +360,7 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams {
Namespace: cfg.Namespace, Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount, ServiceAccount: cfg.ServiceAccount,
RegistryURL: cfg.RegistryURL, RegistryURL: cfg.RegistryURL,
FelisImage: cfg.FelisImage,
KanikoImage: cfg.KanikoImage, KanikoImage: cfg.KanikoImage,
TrivyImage: cfg.TrivyImage, TrivyImage: cfg.TrivyImage,
Deadline: cfg.Deadline, Deadline: cfg.Deadline,
+122 -2
View File
@@ -2,8 +2,10 @@ package build
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"felis.lolicon.best/internal/naming"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1" networkingv1 "k8s.io/api/networking/v1"
@@ -32,8 +34,23 @@ const (
const ( const (
ContainerKaniko = "kaniko" ContainerKaniko = "kaniko"
ContainerTrivy = "trivy" 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 // 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 // Build + Config by the Builder; jobspec is a pure function of them so the
// security-critical Job shape is unit-tested without a cluster. // security-critical Job shape is unit-tested without a cluster.
@@ -44,6 +61,9 @@ type JobParams struct {
Namespace string Namespace string
ServiceAccount string ServiceAccount string
RegistryURL string RegistryURL 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 KanikoImage string
TrivyImage string TrivyImage string
Deadline time.Duration Deadline time.Duration
@@ -100,21 +120,75 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, 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{ kaniko := corev1.Container{
Name: ContainerKaniko, Name: ContainerKaniko,
Image: p.KanikoImage, Image: p.KanikoImage,
Args: []string{ Args: []string{
"--dockerfile=Dockerfile", "--dockerfile=Dockerfile",
"--context=" + p.ContextRef, "--context=" + contextPath,
"--destination=" + p.ImageRef, "--destination=" + p.ImageRef,
// The internal registry is in-cluster only and may serve plain HTTP; // The internal registry is in-cluster only and may serve plain HTTP;
// it is never a public ingress (spec §17). // it is never a public ingress (spec §17).
"--insecure", "--insecure",
"--skip-tls-verify", "--skip-tls-verify",
}, },
VolumeMounts: kanikoMounts,
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits}, Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
SecurityContext: sec, SecurityContext: sec,
} }
initContainers = append(initContainers, kaniko)
trivy := corev1.Container{ trivy := corev1.Container{
Name: ContainerTrivy, Name: ContainerTrivy,
@@ -147,8 +221,9 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
RestartPolicy: corev1.RestartPolicyNever, RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: p.ServiceAccount, ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false), AutomountServiceAccountToken: boolPtr(false),
InitContainers: []corev1.Container{kaniko}, InitContainers: initContainers,
Containers: []corev1.Container{trivy}, Containers: []corev1.Container{trivy},
Volumes: podVolumes,
}, },
}, },
}, },
@@ -156,11 +231,24 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
return job, nil 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. // NetPolParams parameterises the build-namespace egress lock.
type NetPolParams struct { type NetPolParams struct {
Namespace string Namespace string
RegistryNamespace string RegistryNamespace string
RegistryPort int32 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 // PackageSourceCIDRs is an optional, explicit allowlist of external package
// mirrors (spec §16: egress 仅 registry + 包源). Empty means the most // mirrors (spec §16: egress 仅 registry + 包源). Empty means the most
// locked-down default — no internet egress at all (默认拒外网). // locked-down default — no internet egress at all (默认拒外网).
@@ -177,10 +265,19 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
if port == 0 { if port == 0 {
port = 5000 port = 5000
} }
controlNS := p.ControlNamespace
if controlNS == "" {
controlNS = "felis"
}
apiPort := p.APIPort
if apiPort == 0 {
apiPort = 8081
}
dnsUDP := corev1.ProtocolUDP dnsUDP := corev1.ProtocolUDP
dnsTCP := corev1.ProtocolTCP dnsTCP := corev1.ProtocolTCP
dns53 := intstr.FromInt32(53) dns53 := intstr.FromInt32(53)
regPort := intstr.FromInt32(port) regPort := intstr.FromInt32(port)
ctxPort := intstr.FromInt32(apiPort)
egress := []networkingv1.NetworkPolicyEgressRule{ egress := []networkingv1.NetworkPolicyEgressRule{
// DNS resolution: port-restricted to 53, so this is not an open-internet // 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: &regPort}, {Protocol: &dnsTCP, Port: &regPort},
}, },
}, },
// 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. // Explicit package-mirror CIDRs, when configured. No CIDR ⇒ no internet.
for _, cidr := range p.PackageSourceCIDRs { 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 boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i } 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 } func int64Ptr(i int64) *int64 { return &i }
+126
View File
@@ -177,6 +177,132 @@ func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) {
if !egressAllowsPort(np, 53) { if !egressAllowsPort(np, 53) {
t.Error("egress must allow DNS (port 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 — // With no package-source CIDRs configured, there must be zero IPBlock egress —
+4
View File
@@ -56,6 +56,10 @@ const (
const ( const (
ServiceTokenSecretName = "felis-service-token" ServiceTokenSecretName = "felis-service-token"
ServiceTokenSecretKey = "token" ServiceTokenSecretKey = "token"
// EnvAPIBaseURL carries the internal-face base URL (platform.InternalAPIBaseURL)
// into a pod: the login gate dials it, and the api reads it to derive the build
// contexts' fetch URLs, so both sides name the same address for the same face.
EnvAPIBaseURL = "FELIS_API_BASE_URL"
) )
// ForwardingSecretName / ForwardingSecretKey name the Velocity modern player-info // ForwardingSecretName / ForwardingSecretKey name the Velocity modern player-info
+3
View File
@@ -81,6 +81,9 @@ func Objects(p Params) []Object {
Namespace: p.BuildNamespace, Namespace: p.BuildNamespace,
RegistryNamespace: p.RegistryNamespace, RegistryNamespace: p.RegistryNamespace,
RegistryPort: p.RegistryPort, RegistryPort: p.RegistryPort,
ControlNamespace: p.ControlNamespace,
// The internal face's port, single-sourced with the api Deployment below.
APIPort: apiInternalPort,
PackageSourceCIDRs: p.PackageSourceCIDRs, PackageSourceCIDRs: p.PackageSourceCIDRs,
}) })
buildNP.TypeMeta = metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"} buildNP.TypeMeta = metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}
+4
View File
@@ -274,6 +274,10 @@ func APIDeployment(p Params) *appsv1.Deployment {
}, },
}, },
{Name: "FELIS_IMAGE", Value: p.FelisImage}, {Name: "FELIS_IMAGE", Value: p.FelisImage},
// The api's own internal-face base URL, so it derives the submission
// context URLs that build Pods fetch through it. Same value the login gate
// is handed; one address for one face.
{Name: naming.EnvAPIBaseURL, Value: InternalAPIBaseURL(p.ControlNamespace)},
} }
if p.BackupPVC != "" { if p.BackupPVC != "" {
env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC}) env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC})
+5
View File
@@ -179,6 +179,11 @@ func TestAPIDeployment_Wiring(t *testing.T) {
if v := envValue(c.Env, "FELIS_IMAGE"); v != p.FelisImage { if v := envValue(c.Env, "FELIS_IMAGE"); v != p.FelisImage {
t.Errorf("FELIS_IMAGE = %q, want %q", v, p.FelisImage) t.Errorf("FELIS_IMAGE = %q, want %q", v, p.FelisImage)
} }
// The internal-face base URL the api derives submission context-fetch URLs
// from — the same address the login gate is handed.
if v := envValue(c.Env, "FELIS_API_BASE_URL"); v != InternalAPIBaseURL(p.ControlNamespace) {
t.Errorf("FELIS_API_BASE_URL = %q, want %q", v, InternalAPIBaseURL(p.ControlNamespace))
}
// FELIS_SERVICE_TOKEN must come from a Secret, never a literal value. // FELIS_SERVICE_TOKEN must come from a Secret, never a literal value.
tok := envVar(c.Env, "FELIS_SERVICE_TOKEN") tok := envVar(c.Env, "FELIS_SERVICE_TOKEN")
if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil { if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
+18
View File
@@ -111,5 +111,23 @@ func (s *LocalContextStore) Exists(_ context.Context, id string) (bool, error) {
} }
} }
// Open returns the stored context blob for id — the read side of the transport the
// build Pod's fetch initContainer uses. A missing blob is ErrBlobNotFound (404 on
// the route), never a bare os error, so the API keeps its status mapping.
func (s *LocalContextStore) Open(_ context.Context, id string) (io.ReadCloser, error) {
dir, err := s.dir(id)
if err != nil {
return nil, err
}
f, err := os.Open(filepath.Join(dir, contextBlobName))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %v", ErrBlobNotFound, err)
}
return nil, fmt.Errorf("submit: open context blob: %w", err)
}
return f, nil
}
// Compile-time proof that the filesystem store satisfies the Blobs transport. // Compile-time proof that the filesystem store satisfies the Blobs transport.
var _ Blobs = (*LocalContextStore)(nil) var _ Blobs = (*LocalContextStore)(nil)
+35
View File
@@ -2,6 +2,8 @@ package submit
import ( import (
"context" "context"
"errors"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -54,6 +56,39 @@ func TestLocalContextStorePutAndExists(t *testing.T) {
} }
} }
// Open is the internal context-fetch route's read path: it serves exactly the
// stored bytes, and a missing blob is ErrBlobNotFound (404), never a bare os error.
func TestLocalContextStoreOpen(t *testing.T) {
base := t.TempDir()
s := &LocalContextStore{Base: base}
ctx := context.Background()
if _, err := s.Open(ctx, "sub-gone"); !errors.Is(err, ErrBlobNotFound) {
t.Fatalf("Open of a missing blob = %v, want ErrBlobNotFound", err)
}
payload := "\x1f\x8b\x08\x00the modpack context"
if _, err := s.Put(ctx, "sub-abc", strings.NewReader(payload)); err != nil {
t.Fatalf("Put: %v", err)
}
rc, err := s.Open(ctx, "sub-abc")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != payload {
t.Fatalf("Open served %q, want %q", got, payload)
}
// The same path guard as Put: an id that could escape Base is refused.
if _, err := s.Open(ctx, "../etc/passwd"); err == nil {
t.Fatal("Open must reject an unsafe id")
}
}
func TestLocalContextStorePutOverwrites(t *testing.T) { func TestLocalContextStorePutOverwrites(t *testing.T) {
base := t.TempDir() base := t.TempDir()
s := &LocalContextStore{Base: base} s := &LocalContextStore{Base: base}
+58 -9
View File
@@ -14,11 +14,30 @@ import (
) )
// s3Client is the minimal object-store surface S3ContextStore needs. *minio.Client // s3Client is the minimal object-store surface S3ContextStore needs. *minio.Client
// satisfies it, and a fake satisfies it in tests — so the store's key derivation // satisfies it through minioStoreClient, and a fake satisfies it in tests — so the
// and not-found handling are unit-verifiable without a live bucket. // store's key derivation and not-found handling are unit-verifiable without a live
// bucket.
type s3Client interface { type s3Client interface {
PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error) StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error)
GetObject(ctx context.Context, bucket, object string, opts minio.GetObjectOptions) (s3Object, error)
}
// s3Object is the handle GetObject yields: a stream whose Stat performs the HEAD
// eagerly, so a missing object surfaces before the first byte is read.
type s3Object interface {
io.ReadCloser
Stat() (minio.ObjectInfo, error)
}
// minioStoreClient adapts *minio.Client to s3Client. The adapter exists because a
// method's return type cannot be narrowed by an interface: GetObject on the real
// client returns a concrete *minio.Object, which does not satisfy a method declared
// to return s3Object.
type minioStoreClient struct{ *minio.Client }
func (m minioStoreClient) GetObject(ctx context.Context, bucket, object string, opts minio.GetObjectOptions) (s3Object, error) {
return m.Client.GetObject(ctx, bucket, object, opts)
} }
// S3ContextStore is the object-store-backed build-context blob store: it writes // S3ContextStore is the object-store-backed build-context blob store: it writes
@@ -27,16 +46,17 @@ type s3Client interface {
// selected by cmd/felis when user_uploads_context is an s3:// base. // selected by cmd/felis when user_uploads_context is an s3:// base.
// //
// The bucket + key prefix are parsed from that same base (parseS3Base), so an // The bucket + key prefix are parsed from that same base (parseS3Base), so an
// object written here lands at exactly s3://{bucket}/{prefix}/{id}/context.tar.gz — // object written here lands at exactly s3://{bucket}/{prefix}/{id}/context.tar.gz.
// the ref deriveContextRef records and Kaniko's native s3:// --context reads.
// Credentials are static V4 keys resolved by cmd/felis from the environment (the // Credentials are static V4 keys resolved by cmd/felis from the environment (the
// setup wizard injects them into felis-api from the felis-uploads-s3 Secret); they // setup wizard injects them into felis-api from the felis-uploads-s3 Secret); they
// never touch felis.toml. // never touch felis.toml.
// //
// Kaniko reading the S3 context at build time needs its own credentials + egress // The sandboxed build Job never needs S3 credentials of its own: the api reads the
// on the sandboxed build Job — a separate deployment integration, exactly like the // object back here (Open) and streams it over the internal face, which is the
// LocalContextStore PVC mount. This transport only makes the upload durable at the // transport every in-cluster build uses (Manager.ContextBaseURL). Only a
// derived location. // deployment that leaves ContextBaseURL empty would fall back to Kaniko reading
// s3:// natively — and such a deployment would still need to hand the build Pod
// credentials + egress itself.
type S3ContextStore struct { type S3ContextStore struct {
client s3Client client s3Client
bucket string bucket string
@@ -79,7 +99,7 @@ func NewS3ContextStore(cfg S3StoreConfig) (*S3ContextStore, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("submit: s3 client: %w", err) return nil, fmt.Errorf("submit: s3 client: %w", err)
} }
return &S3ContextStore{client: client, bucket: bucket, prefix: prefix}, nil return &S3ContextStore{client: minioStoreClient{client}, bucket: bucket, prefix: prefix}, nil
} }
// CheckS3Access verifies the S3 coordinates before they are committed to config: // CheckS3Access verifies the S3 coordinates before they are committed to config:
@@ -167,6 +187,35 @@ func (s *S3ContextStore) Exists(ctx context.Context, id string) (bool, error) {
return true, nil return true, nil
} }
// Open returns the stored context blob for id — the read side of the transport the
// build Pod's fetch initContainer uses. minio's GetObject returns only once the
// server answered with an object (it surfaces NoSuchKey up front), so a missing
// object maps to ErrBlobNotFound right here and the route answers 404.
func (s *S3ContextStore) Open(ctx context.Context, id string) (io.ReadCloser, error) {
key, err := s.keyFor(id)
if err != nil {
return nil, err
}
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
if err != nil {
if isS3NotFound(err) {
return nil, fmt.Errorf("%w: %v", ErrBlobNotFound, err)
}
return nil, fmt.Errorf("submit: open context blob: %w", err)
}
// minio.Object is lazy: the first Read triggers the GET and is where a missing
// key actually surfaces, so stat it once here to translate that case eagerly
// (the caller can then trust the io.ReadCloser belongs to a real object).
if _, err := obj.Stat(); err != nil {
_ = obj.Close()
if isS3NotFound(err) {
return nil, fmt.Errorf("%w: %v", ErrBlobNotFound, err)
}
return nil, fmt.Errorf("submit: open context blob: %w", err)
}
return obj, nil
}
// isS3NotFound recognizes the "object is absent" outcome across S3 // isS3NotFound recognizes the "object is absent" outcome across S3
// implementations: a GET-shaped NoSuchKey code or a bare 404 from the HEAD that // implementations: a GET-shaped NoSuchKey code or a bare 404 from the HEAD that
// StatObject issues. // StatObject issues.
+63
View File
@@ -45,6 +45,41 @@ func (f *fakeS3) StatObject(_ context.Context, bucket, object string, _ minio.St
return minio.ObjectInfo{}, minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound} return minio.ObjectInfo{}, minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound}
} }
// fakeS3Object is the object handle fakeS3.GetObject yields: Stat mirrors
// StatObject's not-found behaviour, Read serves the stored bytes.
type fakeS3Object struct {
data []byte
err error
}
func (o *fakeS3Object) Read(p []byte) (int, error) {
if o.err != nil {
return 0, o.err
}
if len(o.data) == 0 {
return 0, io.EOF
}
n := copy(p, o.data)
o.data = o.data[n:]
return n, nil
}
func (o *fakeS3Object) Close() error { return nil }
func (o *fakeS3Object) Stat() (minio.ObjectInfo, error) {
if o.err != nil {
return minio.ObjectInfo{}, o.err
}
return minio.ObjectInfo{Size: int64(len(o.data))}, nil
}
func (f *fakeS3) GetObject(_ context.Context, bucket, object string, _ minio.GetObjectOptions) (s3Object, error) {
if data, ok := f.objects[bucket+"/"+object]; ok {
return &fakeS3Object{data: append([]byte(nil), data...)}, nil
}
return &fakeS3Object{err: minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound}}, nil
}
func TestCheckBucketAccess(t *testing.T) { func TestCheckBucketAccess(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -107,6 +142,34 @@ func TestS3ContextStorePutAndExists(t *testing.T) {
} }
} }
// Open serves the stored object's bytes and maps a missing key to ErrBlobNotFound
// (the internal fetch route's 404), eagerly — before the caller reads a byte.
func TestS3ContextStoreOpen(t *testing.T) {
fake := &fakeS3{}
s := &S3ContextStore{client: fake, bucket: "felis-uploads", prefix: "builds"}
ctx := context.Background()
if _, err := s.Open(ctx, "sub-gone"); !errors.Is(err, ErrBlobNotFound) {
t.Fatalf("Open of a missing object = %v, want ErrBlobNotFound", err)
}
payload := "\x1f\x8b\x08\x00the modpack context"
if _, err := s.Put(ctx, "sub-abc", strings.NewReader(payload)); err != nil {
t.Fatalf("Put: %v", err)
}
rc, err := s.Open(ctx, "sub-abc")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != payload {
t.Fatalf("Open served %q, want %q", got, payload)
}
}
func TestS3ContextStoreEmptyPrefix(t *testing.T) { func TestS3ContextStoreEmptyPrefix(t *testing.T) {
fake := &fakeS3{} fake := &fakeS3{}
s := &S3ContextStore{client: fake, bucket: "b", prefix: ""} s := &S3ContextStore{client: fake, bucket: "b", prefix: ""}
+40 -6
View File
@@ -37,7 +37,8 @@
// chosen ref would let an untrusted origin point the build at an arbitrary // chosen ref would let an untrusted origin point the build at an arbitrary
// source. By deriving it from the submission id the user selects nothing // source. By deriving it from the submission id the user selects nothing
// that reaches the executor — only the modpack blob behind the pinned, // that reaches the executor — only the modpack blob behind the pinned,
// id-namespaced location (uploaded by a separate, deferred transport). // id-namespaced location (uploaded through the Blobs transport, and served
// back to the build Pod over the service-token-gated internal face).
// //
// Source of truth. submissions is a NEW Postgres business-truth domain, added to // Source of truth. submissions is a NEW Postgres business-truth domain, added to
// §1 invariant 2's enumeration (owner/claim/accounts/audit/quota/images/builds/ // §1 invariant 2's enumeration (owner/claim/accounts/audit/quota/images/builds/
@@ -91,6 +92,10 @@ var (
// an honest 503, never a 500, exactly as the restore executor does when its // an honest 503, never a 500, exactly as the restore executor does when its
// integration is not wired. // integration is not wired.
ErrUploadsUnavailable = errors.New("submit: context upload transport not configured") ErrUploadsUnavailable = errors.New("submit: context upload transport not configured")
// ErrBlobNotFound reports that a submission has no stored context blob (or it
// was never uploaded). The internal context-fetch route maps it to 404, the
// same distinction Exists draws for Approve.
ErrBlobNotFound = errors.New("submit: context blob not found")
) )
// invalidf wraps ErrInvalid so every malformed-request case maps to one 400. // invalidf wraps ErrInvalid so every malformed-request case maps to one 400.
@@ -176,11 +181,12 @@ type Builds interface {
// Blobs is the build-context blob transport the lane depends on to place a // Blobs is the build-context blob transport the lane depends on to place a
// submitter's uploaded modpack at the platform-derived, id-namespaced location // submitter's uploaded modpack at the platform-derived, id-namespaced location
// deriveContextRef points Kaniko at. It is the piece the package doc calls a // deriveContextRef points Kaniko at. Creation only derives and records the ref;
// "separate, deferred transport": creation only derives and records the ref, and // the bytes behind it arrive through Put here, and the build Pod reads them back
// the bytes behind it arrive through Put here. It is an interface so the Manager // through Open (the manager exposes it as OpenContext, which the API's internal
// is unit-tested against an in-memory fake; the production implementation is the // context route serves). It is an interface so the Manager is unit-tested against
// filesystem-backed LocalContextStore. // an in-memory fake; the production implementations are LocalContextStore
// (filesystem) and S3ContextStore (object store).
// //
// Both methods key off the submission id, never a caller-supplied path, so the // Both methods key off the submission id, never a caller-supplied path, so the
// write target is as platform-pinned as the derived ref itself. Put stores (and // write target is as platform-pinned as the derived ref itself. Put stores (and
@@ -190,6 +196,11 @@ type Builds interface {
type Blobs interface { type Blobs interface {
Put(ctx context.Context, id string, r io.Reader) (int64, error) Put(ctx context.Context, id string, r io.Reader) (int64, error)
Exists(ctx context.Context, id string) (bool, error) Exists(ctx context.Context, id string) (bool, error)
// Open returns the stored blob's bytes for the internal context-fetch route
// the build Pod's initContainer dials (cmd/felis fetch-context). It returns an
// error wrapping ErrBlobNotFound when no blob exists, so the route can answer
// 404 without leaking which ids do exist.
Open(ctx context.Context, id string) (io.ReadCloser, error)
} }
// Manager orchestrates the approval lane. It holds no mutable state; the clock // Manager orchestrates the approval lane. It holds no mutable state; the clock
@@ -210,6 +221,15 @@ type Manager struct {
// "s3://felis-user-uploads" (an object store) or a local uploads PVC path. The // "s3://felis-user-uploads" (an object store) or a local uploads PVC path. The
// derived context ref is {ContextStore}/{id}/context.tar.gz. // derived context ref is {ContextStore}/{id}/context.tar.gz.
ContextStore string ContextStore string
// ContextBaseURL, when set, is the platform's internal-face base URL
// (platform.InternalAPIBaseURL). It makes the derived context ref an HTTP URL
// on that face — {ContextBaseURL}/api/v1/internal/submissions/{id}/context —
// instead of a filesystem/object-store location: the build Pod cannot mount
// the uploads PVC (builds run in another namespace) and carries no object-store
// credentials, so the API streams the blob it stored at ContextStore over the
// service-token-gated internal face. Empty keeps the legacy ref shape for a
// deployment that predates the transport.
ContextBaseURL string
// Blobs is the upload transport that persists the modpack behind the derived // Blobs is the upload transport that persists the modpack behind the derived
// context ref. When nil (a store with no implemented transport, e.g. an // context ref. When nil (a store with no implemented transport, e.g. an
// object-store base with no client), UploadContext returns ErrUploadsUnavailable // object-store base with no client), UploadContext returns ErrUploadsUnavailable
@@ -269,9 +289,23 @@ func (m *Manager) deriveImageRef(id string) string {
// selects nothing that reaches Kaniko's --context argument; only the blob behind // selects nothing that reaches Kaniko's --context argument; only the blob behind
// this pinned, id-namespaced location (placed by the upload transport) varies. // this pinned, id-namespaced location (placed by the upload transport) varies.
func (m *Manager) deriveContextRef(id string) string { func (m *Manager) deriveContextRef(id string) string {
if m.ContextBaseURL != "" {
return fmt.Sprintf("%s/api/v1/internal/submissions/%s/context", strings.TrimRight(m.ContextBaseURL, "/"), id)
}
return fmt.Sprintf("%s/%s/%s", strings.TrimRight(m.ContextStore, "/"), id, contextBlobName) return fmt.Sprintf("%s/%s/%s", strings.TrimRight(m.ContextStore, "/"), id, contextBlobName)
} }
// OpenContext returns the stored build context for id — the read path behind the
// internal context-fetch route. It requires the upload transport (Blobs): with no
// transport there is no blob to read, so it reports ErrUploadsUnavailable, the
// same honest 503 the upload endpoint gives.
func (m *Manager) OpenContext(ctx context.Context, id string) (io.ReadCloser, error) {
if m.Blobs == nil {
return nil, ErrUploadsUnavailable
}
return m.Blobs.Open(ctx, id)
}
// auditDockerfile is the audit-archive Dockerfile recorded on the build row. It // auditDockerfile is the audit-archive Dockerfile recorded on the build row. It
// is NOT what Kaniko executes — Kaniko reads the real Dockerfile from inside the // is NOT what Kaniko executes — Kaniko reads the real Dockerfile from inside the
// uploaded context (build/jobspec.go) — so this honestly documents the // uploaded context (build/jobspec.go) — so this honestly documents the
+56
View File
@@ -1,8 +1,10 @@
package submit package submit
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"strings" "strings"
"testing" "testing"
@@ -50,6 +52,14 @@ func (f *fakeBlobs) Exists(_ context.Context, id string) (bool, error) {
return ok, nil return ok, nil
} }
func (f *fakeBlobs) Open(_ context.Context, id string) (io.ReadCloser, error) {
b, ok := f.stored[id]
if !ok {
return nil, fmt.Errorf("%w: no blob for %s", ErrBlobNotFound, id)
}
return io.NopCloser(bytes.NewReader(b)), nil
}
// testNow is the frozen clock for hermetic assertions. // testNow is the frozen clock for hermetic assertions.
var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
@@ -217,6 +227,52 @@ func TestCreatePendingDoesNotBuild(t *testing.T) {
} }
} }
// With a ContextBaseURL the derived ref is the internal-face URL the build Pod's
// fetch initContainer dials — not a filesystem path it could never read across
// namespaces. OpenContext then serves whatever the Blobs transport stored.
func TestContextRefIsFetchURLAndOpenContextServesIt(t *testing.T) {
m, _, _ := newManager()
m.ContextBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081/"
m.Blobs = newFakeBlobs()
ctx := context.Background()
sub, err := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if err != nil {
t.Fatalf("Create: %v", err)
}
want := "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context"
if sub.ContextRef != want {
t.Fatalf("context_ref = %q, want the internal fetch URL %q", sub.ContextRef, want)
}
// Before any upload the read path reports not-found (the route's 404).
if _, err := m.OpenContext(ctx, sub.ID); !errors.Is(err, ErrBlobNotFound) {
t.Fatalf("OpenContext before upload = %v, want ErrBlobNotFound", err)
}
payload := "\x1f\x8b\x08\x00payload"
if _, err := m.UploadContext(ctx, sub.ID, "user-1", strings.NewReader(payload)); err != nil {
t.Fatalf("UploadContext: %v", err)
}
rc, err := m.OpenContext(ctx, sub.ID)
if err != nil {
t.Fatalf("OpenContext: %v", err)
}
defer rc.Close()
got, _ := io.ReadAll(rc)
if string(got) != payload {
t.Fatalf("OpenContext served %q, want %q", got, payload)
}
}
// No upload transport ⇒ no readable blob: the route reports the same 503 the
// upload endpoint does, rather than a misleading 404.
func TestOpenContextWithoutTransportIsUnavailable(t *testing.T) {
m, _, _ := newManager()
if _, err := m.OpenContext(context.Background(), "sub-1"); !errors.Is(err, ErrUploadsUnavailable) {
t.Fatalf("OpenContext with nil Blobs = %v, want ErrUploadsUnavailable", err)
}
}
func TestCreateValidation(t *testing.T) { func TestCreateValidation(t *testing.T) {
cases := []struct { cases := []struct {
name string name string