feat(submit): local + S3 backends for modpack upload contexts, installer-selectable
This commit is contained in:
22 files changed
+2177
-34
No files matched your search
+78
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "-")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user