Phase 0 : client API PVE et modèle d'objets génériques
This commit is contained in:
1 parent
5f4b3ab930
commit
69c340e4c3
3 files changed
+480
No files matched your search
@@ -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 "<absent>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%q", v)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:<node>:<pid>:<pstart>:<starttime>:<type>:<id>:<user>:".
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user