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
+36
-14
@@ -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 {
|
||||||
@@ -186,11 +196,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "felis api: user-uploads context %q is neither a local path nor an s3:// base — modpack upload transport disabled (POST /api/v1/me/submissions/{id}/context returns 503)\n", contextBase)
|
fmt.Fprintf(stderr, "felis api: user-uploads context %q is neither a local path nor an s3:// base — modpack upload transport disabled (POST /api/v1/me/submissions/{id}/context returns 503)\n", contextBase)
|
||||||
}
|
}
|
||||||
submissions := &submit.Manager{
|
submissions := &submit.Manager{
|
||||||
Store: submit.NewPGStore(drv.DB()),
|
Store: submit.NewPGStore(drv.DB()),
|
||||||
Builds: builder,
|
Builds: builder,
|
||||||
Registry: cfg.Registry.URL,
|
Registry: cfg.Registry.URL,
|
||||||
ContextStore: contextBase,
|
ContextStore: contextBase,
|
||||||
Blobs: blobs,
|
ContextBaseURL: internalAPIBaseURL(),
|
||||||
|
Blobs: blobs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore subsystem (spec §7): the weak-SA restore Job mounts the target
|
// Restore subsystem (spec §7): the weak-SA restore Job mounts the target
|
||||||
@@ -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+.-]*://`)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+127
-7
@@ -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,11 +61,14 @@ type JobParams struct {
|
|||||||
Namespace string
|
Namespace string
|
||||||
ServiceAccount string
|
ServiceAccount string
|
||||||
RegistryURL string
|
RegistryURL string
|
||||||
KanikoImage string
|
// FelisImage runs the context-fetch initContainer (the felis binary's
|
||||||
TrivyImage string
|
// fetch-context entrypoint). Required when ContextRef is an http(s) URL.
|
||||||
Deadline time.Duration
|
FelisImage string
|
||||||
CPULimit string
|
KanikoImage string
|
||||||
MemLimit string
|
TrivyImage string
|
||||||
|
Deadline time.Duration
|
||||||
|
CPULimit string
|
||||||
|
MemLimit string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildJobName is the deterministic Job name for a build id.
|
// 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"}},
|
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: ®Port},
|
{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.
|
// 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 }
|
||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -78,9 +78,12 @@ func Objects(p Params) []Object {
|
|||||||
|
|
||||||
// Build-namespace egress lock (reused from internal/build; TypeMeta stamped).
|
// Build-namespace egress lock (reused from internal/build; TypeMeta stamped).
|
||||||
buildNP := build.BuildNetworkPolicy(build.NetPolParams{
|
buildNP := build.BuildNetworkPolicy(build.NetPolParams{
|
||||||
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"}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: ""}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user