diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 23eb9a3..726f1a7 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -18,6 +18,7 @@ import ( "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/mail" + "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/passkey" "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 // holds DB credentials — felis-api owns the PG store and admits scanned // 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{ Store: build.NewPGStore(drv.DB()), - Jobs: build.NewK8sJobs(cl, buildConfig(cfg)), - Config: buildConfig(cfg), + Jobs: build.NewK8sJobs(cl, buildCfg), + Config: buildCfg, } // 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 two backends the setup wizard chooses between. A local path wires // LocalContextStore (the mounted uploads PVC); an s3:// base wires - // S3ContextStore when its credentials resolve. Either way the store's target is - // derived from the SAME config field the context ref uses, so the blob lands - // exactly where Kaniko's --context points. Anything else — or an s3:// base with - // no credentials configured — leaves Blobs nil so POST + // S3ContextStore when its credentials resolve. 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 - // when its PVC is not supplied. (Letting the sandboxed Kaniko build Pod READ the - // context — PVC mount for local, creds+egress for S3 — is a separate deployment - // integration.) + // when its PVC is not supplied. + // + // 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 var blobs submit.Blobs 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) } submissions := &submit.Manager{ - Store: submit.NewPGStore(drv.DB()), - Builds: builder, - Registry: cfg.Registry.URL, - ContextStore: contextBase, - Blobs: blobs, + Store: submit.NewPGStore(drv.DB()), + Builds: builder, + Registry: cfg.Registry.URL, + ContextStore: contextBase, + ContextBaseURL: internalAPIBaseURL(), + Blobs: blobs, } // 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://". var uploadsSchemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) diff --git a/cmd/felis/fetchcontext.go b/cmd/felis/fetchcontext.go new file mode 100644 index 0000000..3263453 --- /dev/null +++ b/cmd/felis/fetchcontext.go @@ -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)) + } + } +} diff --git a/cmd/felis/fetchcontext_test.go b/cmd/felis/fetchcontext_test.go new file mode 100644 index 0000000..7cbc4b3 --- /dev/null +++ b/cmd/felis/fetchcontext_test.go @@ -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) + } +} diff --git a/cmd/felis/run.go b/cmd/felis/run.go index bdf0343..dc56de6 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -19,6 +19,7 @@ Commands: 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) 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 apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) 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, "backup": cmdBackup, "files": cmdFiles, + "fetch-context": cmdFetchContext, "manifests": cmdManifests, "apply": cmdApply, "setup": cmdSetup, diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 463d116..0c6eea9 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -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 // self-record its world_backups row; without the replica the Job's volume // 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{ 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, - naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"), + naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns"), 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 = append(secretOutcomes, outcomes...) diff --git a/cmd/felis/systemservers.go b/cmd/felis/systemservers.go index 53235d8..c2d05d2 100644 --- a/cmd/felis/systemservers.go +++ b/cmd/felis/systemservers.go @@ -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 // the token is safe (it simply does not authenticate) rather than broken. const ( - envAPIBaseURL = "FELIS_API_BASE_URL" + envAPIBaseURL = naming.EnvAPIBaseURL envRootDomain = "FELIS_ROOT_DOMAIN" envPanelHostname = "FELIS_PANEL_HOSTNAME" envLobbyServer = "FELIS_LOBBY_SERVER" @@ -471,26 +471,27 @@ func phaseOrPending(p v1alpha1.Phase) string { return string(p) } -// ensureSecretReplica copies one Secret from the control namespace into the minecraft -// namespace so a backend pod can mount it via secretKeyRef. A secretKeyRef is -// namespace-local, but the backends run in the minecraft namespace while the sources -// of truth live beside the control plane — so without this replica the operator's -// injected secretKeyRef would dangle and wedge the pod in CreateContainerConfigError. +// ensureSecretReplica copies one Secret from the control namespace into a workload +// namespace (minecraft — or the build namespace, whose fetch initContainer reads the +// context from the felis-api internal face with the same token) so a pod can mount it +// via secretKeyRef. A secretKeyRef is namespace-local, but those workloads do not run +// 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 -// authenticates the limbo plugin to the felis-api internal face) and the Velocity -// modern-forwarding secret (every backend — it is how a backend knows a login really -// came from the proxy, and so that the player's UUID is Mojang-verified rather than -// offline-derived). +// Two Secrets need it, for different reasons: the service token (the login limbo and +// the build Pod's context fetch — both authenticate to the felis-api internal face) +// and the Velocity modern-forwarding secret (every backend — it is how a backend knows +// a login really came from the proxy, and so that the player's UUID is Mojang-verified +// rather than offline-derived). // // 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 // 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 // carries no accidental GC owner or managed-by lineage. -func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label string) systemServerOutcome { - name := label + " (minecraft ns)" +func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label, where string) systemServerOutcome { + name := label + " (" + where + ")" validate := func(secret *corev1.Secret, location, skipped string) systemServerOutcome { if len(secret.Data[secretKey]) == 0 { return systemServerOutcome{name: name, skipped: fmt.Sprintf( diff --git a/cmd/felis/systemservers_test.go b/cmd/felis/systemservers_test.go index d4e86b8..62a0988 100644 --- a/cmd/felis/systemservers_test.go +++ b/cmd/felis/systemservers_test.go @@ -156,7 +156,7 @@ func TestEnsureSecretReplica(t *testing.T) { } replicate := func(cl client.Client, controlNS, mcNS string) systemServerOutcome { 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) { diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 9cce91c..6eecd26 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -2148,6 +2148,10 @@ deploy_bundle() { --from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \ --dry-run=client -o yaml | kube apply -f - 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` # 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 diff --git a/docs/deferred-seams.md b/docs/deferred-seams.md index cc41e29..0f88aed 100644 --- a/docs/deferred-seams.md +++ b/docs/deferred-seams.md @@ -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 `Scheduled` component degrades to a notify, so no path can currently claim an apply is under way. -- `internal/submit/blobstore.go:40` — the uploads PVC is mounted into felis-api but - not into the Kaniko build Pod, so a submitted context is durable at the derived - location without yet being readable by the build that consumes it. Audited - 2026-09-22: this is not a missing volume line — a PVC cannot cross namespaces - (uploads live in the control namespace; build Pods run in `felis-build`), so the - fix is a transport, not a mount. The `s3://` lane does not close it either: the - build Job carries no AWS credentials (no env, and the weak SA's token is - deliberately unmounted, so no IAM either). Options on the table: (a) object - storage with credentials plumbed into the build Pod as a per-build Secret plus an - egress allowance; (b) a context-handoff PVC/Job pair in `felis-build` fed from - the API side; (c) a node-local path both sides mount (single-node only, and it - hands an arbitrary Dockerfile a filesystem view — needs its own security review). - Kaniko/Trivy images are external-only by default; `[registry] kaniko_image / - trivy_image / build_cpu_limit / build_mem_limit` now override them for mirrored - or air-gapped installs. +- `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot + cross namespaces, so the transport went through the API instead of a mount: the + derived context ref is now the internal-face URL + (`/api/v1/internal/submissions/{id}/context`, service-token gated), the build + Job's `context-fetch` initContainer streams it with `felis fetch-context` and + extracts under a zip-slip guard into a size-limited emptyDir, and Kaniko builds + `--context=/context`. The token reaches the build namespace through the same + Secret-replica mechanism the login gate uses (bootstrap + `felis setup`), and the + build egress lock allows exactly the control namespace on the internal port. + Uniform for local and s3:// stores — neither hands the sandboxed build Pod a + filesystem view or object-store credentials. Kaniko/Trivy images are + external-only by default; `[registry] kaniko_image / trivy_image / + build_cpu_limit / build_mem_limit` override them for mirrored or air-gapped + installs, and Trivy's vulnerability DB download needs the same treatment (a + `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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a626616..7f50f6d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -607,6 +607,34 @@ paths: '404': $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: post: tags: [servers-internal] diff --git a/internal/api/api.go b/internal/api/api.go index 33f31dc..9252c7c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -271,6 +271,9 @@ func (a *API) internalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, {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}/join-event", h: a.handleJoinEvent}, // Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls diff --git a/internal/api/submissions.go b/internal/api/submissions.go index 9b8bb47..f1a4942 100644 --- a/internal/api/submissions.go +++ b/internal/api/submissions.go @@ -42,6 +42,11 @@ type SubmissionService interface { // Reject is the admin's other verdict: pending_review -> rejected with a // required reason; it starts no build. 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 @@ -211,6 +216,8 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, submit.ErrAlreadyReviewed): writeError(w, r, newError(http.StatusConflict, "already_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): writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable", "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. var _ SubmissionService = (*submit.Manager)(nil) diff --git a/internal/api/submissions_test.go b/internal/api/submissions_test.go index 283315a..37233fd 100644 --- a/internal/api/submissions_test.go +++ b/internal/api/submissions_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "testing" "felis.lolicon.best/internal/submit" @@ -35,6 +36,9 @@ type fakeSubmissions struct { rejectedBy string rejectReas string rejectErr error + openedID string + openBody string + openErr 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 } +// 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 // app tier — /me/submissions). user@example.net / .test are deliberately not the // deployment domain. @@ -396,3 +410,46 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) { 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) + } + }) +} diff --git a/internal/build/build.go b/internal/build/build.go index b1bae1a..5135b42 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -215,6 +215,11 @@ type Config struct { // RegistryURL is the internal registry the build pushes to and Trivy scans // (spec §17). Image refs are validated to be under it. RegistryURL string + // FelisImage is the platform image whose `fetch-context` entrypoint streams a + // submission's context from the internal face into the build Pod. Required + // only when a build's ContextRef is an http(s) URL (the submit lane's derived + // shape); an install that never builds user submissions can leave it empty. + FelisImage string // KanikoImage / TrivyImage are the executor images. KanikoImage string TrivyImage string @@ -355,6 +360,7 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams { Namespace: cfg.Namespace, ServiceAccount: cfg.ServiceAccount, RegistryURL: cfg.RegistryURL, + FelisImage: cfg.FelisImage, KanikoImage: cfg.KanikoImage, TrivyImage: cfg.TrivyImage, Deadline: cfg.Deadline, diff --git a/internal/build/jobspec.go b/internal/build/jobspec.go index 1539d9f..49ea618 100644 --- a/internal/build/jobspec.go +++ b/internal/build/jobspec.go @@ -2,8 +2,10 @@ package build import ( "fmt" + "strings" "time" + "felis.lolicon.best/internal/naming" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -32,8 +34,23 @@ const ( const ( ContainerKaniko = "kaniko" ContainerTrivy = "trivy" + // ContainerFetch is the initContainer that pulls a submission's build context + // from the felis-api internal face and extracts it into the shared emptyDir. + // It exists only for an http(s) ContextRef (see BuildJob); a ref Kaniko can + // read natively (s3://) or a pre-mounted path renders no such container. + ContainerFetch = "context-fetch" + + // contextVolume/contextMountPath carry a fetched build context: the fetch + // initContainer writes the extracted tree there, Kaniko reads it read-only. + contextVolume = "context" + contextMountPath = "/context" ) +// contextSizeLimit bounds the extracted (attacker-controlled) context tree so a +// tarball bomb wedges the build pod instead of the node's disk. The compressed +// upload is capped at 1 GiB by the submit lane; 4 GiB leaves expansion room. +var contextSizeLimit = resource.MustParse("4Gi") + // JobParams are the rendered inputs to a build Job. They are derived from a // Build + Config by the Builder; jobspec is a pure function of them so the // security-critical Job shape is unit-tested without a cluster. @@ -44,11 +61,14 @@ type JobParams struct { Namespace string ServiceAccount string RegistryURL string - KanikoImage string - TrivyImage string - Deadline time.Duration - CPULimit string - MemLimit string + // FelisImage runs the context-fetch initContainer (the felis binary's + // fetch-context entrypoint). Required when ContextRef is an http(s) URL. + FelisImage string + KanikoImage string + TrivyImage string + Deadline time.Duration + CPULimit string + MemLimit string } // BuildJobName is the deterministic Job name for a build id. @@ -100,21 +120,75 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, } + // The context Kaniko reads. An http(s) ref (the submit lane's derived ref: the + // API streams the blob on its internal face, because the build Pod can neither + // mount the control-plane uploads PVC across namespaces nor hold object-store + // credentials) is first fetched into a shared emptyDir; a ref Kaniko can read + // in place (s3://, or a path an installer pre-mounted) passes through untouched. + contextPath := p.ContextRef + initContainers := []corev1.Container{} + var kanikoMounts []corev1.VolumeMount + var podVolumes []corev1.Volume + if isHTTPContextRef(p.ContextRef) { + if p.FelisImage == "" { + return nil, fmt.Errorf("build: context ref %q needs FelisImage for the fetch initContainer", p.ContextRef) + } + contextPath = contextMountPath + fetch := corev1.Container{ + Name: ContainerFetch, + Image: p.FelisImage, + Args: []string{ + "fetch-context", + "--url=" + p.ContextRef, + "--out=" + contextMountPath, + }, + // The internal face is service-token gated, and the token is read from a + // Secret the installer materializes in THIS namespace (secretKeyRef is + // namespace-local). It is mounted into this initContainer only: the Kaniko + // container executes the untrusted Dockerfile and must never hold it, and + // pod containers share neither environment nor PID namespace. + Env: []corev1.EnvVar{{ + Name: "FELIS_SERVICE_TOKEN", + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName}, + Key: naming.ServiceTokenSecretKey, + }}, + }}, + VolumeMounts: []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath}}, + Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits}, + SecurityContext: sec, + } + initContainers = append(initContainers, fetch) + kanikoMounts = []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath, ReadOnly: true}} + podVolumes = []corev1.Volume{{ + Name: contextVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{ + // The extracted tree is attacker-controlled; bound it so a tarball + // bomb wedges THIS pod (admitted failure) instead of filling the + // node's disk. The compressed upload is capped at 1 GiB by the + // submit lane, and 4 GiB leaves room for a typical expansion. + SizeLimit: sizeLimitPtr(), + }}, + }} + } + kaniko := corev1.Container{ Name: ContainerKaniko, Image: p.KanikoImage, Args: []string{ "--dockerfile=Dockerfile", - "--context=" + p.ContextRef, + "--context=" + contextPath, "--destination=" + p.ImageRef, // The internal registry is in-cluster only and may serve plain HTTP; // it is never a public ingress (spec §17). "--insecure", "--skip-tls-verify", }, + VolumeMounts: kanikoMounts, Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits}, SecurityContext: sec, } + initContainers = append(initContainers, kaniko) trivy := corev1.Container{ Name: ContainerTrivy, @@ -147,8 +221,9 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { RestartPolicy: corev1.RestartPolicyNever, ServiceAccountName: p.ServiceAccount, AutomountServiceAccountToken: boolPtr(false), - InitContainers: []corev1.Container{kaniko}, + InitContainers: initContainers, Containers: []corev1.Container{trivy}, + Volumes: podVolumes, }, }, }, @@ -156,11 +231,24 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { return job, nil } +// isHTTPContextRef reports whether ref is an http(s) URL — the shape the submit +// lane derives when the API is the blob transport — i.e. a context only the +// fetch initContainer can turn into a local path for Kaniko. +func isHTTPContextRef(ref string) bool { + return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") +} + // NetPolParams parameterises the build-namespace egress lock. type NetPolParams struct { Namespace string RegistryNamespace string RegistryPort int32 + // ControlNamespace and APIPort are where the felis-api internal face lives: + // the fetch initContainer's only egress besides DNS and the registry. Both + // defaults (felis, 8081) match platform.DefaultControlNamespace and the + // internal listener, so an unset Params is still the safe shape. + ControlNamespace string + APIPort int32 // PackageSourceCIDRs is an optional, explicit allowlist of external package // mirrors (spec §16: egress 仅 registry + 包源). Empty means the most // locked-down default — no internet egress at all (默认拒外网). @@ -177,10 +265,19 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy { if port == 0 { port = 5000 } + controlNS := p.ControlNamespace + if controlNS == "" { + controlNS = "felis" + } + apiPort := p.APIPort + if apiPort == 0 { + apiPort = 8081 + } dnsUDP := corev1.ProtocolUDP dnsTCP := corev1.ProtocolTCP dns53 := intstr.FromInt32(53) regPort := intstr.FromInt32(port) + ctxPort := intstr.FromInt32(apiPort) egress := []networkingv1.NetworkPolicyEgressRule{ // DNS resolution: port-restricted to 53, so this is not an open-internet @@ -203,6 +300,21 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy { {Protocol: &dnsTCP, Port: ®Port}, }, }, + // felis-api's internal face, where the fetch initContainer streams the + // submission's build context from. Without this rule the build Pod could + // not read the context and every user build would fail in its first init + // step — the default-deny here is exactly why the transport had to be + // planned, not assumed. + { + To: []networkingv1.NetworkPolicyPeer{{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"kubernetes.io/metadata.name": controlNS}, + }, + }}, + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &dnsTCP, Port: &ctxPort}, + }, + }, } // Explicit package-mirror CIDRs, when configured. No CIDR ⇒ no internet. for _, cidr := range p.PackageSourceCIDRs { @@ -281,4 +393,12 @@ func resourceLimits(cpu, mem string) (corev1.ResourceList, error) { func boolPtr(b bool) *bool { return &b } func int32Ptr(i int32) *int32 { return &i } + +// sizeLimitPtr returns a copy of contextSizeLimit for a VolumeSource (the API +// object only ever gets serialized, but a shared pointer across rendered Jobs +// invites accidental aliasing). +func sizeLimitPtr() *resource.Quantity { + q := contextSizeLimit + return &q +} func int64Ptr(i int64) *int64 { return &i } diff --git a/internal/build/jobspec_test.go b/internal/build/jobspec_test.go index e66b290..00c7f99 100644 --- a/internal/build/jobspec_test.go +++ b/internal/build/jobspec_test.go @@ -177,6 +177,132 @@ func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) { if !egressAllowsPort(np, 53) { t.Error("egress must allow DNS (port 53)") } + // The context fetch: build Pods stream submissions from the control + // namespace's internal face (defaults: felis + 8081). + if !egressAllowsNamespace(np, "felis") { + t.Error("egress must allow the control namespace (context fetch)") + } + if !egressAllowsPort(np, 8081) { + t.Error("egress must allow the internal face's port (8081)") + } +} + +// An http(s) context ref — the submit lane's derived shape — must render the +// fetch initContainer ahead of Kaniko, with the service token mounted ONLY into +// that container, and hand Kaniko the extracted local directory. +func TestBuildJobFetchesHTTPContext(t *testing.T) { + p := sampleJobParams() + p.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-abc/context" + p.FelisImage = "felis:test" + job, err := BuildJob(p) + if err != nil { + t.Fatalf("BuildJob: %v", err) + } + inits := job.Spec.Template.Spec.InitContainers + if len(inits) != 2 || inits[0].Name != ContainerFetch || inits[1].Name != ContainerKaniko { + t.Fatalf("initContainers = %v, want [%s %s]", initNames(inits), ContainerFetch, ContainerKaniko) + } + fetch, kaniko := inits[0], inits[1] + if fetch.Image != p.FelisImage { + t.Errorf("fetch image = %q, want the platform image %q", fetch.Image, p.FelisImage) + } + if !hasArg(fetch.Args, "fetch-context") || !hasArg(fetch.Args, "--url="+p.ContextRef) || + !hasArg(fetch.Args, "--out="+contextMountPath) { + t.Errorf("fetch args = %v, want fetch-context --url=%s --out=%s", fetch.Args, p.ContextRef, contextMountPath) + } + // The token comes from the namespace-local Secret and is mounted into the + // fetch container only — never into Kaniko, which executes the untrusted + // Dockerfile. + var fetchToken *corev1.EnvVar + for i := range fetch.Env { + if fetch.Env[i].Name == "FELIS_SERVICE_TOKEN" { + fetchToken = &fetch.Env[i] + } + } + if fetchToken == nil || fetchToken.ValueFrom == nil || fetchToken.ValueFrom.SecretKeyRef == nil { + t.Fatalf("fetch container must read FELIS_SERVICE_TOKEN from a secretKeyRef, got %#v", fetchToken) + } + if fetchToken.Value != "" { + t.Error("fetch container must not carry a literal token") + } + if len(kaniko.Env) != 0 { + t.Errorf("kaniko must carry no env (especially no token), got %v", kaniko.Env) + } + if !hasArg(kaniko.Args, "--context="+contextMountPath) { + t.Errorf("kaniko context = %v, want the fetched local dir %s", kaniko.Args, contextMountPath) + } + // The shared emptyDir must exist, be bounded, and be read-only to Kaniko. + var ctxVol *corev1.Volume + for i := range job.Spec.Template.Spec.Volumes { + if job.Spec.Template.Spec.Volumes[i].Name == contextVolume { + ctxVol = &job.Spec.Template.Spec.Volumes[i] + } + } + if ctxVol == nil || ctxVol.EmptyDir == nil || ctxVol.EmptyDir.SizeLimit == nil { + t.Fatalf("context volume must be a size-limited emptyDir, got %#v", ctxVol) + } + mountedRO := false + for _, m := range kaniko.VolumeMounts { + if m.Name == contextVolume && m.MountPath == contextMountPath && m.ReadOnly { + mountedRO = true + } + } + if !mountedRO { + t.Errorf("kaniko must mount the context read-only at %s, got %v", contextMountPath, kaniko.VolumeMounts) + } +} + +// Without the platform image the fetch initContainer cannot run, so rendering an +// http(s) context must fail loudly at Job-creation time, not with an ImagePull +// error at 3am. +func TestBuildJobHTTPContextNeedsFelisImage(t *testing.T) { + p := sampleJobParams() + p.ContextRef = "https://example.invalid/sub-abc/context" + if _, err := BuildJob(p); err == nil { + t.Fatal("http(s) context without FelisImage must fail to render") + } +} + +// A ref Kaniko reads natively (or an installer pre-mounted) must NOT grow the +// fetch initContainer: the transport is for http(s) only. +func TestBuildJobNativeContextNeedsNoFetch(t *testing.T) { + p := sampleJobParams() + p.ContextRef = "s3://bucket/prefix/context.tar.gz" + job, err := BuildJob(p) + if err != nil { + t.Fatalf("BuildJob: %v", err) + } + if len(job.Spec.Template.Spec.InitContainers) != 1 || job.Spec.Template.Spec.InitContainers[0].Name != ContainerKaniko { + t.Errorf("a native ref must render just kaniko, got %v", initNames(job.Spec.Template.Spec.InitContainers)) + } + if len(job.Spec.Template.Spec.Volumes) != 0 { + t.Errorf("a native ref must render no context volume, got %v", job.Spec.Template.Spec.Volumes) + } +} + +func initNames(cs []corev1.Container) []string { + names := make([]string, 0, len(cs)) + for _, c := range cs { + names = append(names, c.Name) + } + return names +} + +// An explicit control namespace/port override must reach the egress rule (a +// renamed control namespace otherwise silently blocks every context fetch). +func TestBuildNetworkPolicyHonoursControlNamespaceOverride(t *testing.T) { + np := BuildNetworkPolicy(NetPolParams{ + Namespace: "felis-build", + RegistryNamespace: "felis-system", + ControlNamespace: "control-plane", + APIPort: 9081, + }) + if !egressAllowsNamespace(np, "control-plane") { + t.Error("egress must allow the overridden control namespace") + } + if !egressAllowsPort(np, 9081) { + t.Error("egress must allow the overridden api port") + } } // With no package-source CIDRs configured, there must be zero IPBlock egress — diff --git a/internal/naming/naming.go b/internal/naming/naming.go index e6e60ac..7cb6835 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -56,6 +56,10 @@ const ( const ( ServiceTokenSecretName = "felis-service-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 diff --git a/internal/platform/bundle.go b/internal/platform/bundle.go index 9a5713b..b465b4e 100644 --- a/internal/platform/bundle.go +++ b/internal/platform/bundle.go @@ -78,9 +78,12 @@ func Objects(p Params) []Object { // Build-namespace egress lock (reused from internal/build; TypeMeta stamped). buildNP := build.BuildNetworkPolicy(build.NetPolParams{ - Namespace: p.BuildNamespace, - RegistryNamespace: p.RegistryNamespace, - RegistryPort: p.RegistryPort, + Namespace: p.BuildNamespace, + RegistryNamespace: p.RegistryNamespace, + RegistryPort: p.RegistryPort, + ControlNamespace: p.ControlNamespace, + // The internal face's port, single-sourced with the api Deployment below. + APIPort: apiInternalPort, PackageSourceCIDRs: p.PackageSourceCIDRs, }) buildNP.TypeMeta = metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"} diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 06fd03b..72a6d1b 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -274,6 +274,10 @@ func APIDeployment(p Params) *appsv1.Deployment { }, }, {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 != "" { env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC}) diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index b2fcef8..102df9a 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -179,6 +179,11 @@ func TestAPIDeployment_Wiring(t *testing.T) { if v := envValue(c.Env, "FELIS_IMAGE"); 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. tok := envVar(c.Env, "FELIS_SERVICE_TOKEN") if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil { diff --git a/internal/submit/blobstore.go b/internal/submit/blobstore.go index abb698d..ff20c85 100644 --- a/internal/submit/blobstore.go +++ b/internal/submit/blobstore.go @@ -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. var _ Blobs = (*LocalContextStore)(nil) diff --git a/internal/submit/blobstore_test.go b/internal/submit/blobstore_test.go index c09feae..fe36ed6 100644 --- a/internal/submit/blobstore_test.go +++ b/internal/submit/blobstore_test.go @@ -2,6 +2,8 @@ package submit import ( "context" + "errors" + "io" "os" "path/filepath" "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) { base := t.TempDir() s := &LocalContextStore{Base: base} diff --git a/internal/submit/s3store.go b/internal/submit/s3store.go index 372491c..d5d1852 100644 --- a/internal/submit/s3store.go +++ b/internal/submit/s3store.go @@ -14,11 +14,30 @@ import ( ) // 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 -// and not-found handling are unit-verifiable without a live bucket. +// satisfies it through minioStoreClient, and a fake satisfies it in tests — so the +// store's key derivation and not-found handling are unit-verifiable without a live +// bucket. type s3Client interface { 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) + 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 @@ -27,16 +46,17 @@ type s3Client interface { // 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 -// object written here lands at exactly s3://{bucket}/{prefix}/{id}/context.tar.gz — -// the ref deriveContextRef records and Kaniko's native s3:// --context reads. +// object written here lands at exactly s3://{bucket}/{prefix}/{id}/context.tar.gz. // 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 // never touch felis.toml. // -// Kaniko reading the S3 context at build time needs its own credentials + egress -// on the sandboxed build Job — a separate deployment integration, exactly like the -// LocalContextStore PVC mount. This transport only makes the upload durable at the -// derived location. +// The sandboxed build Job never needs S3 credentials of its own: the api reads the +// object back here (Open) and streams it over the internal face, which is the +// transport every in-cluster build uses (Manager.ContextBaseURL). Only a +// 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 { client s3Client bucket string @@ -79,7 +99,7 @@ func NewS3ContextStore(cfg S3StoreConfig) (*S3ContextStore, error) { if err != nil { 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: @@ -167,6 +187,35 @@ func (s *S3ContextStore) Exists(ctx context.Context, id string) (bool, error) { 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 // implementations: a GET-shaped NoSuchKey code or a bare 404 from the HEAD that // StatObject issues. diff --git a/internal/submit/s3store_test.go b/internal/submit/s3store_test.go index defb5a9..9c99a39 100644 --- a/internal/submit/s3store_test.go +++ b/internal/submit/s3store_test.go @@ -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} } +// 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) { 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) { fake := &fakeS3{} s := &S3ContextStore{client: fake, bucket: "b", prefix: ""} diff --git a/internal/submit/submit.go b/internal/submit/submit.go index a92bf69..7d6fc6f 100644 --- a/internal/submit/submit.go +++ b/internal/submit/submit.go @@ -37,7 +37,8 @@ // 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 // 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 // §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 // integration is not wired. 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. @@ -176,11 +181,12 @@ type Builds interface { // 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 -// deriveContextRef points Kaniko at. It is the piece the package doc calls a -// "separate, deferred transport": creation only derives and records the ref, and -// the bytes behind it arrive through Put here. It is an interface so the Manager -// is unit-tested against an in-memory fake; the production implementation is the -// filesystem-backed LocalContextStore. +// deriveContextRef points Kaniko at. Creation only derives and records the ref; +// the bytes behind it arrive through Put here, and the build Pod reads them back +// through Open (the manager exposes it as OpenContext, which the API's internal +// context route serves). It is an interface so the Manager is unit-tested against +// 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 // write target is as platform-pinned as the derived ref itself. Put stores (and @@ -190,6 +196,11 @@ type Builds interface { type Blobs interface { Put(ctx context.Context, id string, r io.Reader) (int64, 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 @@ -210,6 +221,15 @@ type Manager struct { // "s3://felis-user-uploads" (an object store) or a local uploads PVC path. The // derived context ref is {ContextStore}/{id}/context.tar.gz. 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 // context ref. When nil (a store with no implemented transport, e.g. an // 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 // this pinned, id-namespaced location (placed by the upload transport) varies. 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) } +// 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 // is NOT what Kaniko executes — Kaniko reads the real Dockerfile from inside the // uploaded context (build/jobspec.go) — so this honestly documents the diff --git a/internal/submit/submit_test.go b/internal/submit/submit_test.go index a4844cb..e52ef08 100644 --- a/internal/submit/submit_test.go +++ b/internal/submit/submit_test.go @@ -1,8 +1,10 @@ package submit import ( + "bytes" "context" "errors" + "fmt" "io" "strings" "testing" @@ -50,6 +52,14 @@ func (f *fakeBlobs) Exists(_ context.Context, id string) (bool, error) { 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. 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) { cases := []struct { name string