Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
// pve-confsync synchronizes part of the configuration of a reference Proxmox VE
|
// pve-confsync synchronizes part of the configuration of a reference Proxmox VE
|
||||||
// cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"pve-confsync/internal/engine"
|
"pve-confsync/internal/engine"
|
||||||
"pve-confsync/internal/model"
|
"pve-confsync/internal/model"
|
||||||
"pve-confsync/internal/pve"
|
"pve-confsync/internal/pve"
|
||||||
|
"pve-confsync/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
@@ -37,6 +39,7 @@ Commandes :
|
|||||||
export exporte l'état normalisé d'un cluster en YAML (défaut : la référence)
|
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)
|
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
|
apply dry-run par défaut ; applique avec -confirm
|
||||||
|
serve interface web (clusters, diff, dry-run, synchronisation, journal)
|
||||||
version affiche la version
|
version affiche la version
|
||||||
|
|
||||||
Lancer "pve-confsync <commande> -h" pour les options d'une commande.
|
Lancer "pve-confsync <commande> -h" pour les options d'une commande.
|
||||||
@@ -69,6 +72,8 @@ func main() {
|
|||||||
code, err = cmdDiff(ctx, *cfgFile, args[1:])
|
code, err = cmdDiff(ctx, *cfgFile, args[1:])
|
||||||
case "apply":
|
case "apply":
|
||||||
err = cmdApply(ctx, *cfgFile, args[1:])
|
err = cmdApply(ctx, *cfgFile, args[1:])
|
||||||
|
case "serve":
|
||||||
|
err = cmdServe(ctx, args[1:])
|
||||||
default:
|
default:
|
||||||
global.Usage()
|
global.Usage()
|
||||||
os.Exit(1)
|
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)
|
fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err)
|
||||||
return
|
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)
|
fmt.Println(" avertissement :", w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,39 +132,6 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error {
|
|||||||
return errors.Join(errs...)
|
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 ----
|
// ---- export ----
|
||||||
|
|
||||||
func cmdExport(ctx context.Context, cfgFile string, args []string) error {
|
func cmdExport(ctx context.Context, cfgFile string, args []string) error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ type Cluster struct {
|
|||||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||||
Insecure bool `yaml:"insecure,omitempty"`
|
Insecure bool `yaml:"insecure,omitempty"`
|
||||||
Timeout string `yaml:"timeout,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)
|
// 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()
|
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 {
|
func (c *Config) validate() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -86,7 +93,7 @@ func (c *Config) validate() error {
|
|||||||
errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name))
|
errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name))
|
||||||
}
|
}
|
||||||
seen[cl.Name] = true
|
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))
|
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{
|
return pve.New(pve.Options{
|
||||||
Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret,
|
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) {
|
func (cl Cluster) secret() (string, error) {
|
||||||
|
if cl.Secret != "" {
|
||||||
|
return cl.Secret, nil
|
||||||
|
}
|
||||||
if cl.TokenSecretEnv != "" {
|
if cl.TokenSecretEnv != "" {
|
||||||
if v := os.Getenv(cl.TokenSecretEnv); v != "" {
|
if v := os.Getenv(cl.TokenSecretEnv); v != "" {
|
||||||
return v, nil
|
return v, nil
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user