From 69c340e4c38bd90367ae34ba579cb38527594d27 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 13 Oct 2026 14:46:51 +0200 Subject: [PATCH] =?UTF-8?q?Phase=200=20:=20client=20API=20PVE=20et=20mod?= =?UTF-8?q?=C3=A8le=20d'objets=20g=C3=A9n=C3=A9riques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/model.go | 127 +++++++++++++++++++++++++ internal/pve/client.go | 202 ++++++++++++++++++++++++++++++++++++++++ internal/pve/cluster.go | 151 ++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 internal/model/model.go create mode 100644 internal/pve/client.go create mode 100644 internal/pve/cluster.go diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5df00d4 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,127 @@ +// Package model holds the generic, normalized representation of synchronized objects. +// Field values are canonical strings or sorted string lists, so that two clusters +// can be compared field by field and the result serialized to YAML without loss. +package model + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Kind identifies an object type handled by an adapter. +type Kind string + +const ( + KindDatacenter Kind = "datacenter-options" + KindZone Kind = "sdn-zone" + KindVNet Kind = "sdn-vnet" + KindSubnet Kind = "sdn-subnet" +) + +// Kinds lists the kinds in dependency order (creation order). +var Kinds = []Kind{KindDatacenter, KindZone, KindVNet, KindSubnet} + +// Object is one configuration object of a cluster. +// Field values are either string or []string (see Normalize). +type Object struct { + Kind Kind `yaml:"kind" json:"kind"` + ID string `yaml:"id" json:"id"` + Fields map[string]any `yaml:"fields" json:"fields"` +} + +// Key is the unique identity of an object inside a cluster. +func (o Object) Key() string { return string(o.Kind) + "/" + o.ID } + +// Snapshot is the exported state of one cluster (YAML, versionable in Git). +type Snapshot struct { + Cluster string `yaml:"cluster"` + PVEVersion string `yaml:"pve_version,omitempty"` + ExportedAt time.Time `yaml:"exported_at"` + Objects []Object `yaml:"objects"` + Warnings []string `yaml:"warnings,omitempty"` +} + +// Sort orders objects by kind (dependency order) then by id. +func Sort(objs []Object) { + rank := map[Kind]int{} + for i, k := range Kinds { + rank[k] = i + } + sort.SliceStable(objs, func(i, j int) bool { + if objs[i].Kind != objs[j].Kind { + return rank[objs[i].Kind] < rank[objs[j].Kind] + } + return objs[i].ID < objs[j].ID + }) +} + +// Normalize turns a YAML/JSON decoded field map into canonical values: +// scalars become strings, lists become sorted []string. +func Normalize(fields map[string]any) map[string]any { + out := make(map[string]any, len(fields)) + for k, v := range fields { + switch t := v.(type) { + case []any: + l := make([]string, 0, len(t)) + for _, e := range t { + l = append(l, scalar(e)) + } + sort.Strings(l) + out[k] = l + case []string: + l := append([]string(nil), t...) + sort.Strings(l) + out[k] = l + default: + out[k] = scalar(v) + } + } + return out +} + +func scalar(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case bool: + if t { + return "1" + } + return "0" + default: + return fmt.Sprint(t) + } +} + +// Equal compares two canonical values. +func Equal(a, b any) bool { + la, oka := a.([]string) + lb, okb := b.([]string) + if oka || okb { + if !oka || !okb || len(la) != len(lb) { + return false + } + for i := range la { + if la[i] != lb[i] { + return false + } + } + return true + } + return a == b +} + +// Format renders a canonical value for humans. +func Format(v any) string { + if l, ok := v.([]string); ok { + return "[" + strings.Join(l, ", ") + "]" + } + if v == nil { + return "" + } + return fmt.Sprintf("%q", v) +} diff --git a/internal/pve/client.go b/internal/pve/client.go new file mode 100644 index 0000000..3125a13 --- /dev/null +++ b/internal/pve/client.go @@ -0,0 +1,202 @@ +// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON, +// form-encoded writes, task polling and version check. No typed SDK on purpose: +// objects stay generic (map[string]any) and are interpreted by the adapters. +package pve + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" +) + +// Options describes how to reach and authenticate against one cluster. +type Options struct { + Name string // cluster name in the config (for messages only) + BaseURL string // https://host:8006 + TokenID string // user@realm!tokenname + TokenSecret string // never logged + CAFile string // PEM bundle to trust (optional) + Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning) + Insecure bool // skip TLS verification (lab only) + Timeout time.Duration +} + +// Client talks to the PVE API of one cluster. +type Client struct { + name string + base *url.URL + auth string + http *http.Client +} + +// APIError is a non-2xx answer of the API. +type APIError struct { + Method, Path string + Status int + Message string + Errors map[string]string +} + +func (e *APIError) Error() string { + msg := fmt.Sprintf("%s %s: HTTP %d", e.Method, e.Path, e.Status) + if e.Message != "" { + msg += ": " + e.Message + } + if len(e.Errors) > 0 { + keys := make([]string, 0, len(e.Errors)) + for k := range e.Errors { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + msg += fmt.Sprintf(" [%s: %s]", k, strings.TrimSpace(e.Errors[k])) + } + } + return msg +} + +// New builds a client. The token secret is kept in the Authorization header only. +func New(o Options) (*Client, error) { + u, err := url.Parse(strings.TrimRight(o.BaseURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL) + } + if o.TokenID == "" || o.TokenSecret == "" { + return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name) + } + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + switch { + case o.Fingerprint != "": + want := normalizeFingerprint(o.Fingerprint) + // Self-signed PVE certificates: pin the leaf certificate instead of a CA. + tlsCfg.InsecureSkipVerify = true + tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return errors.New("no peer certificate") + } + sum := sha256.Sum256(raw[0]) + if got := hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("certificate fingerprint mismatch (got %s)", got) + } + return nil + } + case o.CAFile != "": + pem, err := os.ReadFile(o.CAFile) + if err != nil { + return nil, fmt.Errorf("cluster %s: %w", o.Name, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, o.CAFile) + } + tlsCfg.RootCAs = pool + case o.Insecure: + tlsCfg.InsecureSkipVerify = true + } + timeout := o.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + return &Client{ + name: o.Name, + base: u, + auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret, + http: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment}, + }, + }, nil +} + +// Name returns the cluster name given in the options. +func (c *Client) Name() string { return c.name } + +func normalizeFingerprint(s string) string { + return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s)) +} + +// Get reads path (relative to /api2/json) and decodes "data" into out. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + return c.do(ctx, http.MethodGet, path, query, nil, out) +} + +// Post, Put and Delete send form-encoded parameters (the encoding pvesh and the GUI use). +func (c *Client) Post(ctx context.Context, path string, params url.Values, out any) error { + return c.do(ctx, http.MethodPost, path, nil, params, out) +} + +func (c *Client) Put(ctx context.Context, path string, params url.Values, out any) error { + return c.do(ctx, http.MethodPut, path, nil, params, out) +} + +// Delete passes its parameters in the query string, as the PVE API expects for DELETE. +func (c *Client) Delete(ctx context.Context, path string, params url.Values, out any) error { + return c.do(ctx, http.MethodDelete, path, params, nil, out) +} + +func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error { + u := *c.base + u.Path = "/api2/json/" + strings.TrimLeft(path, "/") + if len(query) > 0 { + u.RawQuery = query.Encode() + } + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return err + } + req.Header.Set("Authorization", c.auth) + req.Header.Set("Accept", "application/json") + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + resp, err := c.http.Do(req) + if err != nil { + // url.Error contains the URL only, never the Authorization header. + return fmt.Errorf("cluster %s: %w", c.name, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return fmt.Errorf("cluster %s: %s %s: %w", c.name, method, path, err) + } + var env struct { + Data json.RawMessage `json:"data"` + Message string `json:"message"` + Errors map[string]string `json:"errors"` + } + _ = json.Unmarshal(raw, &env) + if resp.StatusCode < 200 || resp.StatusCode > 299 { + msg := env.Message + if msg == "" { + // pveproxy puts the reason in the status line ("596 Broken pipe", "403 Permission check failed") + msg = strings.TrimSpace(strings.TrimPrefix(resp.Status, strconv.Itoa(resp.StatusCode))) + } + return &APIError{Method: method, Path: path, Status: resp.StatusCode, Message: msg, Errors: env.Errors} + } + if out == nil || len(env.Data) == 0 || string(env.Data) == "null" { + return nil + } + d := json.NewDecoder(strings.NewReader(string(env.Data))) + d.UseNumber() + if err := d.Decode(out); err != nil { + return fmt.Errorf("cluster %s: %s %s: decode: %w", c.name, method, path, err) + } + return nil +} diff --git a/internal/pve/cluster.go b/internal/pve/cluster.go new file mode 100644 index 0000000..51f14c0 --- /dev/null +++ b/internal/pve/cluster.go @@ -0,0 +1,151 @@ +package pve + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +// SupportedMajor is the only Proxmox VE major release this tool accepts. +const SupportedMajor = 9 + +// NodeVersion is the pve-manager version of one node. +type NodeVersion struct { + Node string + Version string // full pve-manager version, e.g. 9.1.4 + Release string // point release, e.g. 9.1 +} + +// Versions returns the version of every online node (GET /cluster/status, then +// GET /nodes/{node}/version). A standalone node without cluster falls back to GET /version. +func (c *Client) Versions(ctx context.Context) ([]NodeVersion, error) { + var status []map[string]any + if err := c.Get(ctx, "cluster/status", nil, &status); err != nil { + return nil, err + } + var res []NodeVersion + for _, s := range status { + if Str(s["type"]) != "node" || Str(s["online"]) != "1" { + continue + } + node := Str(s["name"]) + v, err := c.version(ctx, "nodes/"+url.PathEscape(node)+"/version") + if err != nil { + return nil, err + } + v.Node = node + res = append(res, v) + } + if len(res) == 0 { + v, err := c.version(ctx, "version") + if err != nil { + return nil, err + } + res = append(res, v) + } + return res, nil +} + +func (c *Client) version(ctx context.Context, path string) (NodeVersion, error) { + var v struct { + Version string `json:"version"` + Release string `json:"release"` + } + if err := c.Get(ctx, path, nil, &v); err != nil { + return NodeVersion{}, err + } + return NodeVersion{Version: v.Version, Release: v.Release}, nil +} + +// CheckVersion refuses any cluster with a node outside PVE 9.x. +func (c *Client) CheckVersion(ctx context.Context) ([]NodeVersion, error) { + vs, err := c.Versions(ctx) + if err != nil { + return nil, fmt.Errorf("cluster %s: version check: %w", c.name, err) + } + for _, v := range vs { + ref := v.Release + if ref == "" { + ref = v.Version + } + major, err := strconv.Atoi(strings.SplitN(ref, ".", 2)[0]) + if err != nil || major != SupportedMajor { + return vs, fmt.Errorf("cluster %s: node %s runs PVE %q, only %d.x is supported", + c.name, v.Node, ref, SupportedMajor) + } + } + return vs, nil +} + +// Permissions returns the effective privileges of the token (GET /access/permissions), +// as path -> privilege -> propagate flag. +func (c *Client) Permissions(ctx context.Context) (map[string]map[string]any, error) { + var p map[string]map[string]any + err := c.Get(ctx, "access/permissions", nil, &p) + return p, err +} + +// WaitTask polls a worker task until it stops and returns an error unless it ended "OK". +func (c *Client) WaitTask(ctx context.Context, upid string, poll time.Duration) error { + node, err := upidNode(upid) + if err != nil { + return err + } + path := "nodes/" + url.PathEscape(node) + "/tasks/" + url.PathEscape(upid) + "/status" + for { + var st struct { + Status string `json:"status"` + ExitStatus string `json:"exitstatus"` + } + if err := c.Get(ctx, path, nil, &st); err != nil { + return err + } + if st.Status == "stopped" { + if st.ExitStatus != "OK" { + return fmt.Errorf("cluster %s: task %s ended with %q", c.name, upid, st.ExitStatus) + } + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(poll): + } + } +} + +// upidNode extracts the node from "UPID::::::::". +func upidNode(upid string) (string, error) { + parts := strings.Split(upid, ":") + if len(parts) < 3 || parts[0] != "UPID" || parts[1] == "" { + return "", fmt.Errorf("invalid UPID %q", upid) + } + return parts[1], nil +} + +// Str renders a decoded JSON scalar as a string (json.Number, bool as 1/0). +func Str(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case json.Number: + return t.String() + case bool: + if t { + return "1" + } + return "0" + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case int: + return strconv.Itoa(t) + default: + return fmt.Sprint(t) + } +}