Files
Felis/cmd/felis/tui_storage.go

408 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}