Unverified Commit 598f3d31 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(submit): local + S3 backends for modpack upload contexts, installer-selectable

parent 191640c3
Loading
Loading
Loading
Loading
+78 −5
Changes for cmd/felis/api.go: 78 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -7,7 +7,9 @@ import (
	"io"
	"net/http"
	"os"
	"regexp"
	goruntime "runtime"
	"strings"
	"time"

	"felis.lolicon.best/internal/api"
@@ -16,6 +18,7 @@ import (
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/panel"
	"felis.lolicon.best/internal/passkey"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/restore"
	"felis.lolicon.best/internal/store"
	"felis.lolicon.best/internal/submit"
@@ -104,15 +107,43 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
	// the SAME Trivy-gated Builder runs as for an admin's direct build. Registry
	// MUST match the Builder's RegistryURL (cfg.Registry.URL) — both are wired from
	// the one field here so the lane's pre-CAS validate and the Builder's Submit
	// can never disagree about the push target. The blob upload transport that
	// populates the derived context ref is deferred (INTEGRATION-ONLY): the
	// create→approve→reject state machine is real Postgres truth, but a real
	// Kaniko context pull needs that transport in place.
	// can never disagree about the push target.
	//
	// 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
	// /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.)
	contextBase := cfg.Registry.UserUploadsContext
	var blobs submit.Blobs
	switch {
	case isLocalUploadsPath(contextBase):
		// Normalize a file:// URL to the plain path ONCE and feed it to BOTH the
		// derived ref (ContextStore) and the store (Base), so the recorded
		// context_ref and the on-disk write location can never diverge.
		contextBase = strings.TrimPrefix(contextBase, "file://")
		blobs = &submit.LocalContextStore{Base: contextBase}
	case strings.HasPrefix(strings.ToLower(contextBase), "s3://"):
		if s3, err := newS3UploadsStore(cfg.Registry); err != nil {
			fmt.Fprintf(stderr, "felis api: S3 user-uploads store not configured (%v) — modpack upload transport disabled (POST /api/v1/me/submissions/{id}/context returns 503)\n", err)
		} else {
			blobs = s3
		}
	default:
		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: cfg.Registry.UserUploadsContext,
		ContextStore: contextBase,
		Blobs:        blobs,
	}

	// Restore subsystem (spec §7): the weak-SA restore Job mounts the target
@@ -280,6 +311,48 @@ func buildConfig(cfg *config.Config) build.Config {
	}
}

// uploadsSchemeRE matches a leading URL scheme like "s3://" or "gs://".
var uploadsSchemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)

// isLocalUploadsPath reports whether the user-uploads context base is a local
// filesystem path (a bare path or a file:// URL), i.e. one LocalContextStore can
// write to. An s3:// base routes to newS3UploadsStore instead; any other scheme
// has no implemented transport, so its uploads are left disabled (503).
func isLocalUploadsPath(base string) bool {
	if strings.HasPrefix(base, "file://") {
		return true
	}
	return !uploadsSchemeRE.MatchString(base)
}

// newS3UploadsStore builds the S3 blob transport for an s3:// user_uploads_context.
// The bucket + key prefix come from the base itself; the endpoint/region come from
// [registry.s3]; and the credentials are read from the environment variables named
// by access_key_ref / secret_key_ref (defaulting to the fixed env names the
// felis-api Deployment injects from the felis-uploads-s3 Secret). Any missing piece
// is an error, so the caller leaves Blobs nil and the upload endpoint returns 503
// rather than pretending it can persist a file.
func newS3UploadsStore(reg config.RegistryConfig) (submit.Blobs, error) {
	accessRef, secretRef := reg.S3.AccessKeyRef, reg.S3.SecretKeyRef
	if accessRef == "" {
		accessRef = platform.UploadsS3AccessKeyEnv
	}
	if secretRef == "" {
		secretRef = platform.UploadsS3SecretKeyEnv
	}
	accessKey, secretKey := os.Getenv(accessRef), os.Getenv(secretRef)
	if accessKey == "" || secretKey == "" {
		return nil, fmt.Errorf("credentials env %s/%s are empty", accessRef, secretRef)
	}
	return submit.NewS3ContextStore(submit.S3StoreConfig{
		Base:      reg.UserUploadsContext,
		Endpoint:  reg.S3.Endpoint,
		Region:    reg.S3.Region,
		AccessKey: accessKey,
		SecretKey: secretKey,
	})
}

// restoreConfig projects felis.toml + the deployment-supplied image and backup
// PVC onto the restore subsystem config (spec §7). The runtime identity, mount
// roots, resource limits, and weak SA fall back to the restore package's
+4 −0
Changes for cmd/felis/breakglass.go: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -532,6 +532,10 @@ type breakGlassResult struct {
	panelHostname     string
	reverseProxyGuide string

	// storage backend outcome
	storageMethod storageMethod
	storageDetail string

	// Cloudflare-specific edge detail (set only when connectMethod is Cloudflare)
	edgeConfigured    bool
	edgeAud           string
+43 −1
Changes for cmd/felis/tui_root.go: 43 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -81,6 +81,11 @@ func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } }
// connection chooser.
type reconfigureConnectMsg struct{}

// reconfigureStorageMsg is sent from the summary/status screen to re-enter the
// storage chooser — the supported way to fix a mistyped S3 detail or switch
// backends after install, without hand-editing felis.toml and the Secret.
type reconfigureStorageMsg struct{}

// ---- rootModel: top-level session ----

type wizardStage int
@@ -89,6 +94,7 @@ const (
	stagePreflight wizardStage = iota
	stageOwner
	stageConnect
	stageStorage
	stageSummary
	// stageMenu is the break-glass operation menu. It is appended last so the
	// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
@@ -101,7 +107,7 @@ const (
// and the post-install wizard owns cells 1–4. Defining it once keeps the two
// programs' breadcrumbs identical so the rail reads as a single continuous bar
// rather than restarting when the wizard takes over.
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Done"}
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Storage", "Done"}

type rootModel struct {
	ctx context.Context
@@ -113,6 +119,12 @@ type rootModel struct {
	// (read-only), or -1 when the live screen is in front. Driven by ←/→.
	reviewing int

	// reconfiguringConnect is set while re-entering the connection chooser from the
	// summary's "change connection" (or the re-run status screen). In that flow the
	// storage backend is already configured, so completing the connection returns
	// straight to the summary instead of forcing the operator back through storage.
	reconfiguringConnect bool

	width  int
	height int

@@ -229,13 +241,36 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

	case connectResultMsg:
		m.applyConnectResult(msg)
		if m.reconfiguringConnect {
			// Changing only the connection — storage is already set, so skip it.
			m.reconfiguringConnect = false
			return m.showSummary()
		}
		m.stage = stageStorage
		return m.adopt(newStorageChooserModel(m.rootDomain, storageLocal, s3Inputs{}))

	case storageResultMsg:
		m.result.storageMethod = msg.method
		m.result.storageDetail = msg.detail
		return m.showSummary()

	case storageBackMsg:
		m.stage = stageStorage
		return m.adopt(newStorageChooserModel(m.rootDomain, storageLocal, s3Inputs{}))

	case reconfigureStorageMsg:
		// Fixing/switching storage after install: re-enter the chooser pre-selected on
		// the current backend, with the non-secret S3 fields pre-filled.
		method, prefill := currentStorageInputs()
		m.stage = stageStorage
		return m.adopt(newStorageChooserModel(m.rootDomain, method, prefill))

	case goBackMsg:
		m.stage = stageConnect
		return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))

	case reconfigureConnectMsg:
		m.reconfiguringConnect = true
		m.stage = stageConnect
		return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
	}
@@ -335,6 +370,12 @@ func (m *rootModel) reviewBody(stage int) string {
		if m.result.panelURL != "" {
			b.WriteString(tuiLabel.Render("panel     ") + m.result.panelURL)
		}
	case stageStorage:
		b.WriteString(tuiOK.Render("✓ Storage") + "\n")
		b.WriteString(tuiLabel.Render("backend   ") + storageMethodLabel(m.result.storageMethod) + "\n")
		if m.result.storageDetail != "" {
			b.WriteString(tuiHint.Render(m.result.storageDetail))
		}
	}
	b.WriteString("\n\n" + tuiHint.Render("read-only · ") + tuiLabel.Render("←/→") +
		tuiHint.Render(" walk steps · ") + tuiLabel.Render("esc") + tuiHint.Render(" back"))
@@ -449,6 +490,7 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
		ownerUsername: m.result.username,
		ownerPassword: m.result.displayPassword,
		accessLabel:   connectMethodLabel(m.result.connectMethod),
		storageLabel:  m.result.storageDetail,
		routedHosts:   routed,
		localHint:     m.result.connectMethod == connectLocal,
	})
+99 −6
Changes for cmd/felis/tui_root_test.go: 99 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -73,7 +73,7 @@ func TestRootSetupHappyPath(t *testing.T) {
		t.Fatalf("owner result not recorded: %+v", m.result)
	}

	// Reverse-proxy chosen → Summary, with the connection recorded.
	// Reverse-proxy chosen → Storage chooser, with the connection recorded.
	guide := "caddy config…"
	m = drive(t, m, connectResultMsg{
		method:        connectReverseProxy,
@@ -81,12 +81,11 @@ func TestRootSetupHappyPath(t *testing.T) {
		adminHostname: "admin.felis.example.com",
		guide:         guide,
	})
	if m.stage != stageSummary {
		t.Fatalf("after connect, stage = %v, want stageSummary", m.stage)
	if m.stage != stageStorage {
		t.Fatalf("after connect, stage = %v, want stageStorage", m.stage)
	}
	sum, ok := m.screen.(*summaryModel)
	if !ok {
		t.Fatalf("after connect, screen = %T, want *summaryModel", m.screen)
	if _, ok := m.screen.(*storageChooserModel); !ok {
		t.Fatalf("after connect, screen = %T, want *storageChooserModel", m.screen)
	}
	if !m.result.connectConfigured {
		t.Fatalf("connectConfigured not set")
@@ -97,6 +96,22 @@ func TestRootSetupHappyPath(t *testing.T) {
	if m.result.reverseProxyGuide != guide {
		t.Fatalf("reverseProxyGuide = %q, want %q", m.result.reverseProxyGuide, guide)
	}

	// Storage chosen → Summary, with both the connection and storage recorded.
	m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket  ·  minio:9000"})
	if m.stage != stageSummary {
		t.Fatalf("after storage, stage = %v, want stageSummary", m.stage)
	}
	sum, ok := m.screen.(*summaryModel)
	if !ok {
		t.Fatalf("after storage, screen = %T, want *summaryModel", m.screen)
	}
	if m.result.storageMethod != storageS3 || m.result.storageDetail == "" {
		t.Fatalf("storage result not recorded: %+v", m.result)
	}
	if sum.storageLabel != m.result.storageDetail {
		t.Fatalf("summary storageLabel = %q, want %q", sum.storageLabel, m.result.storageDetail)
	}
	if want := "https://panel.felis.example.com"; sum.panelURL != want {
		t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
	}
@@ -113,6 +128,7 @@ func TestRootSetupLocalSummary(t *testing.T) {
	m = drive(t, m, preflightDoneMsg{})
	m = drive(t, m, ownerResultMsg{username: "owner"})
	m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
	m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})

	sum, ok := m.screen.(*summaryModel)
	if !ok {
@@ -127,6 +143,83 @@ func TestRootSetupLocalSummary(t *testing.T) {
	}
}

// TestRootReconfigureConnectSkipsStorage locks the flow guard: from the finished
// summary, "change connection" re-enters only the connection chooser and returns
// straight to the summary — storage was already configured, so the operator is not
// dragged back through it, and the earlier storage recap is preserved.
func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
	m := newTestRoot(false, consoleModeSetup, "")
	m = drive(t, m, preflightDoneMsg{})
	m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"})
	m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
	m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
	if _, ok := m.screen.(*summaryModel); !ok {
		t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
	}

	// "change connection" re-enters the connection chooser.
	m = drive(t, m, reconfigureConnectMsg{})
	if m.stage != stageConnect {
		t.Fatalf("reconfigure stage = %v, want stageConnect", m.stage)
	}
	if _, ok := m.screen.(*connectChooserModel); !ok {
		t.Fatalf("reconfigure screen = %T, want *connectChooserModel", m.screen)
	}

	// Completing it returns straight to the summary — NOT the storage chooser —
	// with the original storage recap intact.
	m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"})
	if m.stage != stageSummary {
		t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage)
	}
	sum, ok := m.screen.(*summaryModel)
	if !ok {
		t.Fatalf("after reconfigure connect, screen = %T, want *summaryModel", m.screen)
	}
	if sum.storageLabel != "s3://bucket" {
		t.Fatalf("reconfigure summary storageLabel = %q, want preserved %q", sum.storageLabel, "s3://bucket")
	}
	if m.result.connectMethod != connectReverseProxy {
		t.Fatalf("reconfigure did not update connectMethod: %v", m.result.connectMethod)
	}
}

// TestRootReconfigureStorageReEntersChooser locks the post-install "change storage"
// path: from the finished summary it re-enters the storage chooser (not the
// connection one) and returns to the summary carrying the new storage recap.
func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
	m := newTestRoot(false, consoleModeSetup, "")
	m = drive(t, m, preflightDoneMsg{})
	m = drive(t, m, ownerResultMsg{username: "owner"})
	m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
	m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
	if _, ok := m.screen.(*summaryModel); !ok {
		t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
	}

	// "change storage" re-enters the storage chooser.
	m = drive(t, m, reconfigureStorageMsg{})
	if m.stage != stageStorage {
		t.Fatalf("reconfigure-storage stage = %v, want stageStorage", m.stage)
	}
	if _, ok := m.screen.(*storageChooserModel); !ok {
		t.Fatalf("reconfigure-storage screen = %T, want *storageChooserModel", m.screen)
	}

	// Completing it returns to the summary with the updated storage recap.
	m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://newbucket"})
	if m.stage != stageSummary {
		t.Fatalf("after reconfigure-storage, stage = %v, want stageSummary", m.stage)
	}
	sum, ok := m.screen.(*summaryModel)
	if !ok {
		t.Fatalf("after reconfigure-storage, screen = %T, want *summaryModel", m.screen)
	}
	if sum.storageLabel != "s3://newbucket" {
		t.Fatalf("summary storageLabel = %q, want updated %q", sum.storageLabel, "s3://newbucket")
	}
}

func TestRootRerunLandsOnStatus(t *testing.T) {
	// adminExists at start of a setup run = re-run: preflight should skip straight
	// to the "manage in panel" status screen, never touching owner/connect.
+407 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading