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

This commit is contained in:
Lemon-miaow committed 2026-07-02 23:38:46 +08:00
1 parent 191640c3f5
commit 598f3d31f4
22 files changed
+2177 -34

No files matched your search

+78 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,407 @@
package main
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"felis.lolicon.best/internal/platform"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
// ---- Storage backends ----
type storageMethod int
const (
storageLocal storageMethod = iota
storageS3
)
func storageMethodLabel(m storageMethod) string {
if m == storageS3 {
return "Object storage (S3-compatible)"
}
return "Local disk (on this node)"
}
// s3Inputs is the operator-entered coordinates for the S3 backend. Only endpoint,
// bucket and region reach felis.toml; the two keys go into a Kubernetes Secret.
type s3Inputs struct {
endpoint string
bucket string
region string
accessKey string
secretKey string
}
// storageChooserModel presents the two build-context storage backends as peer
// choices. "Local" persists uploaded modpacks on a node-local PVC (nothing else to
// configure); "S3" points them at an S3-compatible bucket. Both are functional; S3
// suits external object storage. It mirrors connectChooserModel's shape so the two
// mid-wizard forks read identically.
type storageChooserModel struct {
rootDomain string
form *huh.Form
choice storageMethod
prefill s3Inputs // pre-filled non-secret fields when re-entering to change storage
width, height int
}
// newStorageChooserModel opens the storage picker pre-selected on method and, for
// S3, carrying prefill's non-secret fields (endpoint/bucket/region) into the detail
// form. First-run callers pass (storageLocal, s3Inputs{}); the reconfigure path
// passes the backend already in felis.toml so an operator fixing a typo doesn't
// retype everything (credentials still must be re-entered — they live only in the
// Secret).
func newStorageChooserModel(rootDomain string, method storageMethod, prefill s3Inputs) *storageChooserModel {
m := &storageChooserModel{rootDomain: rootDomain, choice: method, prefill: prefill}
m.form = m.build()
return m
}
func (m *storageChooserModel) build() *huh.Form {
return m.sized(newFelisForm(huh.NewGroup(
huh.NewSelect[storageMethod]().
Title("Where should uploaded modpacks be stored?").
Description("Players submit modpacks for review; approved ones are built from here. You can change this later.").
Value(&m.choice).
Options(
huh.NewOption("Local disk · nothing else to set up", storageLocal),
huh.NewOption("Object storage · S3-compatible bucket", storageS3),
),
// A dim footnote, subordinate to the picker — the connect-chooser pattern.
huh.NewNote().Description(
"⚠ Local keeps uploads on this node's disk — simplest, ideal for a single-node install. "+
"Choose S3 for external object storage (AWS S3, MinIO, Cloudflare R2, …)."),
)))
}
func (m *storageChooserModel) sized(f *huh.Form) *huh.Form {
if m.width > 0 {
return f.WithWidth(m.width).WithHeight(m.height)
}
return f
}
func (m *storageChooserModel) setSize(w, h int) {
m.width, m.height = w, h
if m.form != nil {
m.form = m.form.WithWidth(w).WithHeight(h)
}
}
func (m *storageChooserModel) Init() tea.Cmd { return m.form.Init() }
func (m *storageChooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
// Skipping is choosing local — the simplest backend, changeable later.
return newStorageModel(storageLocal, s3Inputs{}), nil
}
}
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
switch m.form.State {
case huh.StateCompleted:
return newStorageModel(m.choice, m.prefill), nil
case huh.StateAborted:
return m, tea.Quit
}
return m, cmd
}
func (m *storageChooserModel) View() string { return m.form.View() }
// arrowNavOK lets the root repurpose ←/→ to walk the step rail: the picker uses
// ↑/↓, so the horizontal arrows are free.
func (m *storageChooserModel) arrowNavOK() bool { return true }
// ---- Storage apply: local applies immediately, S3 collects then applies ----
type storageStep int
const (
ssForm storageStep = iota // S3 only: collect endpoint/bucket/keys
ssWorking
ssDone
ssError
)
type storageApplyMsg struct{ err error }
// storageResultMsg is the method-agnostic outcome the root advances on, emitted
// once the chosen backend is written and the API has rolled.
type storageResultMsg struct {
method storageMethod
detail string
}
// storageBackMsg returns from the storage sub-screen to the chooser.
type storageBackMsg struct{}
func goBackStorage() tea.Cmd { return func() tea.Msg { return storageBackMsg{} } }
// storageModel drives applying the chosen backend. Local has no form and applies
// straight away; S3 first collects its coordinates. Both share the working → done /
// error machine, mirroring reverseProxyModel.
type storageModel struct {
method storageMethod
step storageStep
form *huh.Form
sp spinner.Model
err error
in s3Inputs
width, height int
}
func newStorageModel(method storageMethod, in s3Inputs) *storageModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
m := &storageModel{method: method, sp: sp, in: in}
if method == storageS3 {
m.step = ssForm
m.form = m.build()
} else {
m.step = ssWorking
}
return m
}
func (m *storageModel) build() *huh.Form {
return m.sized(newFelisForm(huh.NewGroup(
huh.NewNote().
Title("Object storage (S3-compatible)").
Description("Enter your bucket and credentials. The keys go into a Kubernetes Secret; only the endpoint and bucket are written to felis.toml."),
huh.NewInput().
Title("Endpoint").
Description("Your S3 API host, e.g. s3.amazonaws.com or minio.example.com:9000 (prefix http:// for a plaintext dev store).").
Value(&m.in.endpoint).
Validate(requiredStorageField("endpoint")),
huh.NewInput().
Title("Bucket").
Description("An existing bucket uploads are written to.").
Value(&m.in.bucket).
Validate(validateBucketName),
huh.NewInput().
Title("Region").
Description("Optional — leave blank for MinIO / R2.").
Value(&m.in.region),
huh.NewInput().
Title("Access key ID").
Value(&m.in.accessKey).
Validate(requiredStorageField("access key ID")),
huh.NewInput().
Title("Secret access key").
EchoMode(huh.EchoModePassword).
Value(&m.in.secretKey).
Validate(requiredStorageField("secret access key")),
)))
}
func (m *storageModel) sized(f *huh.Form) *huh.Form {
if m.width > 0 {
return f.WithWidth(m.width).WithHeight(m.height)
}
return f
}
func (m *storageModel) setSize(w, h int) {
m.width, m.height = w, h
if m.form != nil {
m.form = m.form.WithWidth(w).WithHeight(h)
}
}
func (m *storageModel) Init() tea.Cmd {
if m.method == storageS3 {
return m.form.Init()
}
// Local: the chooser already confirmed the choice, so apply immediately.
return tea.Batch(m.sp.Tick, m.apply())
}
func (m *storageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case storageApplyMsg:
if msg.err != nil {
m.step, m.err = ssError, msg.err
return m, nil
}
m.step = ssDone
return m, nil
case spinner.TickMsg:
if m.step == ssWorking {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
return m, nil
case tea.KeyMsg:
switch m.step {
case ssForm:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
return m, goBackStorage()
}
case ssDone:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, m.emit()
}
return m, nil
case ssError:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.method == storageS3 {
m.step, m.err = ssForm, nil
m.form = m.build()
return m, m.form.Init()
}
return m, goBackStorage()
case "enter":
m.step, m.err = ssWorking, nil
return m, tea.Batch(m.sp.Tick, m.apply())
}
return m, nil
case ssWorking:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
}
if m.step == ssForm && m.form != nil {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
switch m.form.State {
case huh.StateCompleted:
m.normalizeInputs()
m.step = ssWorking
return m, tea.Batch(m.sp.Tick, m.apply())
case huh.StateAborted:
return m, goBackStorage()
}
return m, cmd
}
return m, nil
}
func (m *storageModel) apply() tea.Cmd {
method, in := m.method, m.in
return func() tea.Msg {
return storageApplyMsg{err: applyStorageConfig(context.Background(), method, in)}
}
}
func (m *storageModel) emit() tea.Cmd {
method, detail := m.method, storageDetail(m.method, m.in)
return func() tea.Msg {
return storageResultMsg{method: method, detail: detail}
}
}
func (m *storageModel) normalizeInputs() {
m.in.endpoint = strings.TrimSpace(m.in.endpoint)
m.in.bucket = strings.TrimSpace(m.in.bucket)
m.in.region = strings.TrimSpace(m.in.region)
m.in.accessKey = strings.TrimSpace(m.in.accessKey)
m.in.secretKey = strings.TrimSpace(m.in.secretKey)
}
func (m *storageModel) View() string {
switch m.step {
case ssWorking:
return " " + m.sp.View() + " " + tuiHint.Render("Saving storage settings and rolling the API…") + "\n"
case ssDone:
var b strings.Builder
if m.method == storageS3 {
b.WriteString(tuiSuccessBanner("Object storage configured.") + "\n\n")
b.WriteString(tuiInfo("Uploads → s3://"+m.in.bucket+" · "+m.in.endpoint) + "\n")
} else {
b.WriteString(tuiSuccessBanner("Local storage configured.") + "\n\n")
b.WriteString(tuiInfo("Uploads → "+platform.UploadsLocalPath+" on this node") + "\n")
}
b.WriteString("\n" + tuiAction("enter", "continue"))
return b.String()
case ssError:
var b strings.Builder
b.WriteString(tuiErrorBanner("Could not save storage settings.") + "\n\n")
if m.err != nil {
b.WriteString(tuiHint.Render(m.err.Error()) + "\n")
}
if m.method == storageS3 {
b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit"))
} else {
b.WriteString("\n" + tuiAction("enter", "retry", "esc", "back"))
}
return b.String()
default:
if m.form == nil {
return ""
}
return m.form.View()
}
}
// arrowNavOK yields the horizontal arrows to the rail except while the S3 form is
// taking text input (where ←/→ move the cursor).
func (m *storageModel) arrowNavOK() bool { return m.step != ssForm }
// storageDetail is the one-line backend recap shown on the summary and in review.
func storageDetail(method storageMethod, in s3Inputs) string {
if method == storageS3 {
return "s3://" + in.bucket + " · " + in.endpoint
}
return "local disk · " + platform.UploadsLocalPath
}
// requiredStorageField rejects a blank value with a field-named message.
func requiredStorageField(name string) func(string) error {
return func(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("%s is required", name)
}
return nil
}
}
// bucketNameRE is a pragmatic S3 bucket-name check: 3–63 chars, lowercase
// letters/digits/dots/hyphens, starting and ending alphanumeric. It catches typos
// without trying to encode every provider's exact rules.
var bucketNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$`)
func validateBucketName(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return errors.New("bucket is required")
}
if !bucketNameRE.MatchString(s) {
return errors.New("bucket must be 3–63 chars: lowercase letters, digits, dots or hyphens")
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"fmt"
"strings"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/submit"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
// applyStorageConfig persists the operator's storage choice and rolls felis-api so
// it picks up the new backend. For local it stamps user_uploads_context at the
// uploads PVC mount; for S3 it stamps the s3:// base + the [registry.s3] endpoint
// and credential refs, and creates the felis-uploads-s3 Secret the deployment reads
// the keys from. It mirrors applyReverseProxy — the same write-config →
// apply-secret → roll chain, hardcoding the default "felis" namespace as the rest
// of the wizard does.
func applyStorageConfig(ctx context.Context, method storageMethod, in s3Inputs) error {
var uploadsCtx string
var s3cfg config.RegistryS3Config
if method == storageS3 {
// Preflight the coordinates BEFORE touching config, the Secret, or the
// deployment: a mistyped key, wrong endpoint, or missing bucket fails here at
// the keyboard instead of silently at the first real upload. Nothing has been
// written yet, so a failed check leaves the install untouched.
if err := submit.CheckS3Access(ctx, submit.S3StoreConfig{
Base: "s3://" + in.bucket,
Endpoint: in.endpoint,
Region: in.region,
AccessKey: in.accessKey,
SecretKey: in.secretKey,
}); err != nil {
return err
}
uploadsCtx = "s3://" + in.bucket
s3cfg = config.RegistryS3Config{
Endpoint: in.endpoint,
Region: in.region,
AccessKeyRef: platform.UploadsS3AccessKeyEnv,
SecretKeyRef: platform.UploadsS3SecretKeyEnv,
}
} else {
uploadsCtx = platform.UploadsLocalPath
}
if err := writeStorageConfig(uploadsCtx, s3cfg); err != nil {
return err
}
// S3: land the credentials in their own Secret BEFORE the roll, so the optional
// env refs resolve on the fresh pod. Local needs no Secret.
if method == storageS3 {
if err := applyUploadsS3Secret(ctx, in.accessKey, in.secretKey); err != nil {
return err
}
}
if err := applyFelisConfigSecret(ctx); err != nil {
return err
}
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
return err
}
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
}
// currentStorageInputs reads the storage backend already recorded in felis.toml so
// the reconfigure flow can pre-select the method and pre-fill the non-secret S3
// fields (endpoint/bucket/region). Credentials live only in the felis-uploads-s3
// Secret and are deliberately never read back — they must be re-entered to change.
// Any read error falls back to a blank local default rather than blocking reconfig.
func currentStorageInputs() (storageMethod, s3Inputs) {
cfg, err := config.Load(hostSetupConfigPath)
if err != nil {
return storageLocal, s3Inputs{}
}
base := cfg.Registry.UserUploadsContext
if !strings.HasPrefix(strings.ToLower(base), "s3://") {
return storageLocal, s3Inputs{}
}
bucket := base[len("s3://"):]
if i := strings.IndexByte(bucket, '/'); i >= 0 {
bucket = bucket[:i]
}
return storageS3, s3Inputs{
endpoint: cfg.Registry.S3.Endpoint,
bucket: bucket,
region: cfg.Registry.S3.Region,
}
}
// writeStorageConfig stamps the uploads backend into both the host and pod config
// files. The S3 subtable is set for S3 and cleared (zero value) for local, so
// switching backends never leaves stale coordinates behind.
func writeStorageConfig(uploadsCtx string, s3cfg config.RegistryS3Config) error {
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
cfg, err := config.Load(path)
if err != nil {
return err
}
cfg.Registry.UserUploadsContext = uploadsCtx
cfg.Registry.S3 = s3cfg
if err := writeConfig(path, cfg); err != nil {
return err
}
}
return nil
}
// applyUploadsS3Secret creates (or replaces) the felis-uploads-s3 Secret the
// felis-api Deployment mounts the S3 credentials from. The Secret is rendered
// in-process and piped to `kubectl apply` — the keys are NEVER passed as
// command-line args, so they never appear in the host process table.
func applyUploadsS3Secret(ctx context.Context, accessKey, secretKey string) error {
secret := &corev1.Secret{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
ObjectMeta: metav1.ObjectMeta{Name: platform.UploadsS3SecretName, Namespace: "felis"},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{
platform.UploadsS3SecretAccessKey: accessKey,
platform.UploadsS3SecretSecretKey: secretKey,
},
}
manifest, err := yaml.Marshal(secret)
if err != nil {
return fmt.Errorf("render uploads s3 secret: %w", err)
}
return kubectlWithInput(ctx, manifest, "apply", "-f", "-")
}
+7 -1
View File
@@ -16,6 +16,7 @@ type summaryModel struct {
ownerUsername string
ownerPassword string // one-time; shown once
accessLabel string
storageLabel string // build-context storage backend recap; empty to omit
routedHosts []string
alreadySetUp bool // re-run: Owner pre-existed
localHint bool // show the self-signed-cert note
@@ -32,6 +33,8 @@ func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch key.String() {
case "c", "C":
return m, func() tea.Msg { return reconfigureConnectMsg{} }
case "s", "S":
return m, func() tea.Msg { return reconfigureStorageMsg{} }
case "ctrl+c", "esc", "enter", "q":
return m, tea.Quit
}
@@ -59,6 +62,9 @@ func (m *summaryModel) View() string {
if m.accessLabel != "" {
card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n")
}
if m.storageLabel != "" {
card.WriteString(tuiLabel.Render("storage ") + m.storageLabel + "\n")
}
if len(m.routedHosts) > 0 {
card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.routedHosts, ", ") + "\n")
}
@@ -72,6 +78,6 @@ func (m *summaryModel) View() string {
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
}
b.WriteString("\n" + tuiAction("c", "change connection", "enter/esc", "exit"))
b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "enter/esc", "exit"))
return b.String()
}