Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
package cli
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/accountant"
"sultan/pkg/crypto"
"sultan/pkg/ledger"
)
const accountantUsage = `sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3).
Sultan-Server (Payees) melden ihr Inventar per signiertem Bericht.
sultan accountant [--addr :8444] [--public-url https://…] [--metrics-addr 127.0.0.1:8082] [--data-dir ./accdata]
Accountant starten (nur TLS; Adresse wird gemerkt und steckt in Einladungen;
Checkmk: curl http://127.0.0.1:8082/metrics/checkmk)
sultan accountant invite --payee ID [--ttl 24h|7d] [--server-url https://…]
Einladung für einen Sultan-Server (Payee) erzeugen
sultan accountant where <host|mandant> Störfall: wo läuft eine URL (Backend, Container,
Compose-Verzeichnis), seit wann, wo vorher? Teiltreffer genügen.
sultan accountant routes [--tenant ID] [--payee ID] alle aktuellen Routen
sultan accountant customer add --id ID --name NAME [--group G] [--note TEXT] [--cycle m|q|j|offen] [--flat-rate 89,00]
sultan accountant customer set --id ID [dieselben Felder] [--no-flat-rate] (nur angegebene Felder ändern)
sultan accountant customer list [--group G] [--unclassified]
sultan accountant customer map --tenant T --customer ID [--payee P] Mandant (z. B. Tippfehler) zuordnen
sultan accountant customer unmap --tenant T [--payee P]
sultan accountant customer delete --id ID
Kunden entstehen automatisch aus den Mandanten-IDs der Berichte.
sultan accountant product add --id ID --name NAME --class included|billable|free|internal [--price 49,00]
sultan accountant product set --id ID --from JJJJ-MM [--class …] [--price … | --no-price] (gilt ab Monat)
sultan accountant product price --id ID --customer ID --price 39,00 [--from JJJJ-MM] Kundenpreis
sultan accountant product list | product delete --id ID
Preise sind optional und nicht buchhaltungsrelevant (Lexware führt).
sultan accountant assign (--host H | --tenant T --router R [--protocol tcp]) [--product P] [--class C] [--start JJJJ-MM] [--clear]
Posten zuordnen – Vorrang vor dem Label sultan.accountant.billing
sultan accountant assignments
sultan accountant billing --month JJJJ-MM [--out datei.csv] Monatsabrechnung als CSV (Semikolon, Dezimalkomma)
sultan accountant payee list
sultan accountant payee report --id ID (letzten Bericht als JSON ausgeben)
sultan accountant payee delete --id ID (z. B. vor neuer Einladung nach Schlüsselverlust)
sultan accountant dump-checkmk [--force] Checkmk-Vorlage nach <data-dir>/checkmk exportieren
(anpassen – wirkt sofort, ohne Neustart)
Verwaltungsbefehle arbeiten direkt auf dem Datenverzeichnis (auch während er läuft).
Alle Befehle: --data-dir DIR (Default ./accdata)
`
var accountantCmds = map[string]tokenCmd{
"invite": accountantInvite,
"payee list": payeeList,
"payee report": payeeReport,
"payee delete": payeeDelete,
"dump-checkmk": dumpCheckmk,
"where": accountantWhere,
"routes": accountantRoutes,
"customer add": customerAdd,
"customer set": customerSet,
"customer list": customerList,
"customer delete": customerDelete,
"customer map": customerMap,
"customer unmap": customerUnmap,
"product add": productAdd,
"product set": productSet,
"product price": productPrice,
"product list": productList,
"product delete": productDelete,
"assign": assign,
"assignments": assignments,
"billing": billing,
}
func runAccountant(ctx context.Context, args []string, stdout, stderr io.Writer) int {
// Ohne Unterbefehl (bzw. nur Flags): Accountant starten.
if len(args) == 0 || len(args[0]) > 0 && args[0][0] == '-' {
return serveAccountant(ctx, args, stderr)
}
if args[0] == "help" {
fmt.Fprint(stdout, accountantUsage)
return 0
}
name, rest := args[0], args[1:]
if (name == "payee" || name == "customer" || name == "product") && len(rest) > 0 {
name, rest = name+" "+rest[0], rest[1:]
}
cmd, ok := accountantCmds[name]
if !ok {
fmt.Fprintf(stderr, "unbekannter Befehl \"accountant %s\"\n\n%s", name, accountantUsage)
return 2
}
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
var ue usageError
switch {
case err == nil, errors.Is(err, flag.ErrHelp):
return 0
case errors.As(err, &ue):
fmt.Fprintln(stderr, err)
return 2
default:
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
}
func serveAccountant(ctx context.Context, args []string, stderr io.Writer) int {
cfg, err := parseAccountantFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
a, err := accountant.New(ctx, cfg)
if err != nil {
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
return 1
}
if err := a.Run(ctx); err != nil {
fmt.Fprintln(stderr, "Accountant beendet mit Fehler:", err)
return 1
}
return 0
}
// parseAccountantFlags liest die Flags von `sultan accountant`; jedes Flag ist
// auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
func parseAccountantFlags(args []string, output io.Writer, getenv func(string) string) (accountant.Config, error) {
fs := flag.NewFlagSet("sultan accountant", flag.ContinueOnError)
fs.SetOutput(output)
var cfg accountant.Config
fs.StringVar(&cfg.DataDir, "data-dir", "./accdata", "Datenverzeichnis (accountant.db, server.key/.crt)")
fs.StringVar(&cfg.Addr, "addr", ":8444", "Adresse für Payees (nur TLS)")
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Payees den Accountant erreichen (z. B. https://accountant.example.de:8444); mehrfach angebbar; wird gemerkt, die erste steckt in Einladungen")
fs.StringVar(&cfg.MetricsAddr, "metrics-addr", "127.0.0.1:8082", `Checkmk-Ausgabe /metrics/checkmk (Klartext, nur Loopback; "none" = aus)`)
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
fs.DurationVar(&cfg.StaleWarn, "stale-warn", 2*time.Hour, "Checkmk WARN, wenn ein Payee so lange nicht berichtet hat")
fs.DurationVar(&cfg.StaleCrit, "stale-crit", 6*time.Hour, "Checkmk CRIT, wenn ein Payee so lange nicht berichtet hat")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return accountant.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return accountant.Config{}, err
}
if fs.NArg() > 0 {
return accountant.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
func accountantFlags(name string, env tokenEnv) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet("sultan accountant "+name, flag.ContinueOnError)
fs.SetOutput(env.stderr)
dir := fs.String("data-dir", "./accdata", "Datenverzeichnis des Accountants")
return fs, dir
}
// openLedger öffnet die DB eines bereits initialisierten Datenverzeichnisses
// (nie neu anlegen: ein Tippfehler würde sonst eine neue Identität erzeugen).
func openLedger(env tokenEnv, dir string) (*ledger.Ledger, error) {
for _, f := range []string{"accountant.db", "server.crt"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan accountant` starten oder --data-dir prüfen", dir, f)
}
}
return ledger.Open(env.ctx, filepath.Join(dir, "accountant.db"))
}
func accountantInvite(env tokenEnv, args []string) error {
fs, dir := accountantFlags("invite", env)
payee := fs.String("payee", "", "ID des Sultan-Servers (a–z, 0–9, - und _), z. B. webproxy11")
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
serverURL := fs.String("server-url", "", "Accountant-Adresse für diese Einladung (Default: --public-url)")
if err := parse(fs, args, "payee"); err != nil {
return err
}
ttl, err := parseTTL(*ttlRaw)
if err != nil {
return usageError{err}
}
if *serverURL != "" {
u, err := crypto.ValidateServerURL(*serverURL)
if err != nil {
return usageError{err}
}
*serverURL = u
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if *serverURL == "" {
urls, err := accountant.LoadPublicURLs(env.ctx, l)
if err != nil {
return err
}
if len(urls) > 0 {
*serverURL = urls[0]
}
}
cert, err := crypto.LoadOrCreateServerCert(*dir)
if err != nil {
return err
}
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
if err != nil {
return err
}
expires := time.Now().Add(ttl)
if err := l.AddInvite(env.ctx, tok.Secret, *payee, expires); err != nil {
return err
}
fmt.Fprintln(env.stdout, tok.String())
fmt.Fprintf(env.stderr, "Einladung für Payee %s, einmalig gültig bis %s.\n", *payee, expires.Format(time.DateTime))
if tok.ServerURL != "" {
fmt.Fprintf(env.stderr, "Accountant-Adresse %s ist enthalten. Auf dem Sultan-Server:\n SULTAN_ACCOUNTANT_TOKEN=<token> sultan server …\n", tok.ServerURL)
} else {
fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN=<token> sultan server --accountant=https://<accountant>:8444 …\n"+
"Tipp: Mit `sultan accountant --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
}
return nil
}
func payeeList(env tokenEnv, args []string) error {
fs, dir := accountantFlags("payee list", env)
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
payees, err := l.ListPayees(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tHOSTNAME\tIP\tENROLLED\tLAST_REPORT")
for _, p := range payees {
last := "nie"
if !p.LastReport.IsZero() {
last = p.LastReport.Local().Format(time.DateTime)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Hostname, p.IPAddress, p.EnrolledAt.Local().Format(time.DateTime), last)
}
return tw.Flush()
}
func payeeReport(env tokenEnv, args []string) error {
fs, dir := accountantFlags("payee report", env)
id := fs.String("id", "", "Payee-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
body, at, err := l.LatestReport(env.ctx, *id)
if err != nil {
return err
}
var buf bytes.Buffer
if err := json.Indent(&buf, body, "", " "); err != nil {
return err
}
fmt.Fprintln(env.stdout, buf.String())
fmt.Fprintf(env.stderr, "Bericht von %s, empfangen %s\n", *id, at.Local().Format(time.DateTime))
return nil
}
func payeeDelete(env tokenEnv, args []string) error {
fs, dir := accountantFlags("payee delete", env)
id := fs.String("id", "", "Payee-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.DeletePayee(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Payee %s gelöscht\n", *id)
return nil
}
func dumpCheckmk(env tokenEnv, args []string) error {
fs, dir := accountantFlags("dump-checkmk", env)
force := fs.Bool("force", false, "vorhandene Vorlage überschreiben")
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir) // nur prüfen, ob das Verzeichnis initialisiert ist
if err != nil {
return err
}
l.Close()
path, err := accountant.DumpCheckmk(*dir, *force)
if err != nil {
return err
}
fmt.Fprintln(env.stdout, path)
fmt.Fprintln(env.stderr, "Vorlage geschrieben – Änderungen wirken sofort, ohne Neustart. Fehlerhafte Vorlage: eingebaute greift, Checkmk meldet CRIT.")
return nil
}
const shortTime = "2006-01-02 15:04"
func localTime(t time.Time) string {
if t.IsZero() {
return "?"
}
return t.Local().Format(shortTime)
}
// splitQuery trennt den Suchbegriff (erstes Argument ohne -) von den Flags;
// ein Flag ohne "=" nimmt das folgende Argument als Wert.
func splitQuery(args []string) (query string, flags []string) {
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case strings.HasPrefix(a, "-"):
flags = append(flags, a)
if !strings.Contains(a, "=") && a != "-h" && a != "--help" && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
case query == "":
query = a
default:
flags = append(flags, a) // parse meldet das überzählige Argument
}
}
return query, flags
}
// routeProblems fasst den Zustand einer Route knapp zusammen ("ok" ohne Befund).
func routeProblems(r accountant.ReportRoute, n accountant.ReportNode) string {
var p []string
if !r.Active {
p = append(p, "verworfen")
}
if r.Down {
p = append(p, "gestoppt")
}
if r.Health == "unhealthy" {
p = append(p, "unhealthy")
}
switch {
case n.Maintenance:
p = append(p, "Wartung")
case !n.Connected:
p = append(p, "getrennt")
}
if len(p) == 0 {
return "ok"
}
return strings.Join(p, ",")
}
func accountantWhere(env tokenEnv, args []string) error {
query, rest := splitQuery(args)
fs, dir := accountantFlags("where", env)
if err := parse(fs, rest); err != nil {
return err
}
if query == "" {
return usageError{errors.New("Suchbegriff fehlt: sultan accountant where <host|mandant>")}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
res, err := accountant.Where(env.ctx, l, query)
if err != nil {
return err
}
if len(res.Current) == 0 && len(res.Gone) == 0 {
return fmt.Errorf("nichts gefunden für %q", query)
}
out := env.stdout
for i, c := range res.Current {
if i > 0 {
fmt.Fprintln(out)
}
r, n := c.Route, c.Node
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "URL\t%s (Router %s, %s)\n", strings.Join(r.Hosts, ", "), r.Router, r.Protocol)
tenant := r.Tenant
if c.TenantName != "" {
tenant += " (" + c.TenantName + ")"
}
fmt.Fprintf(tw, "Mandant\t%s\n", tenant)
if c.Customer != nil {
cust := c.Customer.ID + " " + c.Customer.Name + " (" + cycleLabel(c.Customer.Cycle)
if c.Customer.Group != "" {
cust += ", Gruppe " + c.Customer.Group
}
fmt.Fprintf(tw, "Kunde\t%s)\n", cust)
} else {
fmt.Fprintf(tw, "Kunde\tnicht zugeordnet\n")
}
fmt.Fprintf(tw, "Ingress\t%s (Bericht %s)\n", c.Payee, localTime(c.ReportAt))
backend := r.Backend
if n.NetBirdIP != "" {
backend += ", NetBird " + n.NetBirdIP
}
fmt.Fprintf(tw, "Backend\t%s (%s)\n", cmp.Or(r.BackendFQDN, r.Backend), backend)
container := r.Workload
if r.ComposeDir != "" {
container += ", compose " + r.ComposeDir
if r.ComposeProject != "" {
container += " (Projekt " + r.ComposeProject + ")"
}
}
fmt.Fprintf(tw, "Container\t%s\n", container)
state := []string{routeProblems(r, n)}
if r.State != "" {
s := r.State
if !r.StartedAt.IsZero() {
s += " seit " + localTime(r.StartedAt)
}
state = append(state, s)
}
if r.Health != "" {
state = append(state, r.Health)
}
if r.Product != "" {
state = append(state, "Produkt "+r.Product)
}
fmt.Fprintf(tw, "Zustand\t%s\n", strings.Join(state, ", "))
node := r.Node
if n.Connected {
node += " verbunden"
} else {
node += " getrennt"
}
for _, v := range []string{n.OS, prefixed("Docker ", n.DockerVersion)} {
if v != "" {
node += ", " + v
}
}
fmt.Fprintf(tw, "Knoten\t%s\n", node)
fmt.Fprintf(tw, "Seit\t%s\n", localTime(c.Since))
for _, p := range c.Previous {
fmt.Fprintf(tw, "Vorher\t%s (%s) %s – %s\n", p.Node, p.Payee, localTime(p.FirstSeen), localTime(p.Ended))
}
tw.Flush()
}
if len(res.Gone) > 0 {
if len(res.Current) > 0 {
fmt.Fprintln(out)
}
fmt.Fprintln(out, "Nicht mehr vorhanden:")
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
for _, g := range res.Gone {
fmt.Fprintf(tw, " %s\t%s\t%s (%s)\t%s – %s\n", strings.Join(g.Hosts, ", "), g.Tenant, g.Node, g.Payee,
localTime(g.FirstSeen), localTime(g.Ended))
}
tw.Flush()
}
return nil
}
func prefixed(prefix, v string) string {
if v == "" {
return ""
}
return prefix + v
}
func accountantRoutes(env tokenEnv, args []string) error {
fs, dir := accountantFlags("routes", env)
tenant := fs.String("tenant", "", "nur Routen dieses Mandanten")
payee := fs.String("payee", "", "nur Routen dieses Payees")
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
routes, err := accountant.Routes(env.ctx, l, accountant.RoutesFilter{Tenant: *tenant, Payee: *payee})
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "HOST\tTENANT\tPAYEE\tNODE\tBACKEND\tSTATE\tSINCE")
for _, c := range routes {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", strings.Join(c.Route.Hosts, ","), c.Route.Tenant, c.Payee,
c.Route.Node, cmp.Or(c.Route.BackendFQDN, c.Route.Backend), routeProblems(c.Route, c.Node), localTime(c.Since))
}
return tw.Flush()
}
+471
View File
@@ -0,0 +1,471 @@
package cli
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"sultan/pkg/accountant"
"sultan/pkg/crypto"
"sultan/pkg/ledger"
)
// initAccDir initialisiert ein Datenverzeichnis wie der erste Start von
// `sultan accountant`.
func initAccDir(t *testing.T, publicURLs ...string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "accdata")
a, err := accountant.New(context.Background(), accountant.Config{
DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: publicURLs, Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
a.Shutdown(context.Background())
return dir
}
func acc(t *testing.T, dir string, args ...string) (int, string, string) {
t.Helper()
full := append([]string{"accountant"}, args...)
full = append(full, "--data-dir="+dir)
return run(context.Background(), full...)
}
func TestAccountantFlags(t *testing.T) {
cfg, err := parseAccountantFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./accdata" || cfg.Addr != ":8444" || len(cfg.PublicURLs) != 0 || cfg.MetricsAddr != "127.0.0.1:8082" ||
cfg.EnrollLimit != 10 || cfg.StaleWarn != 2*time.Hour || cfg.StaleCrit != 6*time.Hour {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseAccountantFlags([]string{"--metrics-addr=none", "--enroll-limit=-1", "--stale-warn=30m", "--stale-crit=1h"},
&bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.MetricsAddr != "none" || cfg.EnrollLimit != -1 || cfg.StaleWarn != 30*time.Minute || cfg.StaleCrit != time.Hour {
t.Errorf("Overrides: %+v", cfg)
}
env := map[string]string{"SULTAN_PUBLIC_URL": "https://a:8444,https://b:8444"}
cfg, err = parseAccountantFlags([]string{"--data-dir=/srv/acc", "--addr=:9444"}, &bytes.Buffer{},
func(k string) string { return env[k] })
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/acc" || cfg.Addr != ":9444" || strings.Join(cfg.PublicURLs, " ") != "https://a:8444 https://b:8444" {
t.Errorf("Overrides: %+v", cfg)
}
}
// Einladung mit gemerkter Adresse und Fingerprint des Accountants; in der
// DB liegt nur der Hash.
func TestAccountantInvite(t *testing.T) {
dir := initAccDir(t, "https://acc.example.de:8444")
code, stdout, stderr := acc(t, dir, "invite", "--payee=webproxy11")
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
tk, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if err != nil {
t.Fatalf("Ausgabe ist keine Einladung: %q (%v)", stdout, err)
}
cert, _ := crypto.LoadOrCreateServerCert(dir)
if tk.Fingerprint != cert.Fingerprint || tk.ServerURL != "https://acc.example.de:8444" {
t.Errorf("Einladung %+v", tk)
}
if !strings.Contains(stderr, "webproxy11") || !strings.Contains(stderr, "SULTAN_ACCOUNTANT_TOKEN") {
t.Errorf("Hinweis fehlt: %q", stderr)
}
// Gleiche Einladung lässt sich im Ledger einlösen (Hash passt).
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
if err != nil {
t.Fatal(err)
}
defer l.Close()
if _, err := l.Enroll(context.Background(), tk.Secret, ledger.Payee{Hostname: "h", PublicKey: "k"}); err != nil {
t.Errorf("Einladung nicht einlösbar: %v", err)
}
// --server-url überschreibt, --ttl wird geprüft.
code, stdout, _ = acc(t, dir, "invite", "--payee=webproxy12", "--server-url=https://other:8444", "--ttl=7d")
if tk, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); code != 0 || tk.ServerURL != "https://other:8444" {
t.Errorf("--server-url: code=%d %+v", code, tk)
}
for _, args := range [][]string{
{"invite"}, // --payee fehlt
{"invite", "--payee=webproxy13", "--ttl=x"}, // ttl unlesbar
{"invite", "--payee=webproxy13", "--server-url=http://klartext"},
} {
if code, _, _ := acc(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d, erwartet 2", args, code)
}
}
if code, _, stderr := acc(t, dir, "invite", "--payee=webproxy11"); code != 1 || !strings.Contains(stderr, "existiert") {
t.Errorf("schon registriert: code=%d stderr=%q", code, stderr)
}
}
func TestAccountantPayees(t *testing.T) {
dir := initAccDir(t)
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "ID") || strings.Contains(stdout, "webproxy11") {
t.Errorf("leere Liste: code=%d %q", code, stdout)
}
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "webproxy11.lan.r25dc.de", PublicKey: "k", IPAddress: "10.0.0.5"})
l.Close()
code, stdout, _ := acc(t, dir, "payee", "list")
if code != 0 || !strings.Contains(stdout, "webproxy11") || !strings.Contains(stdout, "webproxy11.lan.r25dc.de") ||
!strings.Contains(stdout, "10.0.0.5") {
t.Errorf("Liste: code=%d %q", code, stdout)
}
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 0 {
t.Errorf("delete: code=%d", code)
}
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 1 {
t.Errorf("zweites delete: code=%d", code)
}
}
// Ein Tippfehler bei --data-dir legt keine neue Accountant-Identität an.
func TestAccountant_UninitializedDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "tippfehler")
if code, _, stderr := acc(t, dir, "payee", "list"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
if code, _, stderr := acc(t, dir, "invite", "--payee=x1"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("invite: code=%d stderr=%q", code, stderr)
}
}
func TestAccountant_Usage(t *testing.T) {
if code, stdout, _ := run(context.Background(), "accountant", "help"); code != 0 || !strings.Contains(stdout, "invite") {
t.Errorf("help: code=%d %q", code, stdout)
}
if code, _, stderr := run(context.Background(), "accountant", "gibts-nicht"); code != 2 || !strings.Contains(stderr, "unbekannt") {
t.Errorf("unbekannt: code=%d %q", code, stderr)
}
if code, stdout, _ := run(context.Background(), "help"); code != 0 || !strings.Contains(stdout, "sultan accountant") {
t.Errorf("Hauptusage ohne accountant: %q", stdout)
}
}
// payee list zeigt den letzten Bericht, payee report seinen Inhalt.
func TestAccountantPayeeReport(t *testing.T) {
dir := initAccDir(t)
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "h", PublicKey: "k", IPAddress: "10.0.0.5"})
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "LAST_REPORT") || !strings.Contains(stdout, "nie") {
t.Errorf("ohne Bericht: code=%d %q", code, stdout)
}
if code, _, stderr := acc(t, dir, "payee", "report", "--id=webproxy11"); code != 1 || !strings.Contains(stderr, "nicht gefunden") {
t.Errorf("report ohne Bericht: code=%d %q", code, stderr)
}
l.SaveReport(context.Background(), "webproxy11", []byte(`{"version":"v1","routes":[{"hosts":["kunde.de"]}]}`), nil)
l.Close()
code, stdout, _ := acc(t, dir, "payee", "list")
if code != 0 || strings.Contains(stdout, "nie") || !strings.Contains(stdout, time.Now().Format("2006-01-02")) {
t.Errorf("mit Bericht: code=%d %q", code, stdout)
}
code, stdout, _ = acc(t, dir, "payee", "report", "--id=webproxy11")
if code != 0 || !strings.Contains(stdout, `"kunde.de"`) || !strings.Contains(stdout, "\n ") {
t.Errorf("report: code=%d %q (erwartet eingerücktes JSON)", code, stdout)
}
if code, _, _ := acc(t, dir, "payee", "report"); code != 2 {
t.Errorf("report ohne --id: code=%d", code)
}
}
// dump-checkmk legt die eingebaute Vorlage zum Anpassen ins Datenverzeichnis.
func TestAccountantDumpCheckmk(t *testing.T) {
dir := initAccDir(t)
code, stdout, _ := acc(t, dir, "dump-checkmk")
want := filepath.Join(dir, "checkmk", accountant.CheckmkTemplateName)
if code != 0 || strings.TrimSpace(stdout) != want {
t.Fatalf("code=%d stdout=%q", code, stdout)
}
if code, _, stderr := acc(t, dir, "dump-checkmk"); code != 1 || !strings.Contains(stderr, "--force") {
t.Errorf("zweiter Lauf: code=%d %q", code, stderr)
}
if code, _, _ := acc(t, dir, "dump-checkmk", "--force"); code != 0 {
t.Errorf("--force: code=%d", code)
}
// Nicht initialisiertes Verzeichnis: nichts anlegen.
if code, _, _ := acc(t, filepath.Join(t.TempDir(), "tippfehler"), "dump-checkmk"); code != 1 {
t.Errorf("Tippfehler: code=%d", code)
}
}
// accReport legt einen Bericht samt Historie ab, wie es der Endpunkt tut.
func accReport(t *testing.T, dir, payeeID string, at time.Time, rep accountant.Report) {
t.Helper()
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
if err != nil {
t.Fatal(err)
}
defer l.Close()
l.SetClock(func() time.Time { return at })
if list, _ := l.ListPayees(context.Background()); !slices.ContainsFunc(list, func(p ledger.Payee) bool { return p.ID == payeeID }) {
l.AddInvite(context.Background(), "tok-"+payeeID, payeeID, at.Add(time.Hour))
if _, err := l.Enroll(context.Background(), "tok-"+payeeID, ledger.Payee{Hostname: payeeID, PublicKey: "k-" + payeeID}); err != nil {
t.Fatal(err)
}
}
if err := accountant.StoreReport(context.Background(), l, payeeID, rep); err != nil {
t.Fatal(err)
}
}
func cliReport(node string, routes ...accountant.ReportRoute) accountant.Report {
return accountant.Report{
GeneratedAt: time.Now(),
Tenants: []accountant.ReportTenant{{ID: "kunde", Name: "Kunde A"}},
Nodes: []accountant.ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true,
OS: "Debian 13", DockerVersion: "29.8.2"}},
Routes: routes,
}
}
func cliRoute(node, router string, hosts ...string) accountant.ReportRoute {
return accountant.ReportRoute{Tenant: "kunde", Node: node, Router: router, Protocol: "http", Hosts: hosts,
Workload: "kunde-a-" + router + "-1", Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081",
Active: true, ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
}
// where: Störfall – wohin springen, seit wann, wo vorher?
func TestAccountantWhere(t *testing.T) {
dir := initAccDir(t)
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
accReport(t, dir, "webproxy11", t1, cliReport("webdocker07", cliRoute("webdocker07", "wp", "kunde.de"),
cliRoute("webdocker07", "olat", "olat.kunde.de")))
t2 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local)
accReport(t, dir, "webproxy11", t2, cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de")))
code, stdout, stderr := acc(t, dir, "where", "kunde.de")
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
for _, want := range []string{
"kunde.de, www.kunde.de", "Router wp", "kunde (Kunde A)", "webproxy11", "webdocker11.lan.r25dc.de:8081",
"kunde-a-wp-1", "/srv/kunde-a", "healthy", "Debian 13", "2026-10-01 10:00",
"webdocker07", "2026-09-01 10:00", // vorher
"olat.kunde.de", // verschwunden
} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q in:\n%s", want, stdout)
}
}
if code, _, stderr := acc(t, dir, "where", "gibts-nicht.de"); code != 1 || !strings.Contains(stderr, "nichts gefunden") {
t.Errorf("kein Treffer: code=%d %q", code, stderr)
}
if code, _, _ := acc(t, dir, "where"); code != 2 {
t.Errorf("ohne Suchbegriff: code=%d", code)
}
}
func TestAccountantRoutes(t *testing.T) {
dir := initAccDir(t)
down := cliRoute("webdocker11", "olat", "olat.kunde.de")
down.Down = true
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"), down))
code, stdout, _ := acc(t, dir, "routes")
if code != 0 || !strings.Contains(stdout, "HOST") || !strings.Contains(stdout, "webdocker11.lan.r25dc.de:8081") {
t.Fatalf("code=%d\n%s", code, stdout)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) != 3 || !strings.HasPrefix(lines[1], "kunde.de") || !strings.Contains(lines[2], "gestoppt") {
t.Errorf("Tabelle:\n%s", stdout)
}
if code, stdout, _ := acc(t, dir, "routes", "--tenant=andere"); code != 0 || strings.Contains(stdout, "kunde.de") {
t.Errorf("--tenant: %q", stdout)
}
}
// Kunden: anlegen, ändern (nur angegebene Felder), filtern, zuordnen.
func TestAccountantCustomers(t *testing.T) {
dir := initAccDir(t)
if code, _, stderr := acc(t, dir, "customer", "add", "--id=familie", "--name=Familie", "--group=familie", "--cycle=j"); code != 0 {
t.Fatalf("add: code=%d %s", code, stderr)
}
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de")))
// Automatisch angelegt: kunde.
code, stdout, _ := acc(t, dir, "customer", "list", "--unclassified")
if code != 0 || !strings.Contains(stdout, "kunde") || strings.Contains(stdout, "familie") || !strings.Contains(stdout, "nicht eingeordnet") {
t.Errorf("--unclassified: code=%d\n%s", code, stdout)
}
if code, _, stderr := acc(t, dir, "customer", "set", "--id=kunde", "--cycle=quarterly", "--flat-rate=89,00",
"--note=Kumpel – Nebenabrede Bier", "--group=extern"); code != 0 {
t.Fatalf("set: code=%d %s", code, stderr)
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--name=Kunde A GmbH"); code != 0 { // nur der Name
t.Fatal("set --name")
}
code, stdout, _ = acc(t, dir, "customer", "list")
for _, want := range []string{"Kunde A GmbH", "quartalsweise", "89,00", "extern", "Nebenabrede", "jährlich"} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q:\n%s", want, stdout)
}
}
if code, stdout, _ := acc(t, dir, "customer", "list", "--group=familie"); code != 0 || strings.Contains(stdout, "kunde ") || !strings.Contains(stdout, "Familie") {
t.Errorf("--group:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--no-flat-rate"); code != 0 {
t.Error("--no-flat-rate")
}
// Tippfehler-Mandant auf Nebencluster zuordnen; where zeigt den Kunden.
if code, _, stderr := acc(t, dir, "customer", "map", "--tenant=kunde-a", "--payee=webproxy12", "--customer=kunde"); code != 0 {
t.Fatalf("map: %d %s", code, stderr)
}
code, stdout, _ = acc(t, dir, "customer", "list")
if !strings.Contains(stdout, "webproxy12/kunde-a") {
t.Errorf("Zuordnung nicht gelistet:\n%s", stdout)
}
if _, stdout, _ := acc(t, dir, "where", "kunde.de"); !strings.Contains(stdout, "Kunde A GmbH") || !strings.Contains(stdout, "quartalsweise") {
t.Errorf("where ohne Kunde:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "customer", "unmap", "--tenant=kunde-a", "--payee=webproxy12"); code != 0 {
t.Error("unmap")
}
for _, args := range [][]string{
{"customer", "add", "--id=x1"}, // --name fehlt
{"customer", "add", "--id=x1", "--name=X", "--cycle=w"}, // Zahlweise unbekannt
{"customer", "set", "--id=kunde", "--flat-rate=abc"},
{"customer", "set", "--id=kunde", "--flat-rate=1", "--no-flat-rate"},
{"customer", "map", "--tenant=x"},
} {
if code, _, _ := acc(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d, erwartet 2", args, code)
}
}
if code, _, _ := acc(t, dir, "customer", "delete", "--id=familie"); code != 0 {
t.Error("delete")
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=familie", "--name=x"); code != 1 {
t.Error("set nach delete")
}
}
// Katalog: Klasse und Preis mit Gültig-ab, Kundenpreis.
func TestAccountantProducts(t *testing.T) {
dir := initAccDir(t)
mustAcc := func(args ...string) string {
t.Helper()
code, stdout, stderr := acc(t, dir, args...)
if code != 0 {
t.Fatalf("%v: code=%d %s", args, code, stderr)
}
return stdout
}
mustAcc("product", "add", "--id=pingvin", "--name=Pingvin Share", "--class=free")
mustAcc("product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
mustAcc("product", "set", "--id=pingvin", "--from=2027-03", "--class=billable", "--price=5,00")
mustAcc("product", "set", "--id=olat", "--from=2027-01", "--price=52,00") // Klasse bleibt
mustAcc("customer", "add", "--id=k-1001", "--name=Kunde A")
mustAcc("product", "price", "--id=olat", "--customer=k-1001", "--price=39,00", "--from=2026-11")
out := mustAcc("product", "list")
for _, want := range []string{"pingvin", "Pingvin Share", "ab 2027-03: billable 5,00", "olat", "49,00", "ab 2027-01: billable 52,00"} {
if !strings.Contains(out, want) {
t.Errorf("fehlt %q:\n%s", want, out)
}
}
for _, args := range [][]string{
{"product", "add", "--id=x1", "--name=X", "--class=teuer"},
{"product", "add", "--id=x1", "--name=X"}, // --class fehlt
{"product", "set", "--id=olat", "--from=März"},
{"product", "set", "--id=olat", "--from=2027-02", "--price=1", "--no-price"},
{"product", "price", "--id=olat", "--customer=k-1001"}, // --price fehlt
} {
if code, _, _ := acc(t, dir, args...); code != 2 && code != 1 {
t.Errorf("%v: code=%d, erwartet Fehler", args, code)
}
}
if code, _, _ := acc(t, dir, "product", "set", "--id=gibts-nicht", "--from=2027-01", "--class=free"); code != 1 {
t.Errorf("unbekanntes Produkt: code=%d", code)
}
}
// assign: Produkt, Klasse, Beginn je Posten – bequem über den Host.
func TestAccountantAssign(t *testing.T) {
dir := initAccDir(t)
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable")
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"),
cliRoute("webdocker11", "olat", "olat.kunde.de")))
if code, _, stderr := acc(t, dir, "assign", "--host=olat.kunde.de", "--product=olat", "--start=2026-11"); code != 0 {
t.Fatalf("assign: %d %s", code, stderr)
}
if code, _, stderr := acc(t, dir, "assign", "--tenant=kunde", "--router=wp", "--class=internal"); code != 0 {
t.Fatalf("assign --router: %d %s", code, stderr)
}
code, stdout, _ := acc(t, dir, "assignments")
if code != 0 || !strings.Contains(stdout, "olat.kunde.de") || !strings.Contains(stdout, "2026-11") || !strings.Contains(stdout, "internal") {
t.Errorf("assignments:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "assign", "--host=www.kunde.de", "--clear"); code != 0 {
t.Error("--clear")
}
if _, stdout, _ := acc(t, dir, "assignments"); strings.Contains(stdout, "internal") {
t.Errorf("nach --clear:\n%s", stdout)
}
for _, args := range [][]string{
{"assign", "--host=gibts-nicht.de", "--class=free"},
{"assign", "--host=kunde.de", "--product=gibts-nicht"},
{"assign", "--host=kunde.de"}, // nichts zugeordnet
{"assign", "--class=free"}, // weder --host noch --tenant/--router
} {
if code, _, _ := acc(t, dir, args...); code == 0 {
t.Errorf("%v akzeptiert", args)
}
}
}
// billing: Monats-CSV auf stdout bzw. in eine Datei.
func TestAccountantBilling(t *testing.T) {
dir := initAccDir(t)
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
r := cliRoute("webdocker11", "olat", "olat.kunde.de")
r.Product = "olat"
accReport(t, dir, "webproxy11", time.Date(2026, 10, 17, 10, 0, 0, 0, time.Local), cliReport("webdocker11", r))
acc(t, dir, "customer", "set", "--id=kunde", "--cycle=q", "--flat-rate=89,00")
code, stdout, stderr := acc(t, dir, "billing", "--month=2026-10")
if code != 0 {
t.Fatalf("code=%d %s", code, stderr)
}
for _, want := range []string{"Monat;Kunde", "2026-10;kunde;Kunde A;;quartalsweise;Pauschale", "89,00;267,00",
"olat.kunde.de", "billable;olat", "49,00;147,00"} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q:\n%s", want, stdout)
}
}
out := filepath.Join(t.TempDir(), "2026-10.csv")
if code, _, _ := acc(t, dir, "billing", "--month=2026-10", "--out="+out); code != 0 {
t.Fatal("--out")
}
if data, _ := os.ReadFile(out); !strings.Contains(string(data), "olat.kunde.de") {
t.Errorf("Datei: %s", data)
}
if code, _, _ := acc(t, dir, "billing"); code != 2 {
t.Errorf("ohne --month: code=%d", code)
}
if code, _, _ := acc(t, dir, "billing", "--month=Oktober"); code != 2 {
t.Errorf("ungültiger Monat: code=%d", code)
}
}
+1
View File
@@ -52,6 +52,7 @@ func parseAgentFlags(args []string, output io.Writer, getenv func(string) string
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
fs.StringVar(&cfg.FQDN, "fqdn", "", "vollständiger Name dieses Hosts fürs Inventar (Default: automatisch über DNS)")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return agent.Config{}, err
+2 -2
View File
@@ -20,14 +20,14 @@ func TestAgentFlags(t *testing.T) {
cfg, err = parseAgentFlags([]string{
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
"--config-dir=/srv/sultan.d",
"--config-dir=/srv/sultan.d", "--fqdn=webdocker11.lan.r25dc.de",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
cfg.ConfigDir != "/srv/sultan.d" {
cfg.ConfigDir != "/srv/sultan.d" || cfg.FQDN != "webdocker11.lan.r25dc.de" {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
+7
View File
@@ -3,6 +3,7 @@
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
// sultan agent Agent: Enrollment, Stream, Docker-Watcher
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
// sultan accountant Zentrale für Inventar und Abrechnung
// sultan version
//
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
@@ -31,6 +32,7 @@ Befehle:
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
sultan agent Agent starten (Docker-Host oder Bare-Metal)
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
sultan accountant Zentrale für Inventar und Abrechnung starten bzw. verwalten
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
sultan version Version ausgeben
sultan help diese Hilfe
@@ -58,6 +60,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return runToken(ctx, args[1:], stdout, stderr)
case "agent":
return runAgent(ctx, args[1:], stderr)
case "accountant":
return runAccountant(ctx, args[1:], stdout, stderr)
case "dump-landing":
return runDump(args[1:], stdout, stderr)
default:
@@ -76,6 +80,7 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
return 2
}
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
cfg.Version = Version
s, err := server.New(ctx, cfg)
if err != nil {
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
@@ -111,6 +116,8 @@ func parseServerFlags(args []string, output io.Writer, getenv func(string) strin
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
fs.StringVar(&cfg.AccountantToken, "accountant-token", "", "Einladung von `sultan accountant invite` (nur beim ersten Start; danach accountant.json)")
fs.StringVar(&cfg.AccountantURL, "accountant", "", "Accountant-Adresse, z. B. https://accountant.example.de:8444 (Override; sonst aus der Einladung)")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return server.Config{}, err
+20
View File
@@ -236,3 +236,23 @@ func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) {
t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks)
}
}
// Accountant-Anbindung (Spec 5.3): Einladung und Adresse, auch per Umgebung.
func TestServerFlags_Accountant(t *testing.T) {
cfg, err := parseServerFlags([]string{"--accountant=https://acc.example:8444", "--accountant-token=sltn_enroll_x.y"},
&bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.AccountantURL != "https://acc.example:8444" || cfg.AccountantToken != "sltn_enroll_x.y" {
t.Errorf("Flags: %+v", cfg)
}
env := envMap(map[string]string{"SULTAN_ACCOUNTANT_TOKEN": "sltn_enroll_env", "SULTAN_ACCOUNTANT": "https://env:8444"})
cfg, err = parseServerFlags(nil, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if cfg.AccountantURL != "https://env:8444" || cfg.AccountantToken != "sltn_enroll_env" {
t.Errorf("Umgebung: %+v", cfg)
}
}
+267
View File
@@ -0,0 +1,267 @@
package cli
import (
"errors"
"flag"
"fmt"
"slices"
"strings"
"text/tabwriter"
"sultan/pkg/accountant"
"sultan/pkg/ledger"
)
// parseCycle versteht m/q/j bzw. monthly/quarterly/yearly (und deutsche
// Varianten); "offen" bzw. "none" = noch nicht eingeordnet.
func parseCycle(s string) (string, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "m", "monthly", "monatlich":
return ledger.CycleMonthly, nil
case "q", "quarterly", "quartal", "quartalsweise":
return ledger.CycleQuarterly, nil
case "j", "y", "yearly", "jährlich", "jaehrlich":
return ledger.CycleYearly, nil
case "offen", "none", "-":
return ledger.CycleUnclassified, nil
}
return "", fmt.Errorf("Zahlweise %q unbekannt (m, q, j oder offen)", s)
}
func cycleLabel(c string) string {
switch c {
case ledger.CycleMonthly:
return "monatlich"
case ledger.CycleQuarterly:
return "quartalsweise"
case ledger.CycleYearly:
return "jährlich"
}
return "nicht eingeordnet"
}
// customerFields sind die Felder, die add und set gemeinsam haben.
type customerFields struct {
name, group, note, cycle, flat *string
noFlat *bool
}
func addCustomerFlags(fs *flag.FlagSet) customerFields {
return customerFields{
name: fs.String("name", "", "Name"),
group: fs.String("group", "", "Kostenverursacher-Gruppe zum Clustern (z. B. extern, intern, familie)"),
note: fs.String("note", "", "Notiz, z. B. persönliche Nebenabreden"),
cycle: fs.String("cycle", "", "Zahlweise: m (monatlich), q (quartalsweise), j (jährlich), offen"),
flat: fs.String("flat-rate", "", "Pauschale je Monat in Euro, z. B. 89,00"),
noFlat: fs.Bool("no-flat-rate", false, "keine Pauschale"),
}
}
// apply übernimmt die auf der Befehlszeile gesetzten Felder in c.
func (f customerFields) apply(fs *flag.FlagSet, c *ledger.Customer) error {
set := map[string]bool{}
fs.Visit(func(fl *flag.Flag) { set[fl.Name] = true })
if set["flat-rate"] && *f.noFlat {
return usageError{errors.New("--flat-rate und --no-flat-rate schließen sich aus")}
}
if set["name"] {
c.Name = *f.name
}
if set["group"] {
c.Group = *f.group
}
if set["note"] {
c.Note = *f.note
}
if set["cycle"] {
cycle, err := parseCycle(*f.cycle)
if err != nil {
return usageError{err}
}
c.Cycle = cycle
}
if set["flat-rate"] {
cents, err := accountant.ParseMoney(*f.flat)
if err != nil {
return usageError{err}
}
c.FlatRate = &cents
}
if *f.noFlat {
c.FlatRate = nil
}
return nil
}
func customerAdd(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer add", env)
id := fs.String("id", "", "Kunden-ID (a–z, 0–9, - und _), z. B. Kundennummer")
fields := addCustomerFlags(fs)
if err := parse(fs, args, "id", "name"); err != nil {
return err
}
c := ledger.Customer{ID: *id}
if err := fields.apply(fs, &c); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.AddCustomer(env.ctx, c); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Kunde %s angelegt\n", c.ID)
return nil
}
func customerSet(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer set", env)
id := fs.String("id", "", "Kunden-ID")
fields := addCustomerFlags(fs)
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
c, err := l.GetCustomer(env.ctx, *id)
if err != nil {
return err
}
if err := fields.apply(fs, &c); err != nil {
return err
}
if err := l.UpdateCustomer(env.ctx, c); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Kunde %s geändert\n", c.ID)
return nil
}
func customerList(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer list", env)
group := fs.String("group", "", "nur Kunden dieser Gruppe")
unclassified := fs.Bool("unclassified", false, "nur Kunden ohne Zahlweise (Feedback der Buchhaltung fehlt)")
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
customers, err := l.ListCustomers(env.ctx)
if err != nil {
return err
}
maps, err := l.ListMappings(env.ctx)
if err != nil {
return err
}
tenants := map[string][]string{}
for _, c := range customers {
tenants[c.ID] = []string{c.ID} // Mandant mit gleicher ID (sofern nicht anders zugeordnet)
}
for _, m := range maps {
name := m.Tenant
if m.Payee != "" {
name = m.Payee + "/" + m.Tenant
}
if !slices.Contains(tenants[m.Customer], name) {
tenants[m.Customer] = append(tenants[m.Customer], name)
}
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tGROUP\tCYCLE\tFLAT_RATE\tTENANTS\tNOTE")
for _, c := range customers {
if (*group != "" && c.Group != *group) || (*unclassified && c.Cycle != ledger.CycleUnclassified) {
continue
}
flat := "-"
if c.FlatRate != nil {
flat = accountant.FormatMoney(*c.FlatRate)
}
name := c.Name
if c.Auto {
name += " (automatisch)"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", c.ID, name, cmpOrDash(c.Group), cycleLabel(c.Cycle), flat,
strings.Join(tenants[c.ID], ","), c.Note)
}
return tw.Flush()
}
func cmpOrDash(s string) string {
if s == "" {
return "-"
}
return s
}
func customerDelete(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer delete", env)
id := fs.String("id", "", "Kunden-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.DeleteCustomer(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Kunde %s samt Zuordnungen gelöscht\n", *id)
return nil
}
func customerMap(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer map", env)
tenant := fs.String("tenant", "", "Mandanten-ID, wie sie der Sultan-Server meldet")
payee := fs.String("payee", "", "nur auf diesem Payee (Default: auf allen)")
customer := fs.String("customer", "", "Kunden-ID")
if err := parse(fs, args, "tenant", "customer"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.MapTenant(env.ctx, *payee, *tenant, *customer); err != nil {
return err
}
where := "allen Payees"
if *payee != "" {
where = "Payee " + *payee
}
fmt.Fprintf(env.stdout, "Mandant %s auf %s → Kunde %s\n", *tenant, where, *customer)
if c, err := l.GetCustomer(env.ctx, *tenant); err == nil && c.Auto && c.ID != *customer {
fmt.Fprintf(env.stderr, "Hinweis: Kunde %s wurde automatisch angelegt und ist ggf. überflüssig: sultan accountant customer delete --id=%s\n", c.ID, c.ID)
}
return nil
}
func customerUnmap(env tokenEnv, args []string) error {
fs, dir := accountantFlags("customer unmap", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
payee := fs.String("payee", "", "Payee der Zuordnung (leer = Zuordnung für alle Payees)")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.UnmapTenant(env.ctx, *payee, *tenant); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Zuordnung von %s entfernt\n", *tenant)
return nil
}
+319
View File
@@ -0,0 +1,319 @@
package cli
import (
"errors"
"fmt"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/accountant"
"sultan/pkg/ledger"
)
func classHelp() string {
return "Klasse: included (in der Pauschale), billable (Zusatz-Service), free (Give-away), internal"
}
func parseOptionalPrice(raw string, set bool) (*int64, error) {
if !set {
return nil, nil
}
cents, err := accountant.ParseMoney(raw)
if err != nil {
return nil, usageError{err}
}
return &cents, nil
}
func productAdd(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product add", env)
id := fs.String("id", "", "Produkt-ID (a–z, 0–9, - und _), z. B. olat")
name := fs.String("name", "", "Name")
class := fs.String("class", "", classHelp())
price := fs.String("price", "", "Preis je Monat in Euro (optional, nicht buchhaltungsrelevant), z. B. 49,00")
if err := parse(fs, args, "id", "name", "class"); err != nil {
return err
}
p, err := parseOptionalPrice(*price, *price != "")
if err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.AddProduct(env.ctx, ledger.Product{ID: *id, Name: *name, Class: *class, Price: p}); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s angelegt\n", *id)
return nil
}
// productSet: neue Version ab einem Monat; ohne --class bleibt die Klasse,
// ohne --price/--no-price der Preis der bis dahin gültigen Version.
func productSet(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product set", env)
id := fs.String("id", "", "Produkt-ID")
from := fs.String("from", "", "gültig ab Monat JJJJ-MM (z. B. 2027-03)")
class := fs.String("class", "", classHelp()+" (Default: unverändert)")
price := fs.String("price", "", "Preis je Monat in Euro (Default: unverändert)")
noPrice := fs.Bool("no-price", false, "ab dann ohne Preis")
if err := parse(fs, args, "id", "from"); err != nil {
return err
}
if *price != "" && *noPrice {
return usageError{errors.New("--price und --no-price schließen sich aus")}
}
if !ledger.ValidMonth(*from) {
return usageError{fmt.Errorf("--from %q ungültig (JJJJ-MM)", *from)}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
cat, err := l.Catalog(env.ctx)
if err != nil {
return err
}
cur, ok := cat.At(*id, "", *from)
if !ok {
return fmt.Errorf("Produkt %s nicht gefunden", *id)
}
if *class != "" {
cur.Class = *class
}
switch {
case *noPrice:
cur.Price = nil
case *price != "":
if cur.Price, err = parseOptionalPrice(*price, true); err != nil {
return err
}
}
if err := l.SetProductVersion(env.ctx, *id, *from, cur.Class, cur.Price); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s ab %s: %s %s\n", *id, *from, cur.Class, priceText(cur.Price))
return nil
}
func priceText(p *int64) string {
if p == nil {
return "ohne Preis"
}
return accountant.FormatMoney(*p)
}
func productPrice(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product price", env)
id := fs.String("id", "", "Produkt-ID")
customer := fs.String("customer", "", "Kunden-ID")
price := fs.String("price", "", "Kundenpreis je Monat in Euro, z. B. 39,00")
from := fs.String("from", time.Now().Format("2006-01"), "gültig ab Monat JJJJ-MM")
if err := parse(fs, args, "id", "customer", "price"); err != nil {
return err
}
p, err := parseOptionalPrice(*price, true)
if err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.SetCustomerPrice(env.ctx, *id, *customer, *from, *p); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Kundenpreis %s für %s ab %s: %s\n", *id, *customer, *from, accountant.FormatMoney(*p))
return nil
}
func productList(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product list", env)
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
products, err := l.ListProducts(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tCLASS\tPRICE\tVERSIONS")
for _, p := range products {
var versions []string
for _, v := range p.Versions[1:] {
versions = append(versions, fmt.Sprintf("ab %s: %s %s", v.From, v.Class, priceText(v.Price)))
}
price := "-"
if p.Price != nil {
price = accountant.FormatMoney(*p.Price)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Name, p.Class, price, strings.Join(versions, "; "))
}
return tw.Flush()
}
func productDelete(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product delete", env)
id := fs.String("id", "", "Produkt-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.DeleteProduct(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s gelöscht\n", *id)
return nil
}
// assign: Zuordnung eines Postens, gefunden über --host oder direkt über
// --tenant/--router.
func assign(env tokenEnv, args []string) error {
fs, dir := accountantFlags("assign", env)
host := fs.String("host", "", "Host einer Route, z. B. olat.kunde.de")
tenant := fs.String("tenant", "", "alternativ: Mandant …")
router := fs.String("router", "", "… und Router")
protocol := fs.String("protocol", "http", "Protokoll bei --tenant/--router (http oder tcp)")
product := fs.String("product", "", "Produkt aus dem Katalog (Vorrang vor dem Label sultan.accountant.billing)")
class := fs.String("class", "", classHelp()+" (Vorrang vor dem Produkt)")
start := fs.String("start", "", "Abrechnungsbeginn JJJJ-MM (Default: Monat des ersten Auftauchens)")
clear := fs.Bool("clear", false, "Zuordnung entfernen")
if err := parse(fs, args); err != nil {
return err
}
if (*host == "") == (*tenant == "" || *router == "") {
return usageError{errors.New("entweder --host oder --tenant mit --router angeben")}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
key := accountant.ItemKey{Tenant: *tenant, Protocol: *protocol, Router: *router}
if *host != "" {
keys, err := accountant.ItemsForHost(env.ctx, l, *host)
if err != nil {
return err
}
switch len(keys) {
case 0:
return fmt.Errorf("keine Route mit Host %s bekannt", *host)
case 1:
key = keys[0]
default:
var names []string
for _, k := range keys {
names = append(names, k.Tenant+"/"+k.Protocol+"/"+k.Router)
}
return fmt.Errorf("Host %s gehört zu mehreren Posten (%s) – --tenant und --router angeben", *host, strings.Join(names, ", "))
}
}
if *clear {
if err := l.DeleteAssignment(env.ctx, key.Tenant, key.Protocol, key.Router); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Zuordnung von %s/%s/%s entfernt\n", key.Tenant, key.Protocol, key.Router)
return nil
}
a := ledger.Assignment{Tenant: key.Tenant, Protocol: key.Protocol, Router: key.Router, Product: *product, Class: *class, Start: *start}
if err := l.SetAssignment(env.ctx, a); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Posten %s/%s/%s zugeordnet\n", key.Tenant, key.Protocol, key.Router)
return nil
}
func assignments(env tokenEnv, args []string) error {
fs, dir := accountantFlags("assignments", env)
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
list, err := l.ListAssignments(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "TENANT\tROUTER\tHOSTS\tPRODUCT\tCLASS\tSTART")
for _, a := range list {
var hosts []string
spans, _ := l.RouteHistory(env.ctx, ledger.HistoryFilter{Tenant: a.Tenant})
for _, s := range spans {
if s.Protocol == a.Protocol && s.Router == a.Router {
for _, h := range s.Hosts {
if !slices.Contains(hosts, h) {
hosts = append(hosts, h)
}
}
}
}
fmt.Fprintf(tw, "%s\t%s/%s\t%s\t%s\t%s\t%s\n", a.Tenant, a.Protocol, a.Router, cmpOrDash(strings.Join(hosts, ",")),
cmpOrDash(a.Product), cmpOrDash(a.Class), cmpOrDash(a.Start))
}
return tw.Flush()
}
func billing(env tokenEnv, args []string) error {
fs, dir := accountantFlags("billing", env)
month := fs.String("month", "", "Abrechnungsmonat JJJJ-MM")
out := fs.String("out", "", "CSV in diese Datei schreiben (Default: stdout)")
if err := parse(fs, args, "month"); err != nil {
return err
}
if !ledger.ValidMonth(*month) {
return usageError{fmt.Errorf("--month %q ungültig (JJJJ-MM)", *month)}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
lines, err := accountant.Billing(env.ctx, l, *month)
if err != nil {
return err
}
w := env.stdout
if *out != "" {
f, err := os.Create(*out)
if err != nil {
return err
}
defer f.Close()
w = f
}
if err := accountant.WriteBillCSV(w, lines); err != nil {
return err
}
var open int
for _, ln := range lines {
if (ln.Class == "" && ln.Item != accountant.ItemFlatRate) || ln.Customer == "" {
open++
}
}
fmt.Fprintf(env.stderr, "%d Zeilen für %s", len(lines), *month)
if open > 0 {
fmt.Fprintf(env.stderr, ", davon %d ohne Zuordnung bzw. Kunde (Spalte Hinweis)", open)
}
fmt.Fprintln(env.stderr)
return nil
}