sultan token tenant update: Mandanten nachträglich ändern

Weitere Compose-Stacks eines Kunden sind der Regelfall; bisher ließen sich
Domains und Routenlimit nur per delete + neu anlegen (und damit ohne Knoten)
ändern. Die Änderung liegt bewusst nur auf dem Server: Ein Agent darf sich
keine Domains selbst freigeben.

- Domains und Features werden hinzugefügt/entfernt statt ersetzt; unbekannter
  Eintrag beim Entfernen ist ein Fehler, die letzte Domain bleibt
- Verlängerung in die Zukunft setzt expired auf active (der Ablauf-Job setzt
  das nie zurück); Quarantäne und Zahlungsverzug bleiben
- Wechsel auf kommerziell mit vergangenem valid_until verlangt --valid-until

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
brandClaude Opus 5.5 committed 2026-11-02 18:32:28 +01:00
1 parent dd93277750
commit c2d6dd694f
7 files changed
+413 -25

No files matched your search

+190 -19
View File
@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"text/tabwriter"
@@ -29,6 +30,11 @@ Arbeitet direkt auf dem Datenverzeichnis des Servers (auch während er läuft).
sultan token tenant add --id ID --name NAME --email MAIL --domains a.de,*.b.de
[--tier community|pro|enterprise] [--max-routes 5] [--valid-until JJJJ-MM-TT]
[--features custom_middleware,websockets,tcp_stream]
sultan token tenant update --id ID [--add-domains a.de,*.a.de] [--remove-domains b.de]
[--name NAME] [--email MAIL] [--tier community|pro|enterprise]
[--max-routes N] [--valid-until JJJJ-MM-TT]
[--add-features LISTE] [--remove-features LISTE]
(nur Angegebenes ändert sich; wirkt ohne Neustart beim nächsten Refresh)
sultan token tenant list
sultan token tenant set-status --id ID --status active|suspended_payment|quarantined|expired
sultan token tenant delete --id ID (löscht auch Knoten und Einladungen!)
@@ -61,6 +67,7 @@ type tokenCmd func(env tokenEnv, args []string) error
var tokenCmds = map[string]tokenCmd{
"tenant add": tenantAdd,
"tenant update": tenantUpdate,
"tenant list": tenantList,
"tenant set-status": tenantSetStatus,
"tenant delete": tenantDelete,
@@ -176,26 +183,13 @@ func tenantAdd(env tokenEnv, args []string) error {
if !policy.ValidID(*id) || *id == "sultan" {
return fmt.Errorf("Mandanten-ID %q ungültig (a–z, 0–9, - und _, nicht am Rand, kein \"__\", nicht \"sultan\")", *id)
}
var normalized []string
for _, d := range strings.Split(*domains, ",") {
n, err := license.NormalizeDomain(d)
if err != nil {
return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d))
}
normalized = append(normalized, n)
normalized, err := parseDomains(*domains)
if err != nil {
return err
}
var features []string
for _, f := range strings.Split(*featuresRaw, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
switch license.Feature(f) {
case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream:
features = append(features, f)
default:
return fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f)
}
features, err := parseFeatures(*featuresRaw)
if err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
@@ -213,6 +207,183 @@ func tenantAdd(env tokenEnv, args []string) error {
return nil
}
// parseDomains normalisiert eine kommagetrennte Domainliste.
func parseDomains(raw string) ([]string, error) {
var out []string
for _, d := range strings.Split(raw, ",") {
n, err := license.NormalizeDomain(d)
if err != nil {
return nil, fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d))
}
out = append(out, n)
}
return out, nil
}
// parseFeatures prüft eine kommagetrennte Feature-Liste; leer ist erlaubt.
func parseFeatures(raw string) ([]string, error) {
var out []string
for _, f := range strings.Split(raw, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
switch license.Feature(f) {
case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream:
out = append(out, f)
default:
return nil, fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f)
}
}
return out, nil
}
// removeThenAdd entfernt aus list und hängt danach an; Vorhandenes bleibt an
// seiner Stelle. Ein zu entfernender Eintrag, den es nicht gibt, ist ein Fehler:
// Ein Tippfehler darf nicht wie ein erfolgreiches Entziehen aussehen.
func removeThenAdd(what string, list, remove, add []string) ([]string, error) {
out := append([]string{}, list...)
for _, r := range remove {
i := slices.Index(out, r)
if i < 0 {
return nil, fmt.Errorf("%s %q ist nicht eingetragen (vorhanden: %s)", what, r, strings.Join(list, ","))
}
out = slices.Delete(out, i, i+1)
}
for _, a := range add {
if !slices.Contains(out, a) {
out = append(out, a)
}
}
return out, nil
}
// tenantUpdate ändert nur die angegebenen Felder. Ein leerer Wert heißt „nicht
// angegeben“; deshalb sind auch --max-routes und --tier Strings (0 ist ein
// gültiges Limit, und applyEnv lässt sich über fs.Visit nicht erkennen).
func tenantUpdate(env tokenEnv, args []string) error {
fs, dir := flags("tenant update", env)
id := fs.String("id", "", "Mandanten-ID")
name := fs.String("name", "", "neuer Name")
email := fs.String("email", "", "neue Kontakt-E-Mail")
addDomains := fs.String("add-domains", "", "Domains hinzufügen, kommagetrennt")
removeDomains := fs.String("remove-domains", "", "Domains entfernen, kommagetrennt (Routen darauf fallen weg)")
tier := fs.String("tier", "", "community, pro oder enterprise")
maxRoutes := fs.String("max-routes", "", "maximale Anzahl Routen (-1 = unbegrenzt)")
validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT")
addFeatures := fs.String("add-features", "", "Features hinzufügen, kommagetrennt")
removeFeatures := fs.String("remove-features", "", "Features entfernen, kommagetrennt")
if err := parse(fs, args, "id"); err != nil {
return err
}
if *name+*email+*addDomains+*removeDomains+*tier+*maxRoutes+*validUntil+*addFeatures+*removeFeatures == "" {
return usageError{errors.New("nichts zu ändern (siehe sultan token help)")}
}
routes := 0
if *maxRoutes != "" {
n, err := strconv.Atoi(*maxRoutes)
if err != nil {
return usageError{fmt.Errorf("--max-routes %q: keine Zahl", *maxRoutes)}
}
routes = n
}
var until time.Time
if *validUntil != "" {
d, err := parseDate(*validUntil)
if err != nil {
return usageError{err}
}
until = d
}
var domainsAdd, domainsRemove []string
var err error
if *addDomains != "" {
if domainsAdd, err = parseDomains(*addDomains); err != nil {
return err
}
}
if *removeDomains != "" {
if domainsRemove, err = parseDomains(*removeDomains); err != nil {
return err
}
}
featuresAdd, err := parseFeatures(*addFeatures)
if err != nil {
return err
}
featuresRemove, err := parseFeatures(*removeFeatures)
if err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.GetTenant(env.ctx, *id)
if err != nil {
return err
}
now := time.Now()
wasCommercial := t.Tier != store.TierCommunity
if t.AllowedDomains, err = removeThenAdd("Domain", t.AllowedDomains, domainsRemove, domainsAdd); err != nil {
return err
}
if len(t.AllowedDomains) == 0 {
return errors.New("mindestens eine Domain muss bleiben")
}
if t.Features, err = removeThenAdd("Feature", t.Features, featuresRemove, featuresAdd); err != nil {
return err
}
if *name != "" {
t.Name = *name
}
if *email != "" {
t.Email = *email
}
if *tier != "" {
t.Tier = store.Tier(*tier)
}
if *maxRoutes != "" {
t.MaxRoutes = routes
}
if *validUntil != "" {
t.ValidUntil = until
}
// Community prüft valid_until nicht. Ein altes Datum würde beim Wechsel auf
// ein kommerzielles Paket die Seiten beim nächsten Refresh abschalten.
if !wasCommercial && t.Tier != store.TierCommunity && *validUntil == "" && !now.Before(t.ValidUntil) {
return fmt.Errorf("Laufzeitende %s liegt in der Vergangenheit: beim Wechsel auf %s auch --valid-until angeben",
t.ValidUntil.Format(time.DateOnly), t.Tier)
}
// Der Ablauf-Job setzt expired nie zurück: Eine Verlängerung muss das tun.
// Quarantäne und Zahlungsverzug bleiben, das sind eigene Entscheidungen.
reactivated := *validUntil != "" && t.Status == store.TenantExpired && now.Before(until)
if reactivated {
t.Status = store.TenantActive
}
t, err = st.UpdateTenant(env.ctx, t)
if err != nil {
return err
}
limit := strconv.Itoa(t.MaxRoutes)
if t.MaxRoutes < 0 {
limit = "unbegrenzt"
}
fmt.Fprintf(env.stdout, "Mandant %s geändert (%s, Status %s, gültig bis %s, Routen %s)\n Domains: %s\n Features: %s\n",
t.ID, t.Tier, t.Status, t.ValidUntil.Format(time.DateOnly), limit,
strings.Join(t.AllowedDomains, ","), strings.Join(t.Features, ","))
if reactivated {
fmt.Fprintln(env.stdout, "Vertrag verlängert: Status von expired auf active gesetzt.")
}
if len(domainsRemove) > 0 {
fmt.Fprintln(env.stdout, "Hinweis: Routen auf entfernten Domains fallen beim nächsten Refresh weg.")
}
return nil
}
func parseDate(s string) (time.Time, error) {
if t, err := time.Parse(time.DateOnly, s); err == nil {
return t, nil