Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1

Merged
claude Bot merged 8 commits from phase-1-ui into main 2026-10-13 16:47:35 +02:00
4 changed files with 351 additions and 37 deletions
Showing only changes of commit e61f2e4740 - Show all commits

No files matched your search

+7 -35
View File
@@ -1,6 +1,7 @@
// pve-confsync synchronizes part of the configuration of a reference Proxmox VE
// cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client
// clusters through the PVE API. Phase 0: command line only.
// clusters through the PVE API: command line (check, export, diff, apply) and
// web UI (serve).
package main
import (
@@ -24,6 +25,7 @@ import (
"pve-confsync/internal/engine"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
"pve-confsync/internal/service"
)
var version = "dev"
@@ -37,6 +39,7 @@ Commandes :
export exporte l'état normalisé d'un cluster en YAML (défaut : la référence)
diff affiche les écarts référence -> clients (code retour 2 s'il y en a)
apply dry-run par défaut ; applique avec -confirm
serve interface web (clusters, diff, dry-run, synchronisation, journal)
version affiche la version
Lancer "pve-confsync <commande> -h" pour les options d'une commande.
@@ -69,6 +72,8 @@ func main() {
code, err = cmdDiff(ctx, *cfgFile, args[1:])
case "apply":
err = cmdApply(ctx, *cfgFile, args[1:])
case "serve":
err = cmdServe(ctx, args[1:])
default:
global.Usage()
os.Exit(1)
@@ -116,7 +121,7 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error {
fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err)
return
}
for _, w := range privilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) {
for _, w := range service.PrivilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) {
fmt.Println(" avertissement :", w)
}
}
@@ -127,39 +132,6 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error {
return errors.Join(errs...)
}
// privilegeWarnings flags a reference token able to write, and a client token
// missing the privileges needed by the synchronized kinds.
func privilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string {
has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok }
any := func(priv string) bool {
for p := range perms {
if has(p, priv) {
return true
}
}
return false
}
var w []string
if !write {
for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} {
if any(p) {
w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu")
}
}
return w
}
if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") {
w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera")
}
if dcOptions && !has("/", "Sys.Modify") {
w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites")
}
if !has("/", "Sys.Audit") {
w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles")
}
return w
}
// ---- export ----
func cmdExport(ctx context.Context, cfgFile string, args []string) error {
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"context"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"pve-confsync/internal/config"
"pve-confsync/internal/demo"
"pve-confsync/internal/server"
"pve-confsync/internal/store"
"pve-confsync/web"
)
// cmdServe starts the web UI (phases 1 and 2). Settings come from flags or from
// the environment, like Logstream (see README).
func cmdServe(ctx context.Context, args []string) error {
fl := flag.NewFlagSet("serve", flag.ExitOnError)
listen := fl.String("listen", envOr("LISTEN", ":8080"), "adresse d'écoute HTTP")
dataDir := fl.String("data", envOr("DATA_DIR", "/data"), "répertoire des données (base SQLite, clés)")
demoMode := fl.Bool("demo", envBool("CONFSYNC_DEMO", false), "mode démo : faux clusters PVE en mémoire, base temporaire")
webDir := fl.String("web", os.Getenv("WEB_DIR"), "servir les pages depuis ce répertoire au lieu de celles embarquées (développement)")
_ = fl.Parse(args)
if *demoMode && os.Getenv("DATA_DIR") == "" && !flagSet(fl, "data") {
dir, err := os.MkdirTemp("", "confsync-demo-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
*dataDir = dir
}
if err := os.MkdirAll(*dataDir, 0o700); err != nil {
return err
}
key, keySrc, err := store.LoadKey(*dataDir)
if err != nil {
return err
}
st, err := store.Open(filepath.Join(*dataDir, "confsync.db"), key)
if err != nil {
return err
}
defer st.Close()
log.Printf("database %s, encryption key from %s", filepath.Join(*dataDir, "confsync.db"), keySrc)
if *demoMode {
clusters, err := demo.Start(ctx, st)
if err != nil {
return fmt.Errorf("demo: %w", err)
}
defer clusters.Close()
log.Printf("DEMO mode: fake PVE clusters started; accounts admin, operateur, lecteur, password %q", demo.Password)
} else if err := bootstrap(ctx, st); err != nil {
return err
}
ttl, err := time.ParseDuration(envOr("SESSION_TTL", "12h"))
if err != nil {
return fmt.Errorf("SESSION_TTL: %w", err)
}
var static fs.FS = web.FS
if *webDir != "" {
static = os.DirFS(*webDir)
}
srv, err := server.New(server.Options{
Store: st,
Static: static,
Version: version,
Demo: *demoMode,
Auth: server.AuthConfig{
Local: envBool("AUTH_LOCAL", true),
Issuer: os.Getenv("OIDC_ISSUER"),
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: secretEnv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Scopes: os.Getenv("OIDC_SCOPES"),
GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
AdminGroup: os.Getenv("OIDC_ADMIN_GROUP"),
OperatorGroup: os.Getenv("OIDC_OPERATOR_GROUP"),
DefaultRole: os.Getenv("OIDC_DEFAULT_ROLE"),
SessionTTL: ttl,
DataDir: *dataDir,
Demo: *demoMode,
},
})
if err != nil {
return err
}
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
errc := make(chan error, 1)
go func() { errc <- hs.ListenAndServe() }()
log.Printf("pve-confsync %s: web UI on %s", version, *listen)
select {
case err := <-errc:
return err
case <-ctx.Done():
}
log.Printf("stopping: waiting for the synchronizations in progress")
sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = hs.Shutdown(sctx)
srv.Wait()
return nil
}
// bootstrap creates the first administrator and imports a YAML configuration
// on an empty database.
func bootstrap(ctx context.Context, st *store.Store) error {
n, err := st.CountUsers(ctx)
if err != nil {
return err
}
if n == 0 {
user := envOr("CONFSYNC_ADMIN_USER", "admin")
pass := secretEnv("CONFSYNC_ADMIN_PASSWORD")
generated := pass == ""
if generated {
pass = store.RandomPassword()
}
if err := st.SaveUser(ctx, user, store.RoleAdmin, pass); err != nil {
return fmt.Errorf("first administrator: %w", err)
}
if generated {
log.Printf("first administrator %q created, password: %s (change it in Settings > Account)", user, pass)
} else {
log.Printf("first administrator %q created", user)
}
}
if file := os.Getenv("CONFSYNC_IMPORT"); file != "" {
cls, err := st.Clusters(ctx)
if err != nil {
return err
}
if len(cls) > 0 {
return nil // imported once: the database is the reference from now on
}
cfg, err := config.Load(file)
if err != nil {
return fmt.Errorf("CONFSYNC_IMPORT: %w", err)
}
warns, err := st.ImportConfig(ctx, cfg, false)
if err != nil {
return fmt.Errorf("CONFSYNC_IMPORT: %w", err)
}
for _, w := range warns {
log.Printf("import: %s", w)
}
log.Printf("configuration imported from %s", file)
}
return nil
}
// secretEnv reads NAME, or the file named by NAME_FILE (docker secret).
func secretEnv(name string) string {
if v := os.Getenv(name); v != "" {
return v
}
if f := os.Getenv(name + "_FILE"); f != "" {
b, err := os.ReadFile(f)
if err != nil {
log.Printf("%s_FILE: %v", name, err)
return ""
}
return strings.TrimSpace(string(b))
}
return ""
}
func envBool(name string, def bool) bool {
v := os.Getenv(name)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
log.Printf("%s=%q: true or false expected, using %t", name, v, def)
return def
}
return b
}
func flagSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
+16 -2
View File
@@ -30,6 +30,10 @@ type Cluster struct {
Fingerprint string `yaml:"fingerprint,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
// Set by the web UI from its database, never read from nor written to YAML.
Secret string `yaml:"-"` // token secret, decrypted
CAPEM string `yaml:"-"` // CA bundle, PEM
}
// Client is a target cluster with its mapping (kind -> id -> field -> value)
@@ -74,6 +78,9 @@ func Load(file string) (*Config, error) {
return &c, c.validate()
}
// Validate checks a configuration built elsewhere (web UI database, YAML import).
func (c *Config) Validate() error { return c.validate() }
func (c *Config) validate() error {
var errs []error
seen := map[string]bool{}
@@ -86,7 +93,7 @@ func (c *Config) validate() error {
errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name))
}
seen[cl.Name] = true
if cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" {
if cl.Secret == "" && cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" {
errs = append(errs, fmt.Errorf("cluster %s: token_secret_env or token_secret_file is required", cl.Name))
}
}
@@ -158,11 +165,18 @@ func (cl Cluster) PVE() (*pve.Client, error) {
}
return pve.New(pve.Options{
Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret,
CAFile: cl.CAFile, Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout,
CAFile: cl.CAFile, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout,
})
}
// ResolveSecret reads the token secret from the environment variable or file of
// the YAML configuration (used when a YAML file is imported in the web UI).
func (cl Cluster) ResolveSecret() (string, error) { return cl.secret() }
func (cl Cluster) secret() (string, error) {
if cl.Secret != "" {
return cl.Secret, nil
}
if cl.TokenSecretEnv != "" {
if v := os.Getenv(cl.TokenSecretEnv); v != "" {
return v, nil
+129
View File
@@ -0,0 +1,129 @@
// Package demo starts fake PVE clusters (pvetest) and fills a database with
// them, to try the web UI without a Proxmox cluster: a reference, two clients
// with differences (missing objects, different fields, a missing mapping) and
// a PVE 8 cluster that is refused.
package demo
import (
"context"
"crypto/sha256"
"encoding/hex"
"pve-confsync/internal/adapter"
"pve-confsync/internal/model"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/store"
)
// Password of the demo accounts admin, operateur and lecteur.
const Password = "confsync-demo"
// Clusters are the fake clusters; Close stops them.
type Clusters struct{ servers []*pvetest.Server }
// Close stops the fake clusters.
func (c *Clusters) Close() {
for _, s := range c.servers {
s.Close()
}
}
// Start starts the fake clusters and records them (and the demo accounts) in st.
func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
ref := pvetest.New("9.1.4")
ref.Options = map[string]string{
"registered-tags": "backup;dev;prod",
"tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full",
"next-id": "lower=100,upper=999999",
"user-tag-access": "user-allow=list,user-allow-list=team-a;team-b",
"keyboard": "fr",
}
ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"}
ref.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve"}
ref.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "10.0.0.1,10.0.0.2", "mtu": 1450, "ipam": "pve"}
ref.Pending.Zones["evpnz"] = map[string]any{"type": "evpn", "controller": "evpnctl", "vrf-vxlan": 4000}
ref.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"}
ref.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"}
ref.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"}
ref.Pending.VNets["vx1"] = map[string]any{"zone": "vxz", "tag": 10001, "alias": "overlay"}
ref.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1",
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
ref.Commit()
// site-a: tags and an MTU behind, the VXLAN zone and the dev network missing
a := pvetest.New("9.0.10")
a.Options = map[string]string{
"registered-tags": "dev;prod",
"tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full",
"next-id": "lower=2000,upper=2999",
"keyboard": "en-us",
}
a.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr1", "mtu": 9000, "ipam": "pve"}
a.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve", "nodes": "pve-a1,pve-a2"}
a.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "production"}
a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"}
a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"}
a.Commit()
// site-b: nearly in line, but no mapping for the bridge of the "stor" zone
// (blocking error) and a local zone absent from the reference (kept, no prune)
b := pvetest.New("9.1.2")
b.Options = map[string]string{
"registered-tags": "backup;dev;prod",
"tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full",
"next-id": "lower=100,upper=999999",
"user-tag-access": "user-allow=list,user-allow-list=team-a;team-b",
}
b.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"}
b.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "172.16.0.1,172.16.0.2", "mtu": 1450, "ipam": "pve"}
b.Pending.Zones["labz"] = map[string]any{"type": "vlan", "bridge": "vmbr9", "ipam": "pve"}
b.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"}
b.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"}
b.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1",
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
b.Commit()
// legacy: PVE 8, refused by the version check
old := pvetest.New("8.4.1")
c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}}
save := func(s *pvetest.Server, name, token string, refer bool, pos int,
mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error {
sum := sha256.Sum256(s.Certificate().Raw)
return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL,
TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]),
Mapping: mapping, Policies: pol})
}
steps := []error{
save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil),
save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{
model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}},
model.KindZone: {
"vlanz": {"bridge": "vmbr1"},
"stor": {"bridge": "vmbr2", "nodes": "pve-a1,pve-a2"},
"vxz": {"peers": "192.168.10.1,192.168.10.2"},
},
}, nil),
save(b, "site-b", "confsync@pve!rw", false, 2, map[model.Kind]map[string]map[string]any{
model.KindZone: {
"vlanz": {"bridge": "vmbr0"},
"vxz": {"peers": "172.16.0.1,172.16.0.2"},
},
}, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}),
save(old, "legacy-pve8", "confsync@pve!rw", false, 3, nil, nil),
st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds,
Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}),
}
for _, u := range []struct{ name, role string }{{"admin", store.RoleAdmin}, {"operateur", store.RoleOperator}, {"lecteur", store.RoleViewer}} {
steps = append(steps, st.SaveUser(ctx, u.name, u.role, Password))
}
for _, err := range steps {
if err != nil {
c.Close()
return nil, err
}
}
return c, nil
}