Files
zero-scale-platform/internal/platform/select.go
Stephen Schuresko 3a0ec17c7b Treat node-local storage as temporary data only
StorageClasses that keep data on one node's disk (hostpath, local-path,
OpenEBS local PV, Longhorn with 1 replica and strict-local) are now
marked node-local:

- init never records one as platform storage, even with --allow-non-ha.
- doctor lists PVCs on node-local classes that running pods mount. One
  annotated zero-scale.io/data=temporary is a note; one whose workload
  runs on 2+ nodes (a database cluster with a local volume per member)
  is accepted; any other is a failure, and pins its Ingress backend.
- doctor warns when a node-local class is a default StorageClass and
  prints the annotate command that removes the default.
- App volumes take a :tmp mode: scratch space on the gateway pod's own
  disk instead of the shared RWX volume.

doctor also now reports Ingress backends whose Service does not exist,
host+path routed by several Ingresses to different Services, Knative
DomainMappings with no Ingress rule, and judges recorded platform
choices instead of asking for them again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 07:24:02 -04:00

225 lines
6.2 KiB
Go

package platform
import (
"fmt"
"strings"
)
// Options override or constrain what Choose picks.
type Options struct {
BlockClass string // StorageClass name
RWXClass string // StorageClass name
Postgres string // namespace/name, or "none"
MySQL string // namespace/name, or "none"
AllowNonHA bool
}
// AmbiguousError means several HA candidates fit and none is marked default.
type AmbiguousError struct {
What string // e.g. "block storage class"
Flag string // flag that resolves it
Choices []string // candidate names
}
func (e *AmbiguousError) Error() string {
return fmt.Sprintf("several %s candidates fit: %s (choose one with %s)", e.What, strings.Join(e.Choices, ", "), e.Flag)
}
// MissingError means a required piece has no acceptable candidate.
type MissingError struct {
Missing []string // human descriptions
Hints []string // commands that would fix it
}
func (e *MissingError) Error() string {
return "missing required platform pieces: " + strings.Join(e.Missing, "; ")
}
func acceptable(g Grade, allowNonHA bool) bool {
return g == HA || allowNonHA
}
// acceptableClass never takes a node-local class, not even with --allow-non-ha:
// its data stays on one node's disk, so it is only fit for temporary data.
func acceptableClass(sc StorageClass, allowNonHA bool) bool {
return !sc.NodeLocal && acceptable(sc.Grade, allowNonHA)
}
func checkClass(sc *StorageClass, allowNonHA bool) error {
if sc.NodeLocal {
return fmt.Errorf("storage class %q is node-local (%s): its data stays on one node, so it only suits temporary data, never platform storage", sc.Name, sc.Reason)
}
if !acceptable(sc.Grade, allowNonHA) {
return fmt.Errorf("storage class %q is %s (%s); pass --allow-non-ha to use it anyway", sc.Name, sc.Grade, sc.Reason)
}
return nil
}
// Choose turns a detection report into a platform Config.
func Choose(r *Report, o Options) (*Config, error) {
c := &Config{}
missing := &MissingError{}
// Block storage.
if o.BlockClass != "" {
sc := findClass(r, o.BlockClass)
if sc == nil {
return nil, fmt.Errorf("storage class %q not found", o.BlockClass)
}
if err := checkClass(sc, o.AllowNonHA); err != nil {
return nil, err
}
c.Storage.BlockClass = sc.Name
} else {
name, err := pickClass(r, "block storage class", "--storage-class", func(sc StorageClass) bool { return acceptableClass(sc, o.AllowNonHA) })
if err != nil {
return nil, err
}
if name == "" {
missing.Missing = append(missing.Missing, "an HA block StorageClass")
missing.Hints = append(missing.Hints, "zero-scale-ctl install longhorn")
}
c.Storage.BlockClass = name
}
// RWX storage: the block class itself when it can serve RWX.
switch {
case o.RWXClass != "":
sc := findClass(r, o.RWXClass)
if sc == nil {
return nil, fmt.Errorf("storage class %q not found", o.RWXClass)
}
if !sc.RWX {
return nil, fmt.Errorf("storage class %q (%s) cannot serve ReadWriteMany volumes", sc.Name, sc.Provisioner)
}
if err := checkClass(sc, o.AllowNonHA); err != nil {
return nil, err
}
c.Storage.RWXClass = sc.Name
default:
if sc := findClass(r, c.Storage.BlockClass); sc != nil && sc.RWX {
c.Storage.RWXClass = sc.Name
break
}
name, err := pickClass(r, "RWX storage class", "--rwx-class", func(sc StorageClass) bool { return sc.RWX && acceptableClass(sc, o.AllowNonHA) })
if err != nil {
return nil, err
}
if name == "" {
missing.Missing = append(missing.Missing, "an HA ReadWriteMany StorageClass")
if !contains(missing.Hints, "zero-scale-ctl install longhorn") {
missing.Hints = append(missing.Hints, "zero-scale-ctl install longhorn")
}
}
c.Storage.RWXClass = name
}
var err error
if c.Postgres, err = pickDB(r.Postgres, "Postgres cluster", "--postgres", o.Postgres, o.AllowNonHA); err != nil {
return nil, err
}
if c.MySQL, err = pickDB(r.MySQL, "MySQL cluster", "--mysql", o.MySQL, o.AllowNonHA); err != nil {
return nil, err
}
if len(r.Gateways) == 1 {
g := r.Gateways[0]
c.Gateway = GatewayConfig{Namespace: g.Namespace, Service: g.Name, ManagedBy: g.ManagedBy}
}
for _, redis := range r.Redis {
if c.Gateway.Namespace != "" && strings.Contains(redis, "."+c.Gateway.Namespace+".svc") {
c.Redis = redis
}
}
if c.Redis == "" && len(r.Redis) > 0 {
c.Redis = r.Redis[0]
}
for _, ic := range r.IngressClasses {
if ic.Default || (c.Ingress.Class == "" && ic.Controller == "k8s.io/ingress-nginx") {
c.Ingress.Class = ic.Name
}
}
if len(missing.Missing) > 0 {
return c, missing
}
return c, nil
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func findClass(r *Report, name string) *StorageClass {
for i := range r.StorageClasses {
if r.StorageClasses[i].Name == name {
return &r.StorageClasses[i]
}
}
return nil
}
func pickClass(r *Report, what, flag string, ok func(StorageClass) bool) (string, error) {
var fits []StorageClass
for _, sc := range r.StorageClasses {
if ok(sc) {
fits = append(fits, sc)
}
}
switch len(fits) {
case 0:
return "", nil
case 1:
return fits[0].Name, nil
}
var names []string
for _, sc := range fits {
if sc.Default {
return sc.Name, nil
}
names = append(names, sc.Name)
}
return "", &AmbiguousError{What: what, Flag: flag, Choices: names}
}
func pickDB(cands []DBCandidate, what, flag, override string, allowNonHA bool) (*DBTarget, error) {
if override == "none" {
return nil, nil
}
if override != "" {
for i := range cands {
if cands[i].Ref() == override {
if !acceptable(cands[i].Grade, allowNonHA) {
return nil, fmt.Errorf("%s %s is %s (%s); pass --allow-non-ha to use it anyway", what, override, cands[i].Grade, cands[i].Reason)
}
t := cands[i].DBTarget
return &t, nil
}
}
return nil, fmt.Errorf("%s %q not found", what, override)
}
var fits []DBCandidate
for _, c := range cands {
if acceptable(c.Grade, allowNonHA) && c.Ready != "no" {
fits = append(fits, c)
}
}
switch len(fits) {
case 0:
return nil, nil // optional: only needed once an app asks for this engine
case 1:
t := fits[0].DBTarget
return &t, nil
}
var names []string
for _, c := range fits {
names = append(names, c.Ref())
}
return nil, &AmbiguousError{What: what, Flag: flag, Choices: names}
}