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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user