Full web : fichiers restants

This commit is contained in:
cedric committed 2026-10-23 17:43:20 +02:00
1 parent ec3b7c9a01
commit 4826fa5b82
26 files changed
+2537 -793

No files matched your search

+301 -176
View File
@@ -1,6 +1,7 @@
// Package store keeps the state of the web UI in SQLite: clusters (token
// secrets encrypted with AES-256-GCM), synchronization settings, per client
// mappings and policies, users, execution journal and audit trail.
// Package store keeps the state of the web UI in SQLite: clusters (root
// passwords encrypted with AES-256-GCM, pinned TLS certificates and SSH host
// keys), synchronization settings, per target mappings and policies, users,
// execution journal and audit trail.
package store
import (
@@ -9,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
@@ -17,6 +19,7 @@ import (
"pve-confsync/internal/adapter"
"pve-confsync/internal/config"
"pve-confsync/internal/model"
"pve-confsync/internal/sshx"
)
// ErrNotFound is returned for an unknown cluster, user or run.
@@ -28,20 +31,29 @@ type Store struct {
box *Box
}
// schemaVersion 2: clusters reached with the root credentials (API + SSH),
// roles primary / target / disabled. Version 1 (API tokens) is not migrated:
// its clusters table is dropped, the clusters are added again from the UI.
const schemaVersion = 2
const schema = `
CREATE TABLE IF NOT EXISTS clusters (
name TEXT PRIMARY KEY,
reference INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL DEFAULT 'target',
position INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL,
token_id TEXT NOT NULL,
secret BLOB,
api_user TEXT NOT NULL DEFAULT 'root@pam',
password BLOB,
tls TEXT NOT NULL DEFAULT 'fp',
fingerprint TEXT NOT NULL DEFAULT '',
ca_pem TEXT NOT NULL DEFAULT '',
insecure INTEGER NOT NULL DEFAULT 0,
timeout TEXT NOT NULL DEFAULT '',
ssh_host TEXT NOT NULL DEFAULT '',
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_hostkey TEXT NOT NULL DEFAULT '',
mapping TEXT NOT NULL DEFAULT '{}',
policies TEXT NOT NULL DEFAULT '{}',
last_check TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
@@ -96,6 +108,10 @@ func Open(path string, key []byte) (*Store, error) {
return nil, err
}
db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", path, err)
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", path, err)
@@ -108,6 +124,29 @@ func Open(path string, key []byte) (*Store, error) {
return s, nil
}
// migrate drops the clusters of the first schema (API tokens, not reusable).
func migrate(db *sql.DB) error {
var v int
if err := db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
return err
}
if v >= schemaVersion {
return nil
}
var old int
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('clusters') WHERE name = 'token_id'`).Scan(&old); err != nil {
return err
}
if old > 0 {
log.Printf("database: clusters of the previous version (API tokens) dropped, add them again with their root credentials")
if _, err := db.Exec(`DROP TABLE clusters; DROP TABLE IF EXISTS settings`); err != nil {
return err
}
}
_, err := db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
@@ -115,7 +154,7 @@ func (s *Store) Close() error { return s.db.Close() }
// (lost or changed CONFSYNC_SECRET_KEY), instead of failing later on each cluster.
func (s *Store) checkKey() error {
var blob []byte
err := s.db.QueryRow(`SELECT secret FROM clusters WHERE secret IS NOT NULL AND length(secret) > 0 LIMIT 1`).Scan(&blob)
err := s.db.QueryRow(`SELECT password FROM clusters WHERE password IS NOT NULL AND length(password) > 0 LIMIT 1`).Scan(&blob)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
@@ -123,67 +162,176 @@ func (s *Store) checkKey() error {
return err
}
if _, err := s.box.Open(blob); err != nil {
return errors.New("the encryption key does not decrypt the stored token secrets (CONFSYNC_SECRET_KEY changed?)")
return errors.New("the encryption key does not decrypt the stored passwords (CONFSYNC_SECRET_KEY changed?)")
}
return nil
}
// ---- clusters ----
// Cluster is a cluster as stored. The secret is never returned to the UI:
// HasSecret only says whether one is set.
type Cluster struct {
Name string `json:"name"`
Reference bool `json:"reference"`
Position int `json:"position"`
URL string `json:"url"`
TokenID string `json:"tokenId"`
Secret string `json:"secret,omitempty"` // write only
HasSecret bool `json:"hasSecret"`
Fingerprint string `json:"fingerprint"`
CAPEM string `json:"caPem"`
Insecure bool `json:"insecure"`
Timeout string `json:"timeout"`
Mapping map[model.Kind]map[string]map[string]any `json:"mapping"`
Policies map[model.Kind]map[string]adapter.Policy `json:"policies"`
UpdatedAt time.Time `json:"updatedAt"`
// Cluster roles: one primary (the source, never written), targets (replicated)
// and disabled clusters (kept, not read).
const (
ClusterPrimary = "primary"
ClusterTarget = "target"
ClusterDisabled = "disabled"
)
// TLS modes of a cluster.
const (
TLSPin = "fp" // pinned certificate (recorded on the first connection when empty)
TLSCA = "ca" // CA bundle
TLSSystem = "sys" // system authorities
TLSInsecure = "insecure" // no verification (lab only)
)
// Check is the result of the last connection test of a cluster.
type Check struct {
At time.Time `json:"at"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Version string `json:"version,omitempty"`
Nodes []string `json:"nodes,omitempty"`
API bool `json:"api"`
SSH bool `json:"ssh"`
}
// Clusters returns every cluster, reference first, without secrets.
// Cluster is a cluster as stored. The password is never returned to the UI:
// HasPassword only says whether one is set.
type Cluster struct {
Name string `json:"name"`
Role string `json:"role"`
Position int `json:"position"`
URL string `json:"url"`
User string `json:"user"`
Password string `json:"password,omitempty"` // write only
HasPassword bool `json:"hasPassword"`
TLS string `json:"tls"`
Fingerprint string `json:"fingerprint"`
CAPEM string `json:"caPem"`
Timeout string `json:"timeout"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHHostKey string `json:"sshHostKey"` // read only (pinned by the connection test)
SSHFingerprint string `json:"sshFingerprint"` // read only
Mapping map[model.Kind]map[string]map[string]any `json:"mapping"`
Policies map[model.Kind]map[string]adapter.Policy `json:"policies"`
LastCheck *Check `json:"lastCheck,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
const clusterCols = `name, role, position, url, api_user, password, tls, fingerprint, ca_pem, timeout,
ssh_host, ssh_port, ssh_hostkey, mapping, policies, last_check, updated_at`
func scanCluster(scan func(...any) error) (Cluster, []byte, error) {
var c Cluster
var blob []byte
var mapping, policies, check string
var updated int64
err := scan(&c.Name, &c.Role, &c.Position, &c.URL, &c.User, &blob, &c.TLS, &c.Fingerprint, &c.CAPEM, &c.Timeout,
&c.SSHHost, &c.SSHPort, &c.SSHHostKey, &mapping, &policies, &check, &updated)
if err != nil {
return c, nil, err
}
c.HasPassword = len(blob) > 0
c.SSHFingerprint = sshx.Fingerprint(c.SSHHostKey)
c.UpdatedAt = time.Unix(updated, 0).UTC()
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
_ = json.Unmarshal([]byte(policies), &c.Policies)
if check != "" {
c.LastCheck = &Check{}
_ = json.Unmarshal([]byte(check), c.LastCheck)
}
return c, blob, nil
}
// Clusters returns every cluster, primary first, without passwords.
func (s *Store) Clusters(ctx context.Context) ([]Cluster, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name, reference, position, url, token_id, length(secret) > 0,
fingerprint, ca_pem, insecure, timeout, mapping, policies, updated_at
FROM clusters ORDER BY reference DESC, position, name`)
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters
ORDER BY role = 'primary' DESC, position, name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Cluster
for rows.Next() {
var c Cluster
var hasSecret sql.NullBool
var mapping, policies string
var updated int64
if err := rows.Scan(&c.Name, &c.Reference, &c.Position, &c.URL, &c.TokenID, &hasSecret,
&c.Fingerprint, &c.CAPEM, &c.Insecure, &c.Timeout, &mapping, &policies, &updated); err != nil {
c, _, err := scanCluster(rows.Scan)
if err != nil {
return nil, err
}
c.HasSecret = hasSecret.Bool
c.UpdatedAt = time.Unix(updated, 0).UTC()
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
_ = json.Unmarshal([]byte(policies), &c.Policies)
out = append(out, c)
}
return out, rows.Err()
}
// SaveCluster creates or updates a cluster. An empty Secret keeps the stored one.
// oldName renames a cluster (empty: same name). Setting Reference on a cluster
// makes it the only reference.
// Cluster returns one cluster with its decrypted connection (for a test).
func (s *Store) Cluster(ctx context.Context, name string) (Cluster, config.Cluster, error) {
c, blob, err := scanCluster(s.db.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, name).Scan)
if errors.Is(err, sql.ErrNoRows) {
return c, config.Cluster{}, ErrNotFound
}
if err != nil {
return c, config.Cluster{}, err
}
cc, err := s.connection(c, blob)
return c, cc, err
}
func (s *Store) connection(c Cluster, blob []byte) (config.Cluster, error) {
pass, err := s.box.Open(blob)
if err != nil {
return config.Cluster{}, fmt.Errorf("cluster %s: password: %w", c.Name, err)
}
cc := config.Cluster{Name: c.Name, URL: c.URL, User: c.User, Password: string(pass), Timeout: c.Timeout,
SSHHost: c.SSHHost, SSHPort: c.SSHPort, SSHHostKey: c.SSHHostKey}
switch c.TLS {
case TLSPin:
cc.Fingerprint = c.Fingerprint
case TLSCA:
cc.CAPEM = c.CAPEM
case TLSInsecure:
cc.Insecure = true
}
return cc, nil
}
// ValidClusterRole tells whether r is a cluster role.
func ValidClusterRole(r string) bool {
return r == ClusterPrimary || r == ClusterTarget || r == ClusterDisabled
}
// SaveCluster creates or updates a cluster. An empty Password keeps the stored
// one. oldName renames a cluster (empty: same name). A primary cluster makes
// the previous primary a target. The pinned TLS certificate and SSH host key
// are forgotten when the address they belong to changes.
func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) error {
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" || c.URL == "" || c.TokenID == "" {
return errors.New("name, url and token id are required")
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
if c.Name == "" || c.URL == "" {
return errors.New("name and url are required")
}
if c.User == "" {
c.User = "root@pam"
}
if c.Role == "" {
c.Role = ClusterTarget
}
if !ValidClusterRole(c.Role) {
return fmt.Errorf("role %q: primary, target or disabled expected", c.Role)
}
switch c.TLS {
case "":
c.TLS = TLSPin
case TLSPin, TLSCA, TLSSystem, TLSInsecure:
default:
return fmt.Errorf("tls %q: fp, ca, sys or insecure expected", c.TLS)
}
if c.TLS == TLSCA && strings.TrimSpace(c.CAPEM) == "" {
return errors.New("the CA certificate (PEM) is required")
}
if c.SSHPort == 0 {
c.SSHPort = 22
}
if oldName == "" {
oldName = c.Name
@@ -195,36 +343,47 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
return err
}
defer tx.Rollback()
var exists bool
var secret []byte
err = tx.QueryRowContext(ctx, `SELECT 1, secret FROM clusters WHERE name = ?`, oldName).Scan(&exists, &secret)
old, blob, err := scanCluster(tx.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, oldName).Scan)
exists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if c.Secret != "" {
if secret, err = s.box.Seal([]byte(c.Secret)); err != nil {
if c.Password != "" {
if blob, err = s.box.Seal([]byte(c.Password)); err != nil {
return err
}
}
if len(secret) == 0 {
return errors.New("the token secret is required")
if len(blob) == 0 {
return errors.New("the root password is required")
}
if c.Reference {
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil {
hostKey := ""
if exists {
hostKey = old.SSHHostKey
if old.URL != c.URL && c.Fingerprint == old.Fingerprint {
c.Fingerprint = "" // another server: pinned again on the next test
}
if sshTarget(old) != sshTarget(c) {
hostKey = ""
}
}
if c.Role == ClusterPrimary {
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary'`); err != nil {
return err
}
}
now := time.Now().Unix()
if exists {
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, reference=?, position=?, url=?, token_id=?, secret=?,
fingerprint=?, ca_pem=?, insecure=?, timeout=?, mapping=?, policies=?, updated_at=? WHERE name=?`,
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout,
string(mapping), string(policies), now, oldName)
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, role=?, position=?, url=?, api_user=?, password=?, tls=?,
fingerprint=?, ca_pem=?, timeout=?, ssh_host=?, ssh_port=?, ssh_hostkey=?, mapping=?, policies=?, updated_at=?
WHERE name=?`,
c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
c.SSHHost, c.SSHPort, hostKey, string(mapping), string(policies), now, oldName)
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, reference, position, url, token_id, secret, fingerprint,
ca_pem, insecure, timeout, mapping, policies, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout,
string(mapping), string(policies), now)
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, role, position, url, api_user, password, tls, fingerprint,
ca_pem, timeout, ssh_host, ssh_port, ssh_hostkey, mapping, policies, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
c.SSHHost, c.SSHPort, "", string(mapping), string(policies), now)
}
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
@@ -235,6 +394,68 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
return tx.Commit()
}
// sshTarget is the SSH endpoint of a cluster, to detect a change of node.
func sshTarget(c Cluster) string {
host := c.SSHHost
if host == "" {
host = c.URL
}
return fmt.Sprintf("%s|%d", host, c.SSHPort)
}
// SetRole changes the role of a cluster (a new primary demotes the previous one).
func (s *Store) SetRole(ctx context.Context, name, role string) error {
if !ValidClusterRole(role) {
return fmt.Errorf("role %q: primary, target or disabled expected", role)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if role == ClusterPrimary {
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary' AND name <> ?`, name); err != nil {
return err
}
}
res, err := tx.ExecContext(ctx, `UPDATE clusters SET role = ?, updated_at = ? WHERE name = ?`, role, time.Now().Unix(), name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return tx.Commit()
}
// Pin records the TLS fingerprint and/or the SSH host key seen on the first
// connection (empty values are left unchanged), and the result of the test.
func (s *Store) Pin(ctx context.Context, name, fingerprint, hostKey string, check *Check) error {
var chk []byte
if check != nil {
chk, _ = json.Marshal(check)
}
_, err := s.db.ExecContext(ctx, `UPDATE clusters SET
fingerprint = CASE WHEN ? <> '' THEN ? ELSE fingerprint END,
ssh_hostkey = CASE WHEN ? <> '' THEN ? ELSE ssh_hostkey END,
last_check = CASE WHEN ? <> '' THEN ? ELSE last_check END
WHERE name = ?`, fingerprint, fingerprint, hostKey, hostKey, string(chk), string(chk), name)
return err
}
// Forget clears the pinned TLS fingerprint and SSH host key (pinned again on the next test).
func (s *Store) Forget(ctx context.Context, name string) error {
res, err := s.db.ExecContext(ctx, `UPDATE clusters SET fingerprint = CASE WHEN tls = 'fp' THEN '' ELSE fingerprint END,
ssh_hostkey = '' WHERE name = ?`, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteCluster removes a cluster.
func (s *Store) DeleteCluster(ctx context.Context, name string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM clusters WHERE name = ?`, name)
@@ -304,43 +525,38 @@ func (s *Store) SaveSettings(ctx context.Context, st Settings) error {
// ---- configuration for the engine ----
// Config builds the engine configuration, secrets decrypted. It fails when no
// reference is defined.
// Config builds the engine configuration, passwords decrypted: the primary
// cluster and the targets (disabled clusters are left aside). It fails when no
// primary is defined.
func (s *Store) Config(ctx context.Context) (*config.Config, error) {
st, err := s.Settings(ctx)
if err != nil {
return nil, err
}
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune}
rows, err := s.db.QueryContext(ctx, `SELECT name, reference, url, token_id, secret, fingerprint, ca_pem,
insecure, timeout, mapping, policies FROM clusters ORDER BY reference DESC, position, name`)
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters ORDER BY position, name`)
if err != nil {
return nil, err
}
defer rows.Close()
hasRef := false
for rows.Next() {
var ref bool
var blob []byte
var mapping, policies string
var cl config.Cluster
if err := rows.Scan(&cl.Name, &ref, &cl.URL, &cl.TokenID, &blob, &cl.Fingerprint, &cl.CAPEM,
&cl.Insecure, &cl.Timeout, &mapping, &policies); err != nil {
c, blob, err := scanCluster(rows.Scan)
if err != nil {
return nil, err
}
secret, err := s.box.Open(blob)
if err != nil {
return nil, fmt.Errorf("cluster %s: token secret: %w", cl.Name, err)
}
cl.Secret = string(secret)
if ref {
cfg.Reference, hasRef = cl, true
if c.Role == ClusterDisabled {
continue
}
c := config.Client{Cluster: cl}
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
_ = json.Unmarshal([]byte(policies), &c.Policies)
cfg.Clients = append(cfg.Clients, c)
cc, err := s.connection(c, blob)
if err != nil {
return nil, err
}
if c.Role == ClusterPrimary {
cfg.Reference, hasRef = cc, true
continue
}
cfg.Clients = append(cfg.Clients, config.Client{Cluster: cc, Mapping: c.Mapping, Policies: c.Policies})
}
if err := rows.Err(); err != nil {
return nil, err
@@ -351,96 +567,5 @@ func (s *Store) Config(ctx context.Context) (*config.Config, error) {
return cfg, cfg.Validate()
}
// ErrNoReference: nothing can be compared before a reference cluster is defined.
var ErrNoReference = errors.New("no reference cluster defined")
// ExportConfig returns the configuration as the CLI YAML file, without secrets:
// each cluster gets a token_secret_env placeholder to fill.
func (s *Store) ExportConfig(ctx context.Context) (*config.Config, error) {
st, err := s.Settings(ctx)
if err != nil {
return nil, err
}
cls, err := s.Clusters(ctx)
if err != nil {
return nil, err
}
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune}
for _, c := range cls {
cl := config.Cluster{Name: c.Name, URL: c.URL, TokenID: c.TokenID, Fingerprint: c.Fingerprint,
Insecure: c.Insecure, Timeout: c.Timeout, TokenSecretEnv: EnvName(c.Name)}
if c.Reference {
cfg.Reference = cl
continue
}
cfg.Clients = append(cfg.Clients, config.Client{Cluster: cl, Mapping: c.Mapping, Policies: c.Policies})
}
return cfg, nil
}
// EnvName is the environment variable proposed for the secret of a cluster in an export.
func EnvName(cluster string) string {
var b strings.Builder
b.WriteString("PVE_")
for _, r := range strings.ToUpper(cluster) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
b.WriteString("_SECRET")
return b.String()
}
// ImportConfig loads a CLI configuration: settings are replaced, clusters are
// created or updated (the ones absent from the file are kept unless replace is
// set). Secrets are read from token_secret_env / token_secret_file when they
// are reachable from the server; otherwise the stored secret is kept.
// It returns warnings for the clusters left without a secret.
func (s *Store) ImportConfig(ctx context.Context, cfg *config.Config, replace bool) ([]string, error) {
var warns []string
st := Settings{Kinds: cfg.Kinds, Objects: cfg.Objects, Policies: cfg.Policies, Prune: cfg.Prune}
existing, err := s.Clusters(ctx)
if err != nil {
return nil, err
}
have := map[string]Cluster{}
for _, c := range existing {
have[c.Name] = c
}
save := func(cl config.Cluster, ref bool, pos int, mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error {
c := Cluster{Name: cl.Name, Reference: ref, Position: pos, URL: cl.URL, TokenID: cl.TokenID,
Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: cl.Timeout, Mapping: mapping, Policies: pol}
if cl.CAFile != "" {
warns = append(warns, fmt.Sprintf("%s: ca_file %s not imported, paste the CA certificate in the cluster settings", cl.Name, cl.CAFile))
}
if sec, err := cl.ResolveSecret(); err == nil && sec != "" {
c.Secret = sec
} else if !have[cl.Name].HasSecret {
warns = append(warns, fmt.Sprintf("%s: token secret not readable from the server, cluster not imported (%v)", cl.Name, err))
return nil
}
return s.SaveCluster(ctx, "", c)
}
if err := save(cfg.Reference, true, 0, nil, nil); err != nil {
return warns, err
}
keep := map[string]bool{cfg.Reference.Name: true}
for i, c := range cfg.Clients {
keep[c.Name] = true
if err := save(c.Cluster, false, i+1, c.Mapping, c.Policies); err != nil {
return warns, err
}
}
if replace {
for name := range have {
if !keep[name] {
if err := s.DeleteCluster(ctx, name); err != nil {
return warns, err
}
}
}
}
return warns, s.SaveSettings(ctx, st)
}
// ErrNoReference: nothing can be compared before a primary cluster is chosen.
var ErrNoReference = errors.New("no primary cluster defined")