Full web : fichiers restants
This commit is contained in:
1 parent
ec3b7c9a01
commit
4826fa5b82
26 files changed
+2537
-793
No files matched your search
@@ -0,0 +1,137 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
)
|
||||
|
||||
const aliceHash = "$5$salt$aliceHashValue"
|
||||
|
||||
func accessRef(t *testing.T) *pvetest.Server {
|
||||
ref := pvetest.New("9.1.4")
|
||||
t.Cleanup(ref.Close)
|
||||
ref.Groups["admins"] = map[string]any{"comment": "Admins"}
|
||||
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
|
||||
ref.Roles["Ops"] = "VM.PowerMgmt,Sys.Audit"
|
||||
ref.Users["alice@pve"] = map[string]any{"enable": 1, "email": "alice@example.org", "groups": "ops,admins"}
|
||||
ref.Users["bob@pve"] = map[string]any{"enable": 1, "groups": "ops", "expire": 0}
|
||||
ref.Users["carol@pam"] = map[string]any{"enable": 1}
|
||||
ref.Shadow["alice"] = aliceHash
|
||||
ref.Shadow["bob"] = "$5$salt$bobHash"
|
||||
ref.Shadow["orphan"] = "$5$salt$orphan" // no account: left aside
|
||||
return ref
|
||||
}
|
||||
|
||||
var accessKinds = []model.Kind{model.KindGroup, model.KindRole, model.KindUser, model.KindPassword}
|
||||
|
||||
func TestAccountsAndPasswords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ref := accessRef(t)
|
||||
cli := pvetest.New("9.0.10")
|
||||
defer cli.Close()
|
||||
cli.Users["alice@pve"] = map[string]any{"enable": 1, "email": "old@example.org", "firstname": "Al"}
|
||||
cli.Shadow["alice"] = "$5$salt$oldHash"
|
||||
|
||||
cfg := &config.Config{Kinds: accessKinds, Reference: config.Cluster{Name: "ref"}, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := &cfg.Clients[0]
|
||||
src, err := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, o := range src.Objects {
|
||||
if o.ID == "carol@pam" || o.ID == "orphan@pve" || o.ID == "Administrator" || o.ID == "root@pam" {
|
||||
t.Fatalf("%s must not be read", o.Key())
|
||||
}
|
||||
}
|
||||
cc := client(t, cli, "site")
|
||||
cur, err := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := MakePlan(cfg, cl, src.Objects, cur.Objects)
|
||||
if len(plan.Errors) > 0 {
|
||||
t.Fatal(plan.Errors)
|
||||
}
|
||||
// 2 groups + 1 role + bob created, alice updated + 2 passwords
|
||||
if len(plan.Changes) != 7 {
|
||||
t.Fatalf("changes = %+v", plan.Changes)
|
||||
}
|
||||
b, _ := json.Marshal(plan)
|
||||
if strings.Contains(string(b), "aliceHash") || strings.Contains(string(b), "oldHash") {
|
||||
t.Fatalf("password hash in the plan JSON: %s", b)
|
||||
}
|
||||
res := Apply(ctx, cc, plan)
|
||||
if res.Err != nil {
|
||||
t.Fatalf("apply: %v (%+v)", res.Err, res.Steps)
|
||||
}
|
||||
if cli.Shadow["alice"] != aliceHash || cli.Shadow["bob"] != "$5$salt$bobHash" {
|
||||
t.Fatalf("shadow = %v", cli.Shadow)
|
||||
}
|
||||
a := cli.Users["alice@pve"]
|
||||
if a["email"] != "alice@example.org" || a["groups"] != "admins,ops" || a["firstname"] != nil {
|
||||
t.Fatalf("alice = %v", a)
|
||||
}
|
||||
if cli.Roles["Ops"] != "Sys.Audit,VM.PowerMgmt" {
|
||||
t.Fatalf("roles = %v", cli.Roles)
|
||||
}
|
||||
replan := func(actual []model.Object) Plan { return MakePlan(cfg, cl, src.Objects, actual) }
|
||||
if err := Verify(ctx, cc, replan, cfg.Kinds); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordSelectionPullsAccountAndGroups(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ref := accessRef(t)
|
||||
cli := pvetest.New("9.1.0")
|
||||
defer cli.Close()
|
||||
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
|
||||
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
|
||||
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
|
||||
p := Filter(MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects), []string{"pve-password/bob@pve"})
|
||||
var keys []string
|
||||
for _, c := range p.Changes {
|
||||
keys = append(keys, c.Key())
|
||||
}
|
||||
if got := strings.Join(keys, " "); got != "pve-group/ops pve-user/bob@pve pve-password/bob@pve" {
|
||||
t.Fatalf("selection = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingParentIsAnError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ref := accessRef(t)
|
||||
cli := pvetest.New("9.1.0")
|
||||
defer cli.Close()
|
||||
// groups excluded from the selection: alice cannot be created in them
|
||||
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}},
|
||||
Objects: map[model.Kind]config.Selection{model.KindGroup: {Exclude: []string{"*"}}}}
|
||||
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
|
||||
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
|
||||
plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)
|
||||
if len(plan.Errors) != 3 || !strings.Contains(strings.Join(plan.Errors, "\n"), "pve-user/alice@pve: pve-group/admins absent") {
|
||||
t.Fatalf("errors = %v", plan.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordsNeedSSH(t *testing.T) {
|
||||
s := pvetest.New("9.1.0")
|
||||
defer s.Close()
|
||||
c, err := config.Cluster{Name: "c", URL: s.URL, Password: pvetest.Password, Insecure: true}.PVE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Read(context.Background(), c, accessKinds, adapter.ReadCurrent); err == nil || !strings.Contains(err.Error(), "SSH") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
+78
-11
@@ -4,6 +4,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -42,6 +43,9 @@ func Read(ctx context.Context, c *pve.Client, kinds []model.Kind, mode adapter.R
|
||||
if need[model.KindVNet] {
|
||||
need[model.KindZone] = true
|
||||
}
|
||||
if need[model.KindPassword] {
|
||||
need[model.KindUser] = true
|
||||
}
|
||||
var all []model.Object
|
||||
for _, a := range adapter.All() {
|
||||
if !need[a.Kind()] {
|
||||
@@ -74,21 +78,43 @@ const (
|
||||
|
||||
// Change is one planned action. Set holds the new values, Old the current ones.
|
||||
type Change struct {
|
||||
Kind model.Kind `json:"kind" yaml:"kind"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Op Op `json:"op" yaml:"op"`
|
||||
Set map[string]any `json:"set,omitempty" yaml:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty" yaml:"unset,omitempty"`
|
||||
Old map[string]any `json:"old,omitempty" yaml:"old,omitempty"`
|
||||
Target model.Object `json:"-" yaml:"-"`
|
||||
Kind model.Kind `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Op Op `json:"op"`
|
||||
Set map[string]any `json:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty"`
|
||||
Old map[string]any `json:"old,omitempty"`
|
||||
Target model.Object `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON hides the secret fields (password hashes): plans are shown in the
|
||||
// UI and kept in the journal, the real values only live in memory until applied.
|
||||
func (c Change) MarshalJSON() ([]byte, error) {
|
||||
type plain Change
|
||||
red := func(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
if adapter.IsSensitive(c.Kind, k) {
|
||||
v = adapter.Redact(v)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
p := plain(c)
|
||||
p.Set, p.Old = red(c.Set), red(c.Old)
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// Plan is the list of changes for one client, in execution order.
|
||||
type Plan struct {
|
||||
Client string `json:"client" yaml:"client"`
|
||||
Changes []Change `json:"changes" yaml:"changes"`
|
||||
Errors []string `json:"errors,omitempty" yaml:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"`
|
||||
Client string `json:"client"`
|
||||
Changes []Change `json:"changes"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// HasSDN tells whether the plan writes SDN objects (lock + reload needed).
|
||||
@@ -187,6 +213,7 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkParents(&plan, cl.Name, actual)
|
||||
// deletions last, dependents first (subnets, vnets, zones)
|
||||
sort.SliceStable(deletes, func(i, j int) bool {
|
||||
if deletes[i].Kind != deletes[j].Kind {
|
||||
@@ -198,6 +225,46 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
|
||||
return plan
|
||||
}
|
||||
|
||||
// checkParents blocks the objects whose parents (groups of an account, account
|
||||
// of a password) are neither on the target nor created by the plan.
|
||||
func checkParents(plan *Plan, client string, actual []model.Object) {
|
||||
have := map[string]bool{}
|
||||
for _, o := range actual {
|
||||
have[o.Key()] = true
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
if c.Op == OpCreate {
|
||||
have[c.Key()] = true
|
||||
}
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
if c.Op == OpDelete {
|
||||
continue
|
||||
}
|
||||
for _, p := range adapter.Parents(Desired(c)) {
|
||||
if !have[p] {
|
||||
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: %s absent on target %s and not replicated", c.Key(), p, client))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desired is the object as it will be after the change (fields of the target
|
||||
// with the new values).
|
||||
func Desired(c Change) model.Object {
|
||||
f := map[string]any{}
|
||||
for k, v := range c.Target.Fields {
|
||||
f[k] = v
|
||||
}
|
||||
for _, k := range c.Unset {
|
||||
delete(f, k)
|
||||
}
|
||||
for k, v := range c.Set {
|
||||
f[k] = v
|
||||
}
|
||||
return model.Object{Kind: c.Kind, ID: c.ID, Fields: f}
|
||||
}
|
||||
|
||||
func kindRank(k model.Kind) int {
|
||||
for i, x := range model.Kinds {
|
||||
if x == k {
|
||||
|
||||
@@ -14,17 +14,23 @@ import (
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
)
|
||||
|
||||
// client connects to a fake cluster like the web UI does: API as root@pam,
|
||||
// pinned certificate, SSH with the pinned host key.
|
||||
func client(t *testing.T, s *pvetest.Server, name string) *pve.Client {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(s.Certificate().Raw)
|
||||
c, err := pve.New(pve.Options{Name: name, BaseURL: s.URL, TokenID: "confsync@pve!t", TokenSecret: "secret",
|
||||
Fingerprint: hex.EncodeToString(sum[:])})
|
||||
host, port := s.SSHAddr()
|
||||
c, err := config.Cluster{Name: name, URL: s.URL, User: "root@pam", Password: pvetest.Password,
|
||||
Fingerprint: hex.EncodeToString(sum[:]), SSHHost: host, SSHPort: port, SSHHostKey: s.SSHHostKey()}.PVE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// sdnKinds: the kinds of the SDN and datacenter tests.
|
||||
var sdnKinds = []model.Kind{model.KindDatacenter, model.KindZone, model.KindVNet, model.KindSubnet}
|
||||
|
||||
// reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags.
|
||||
func reference(t *testing.T) *pvetest.Server {
|
||||
ref := pvetest.New("9.1.4")
|
||||
@@ -49,7 +55,7 @@ func reference(t *testing.T) *pvetest.Server {
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Kinds: model.Kinds,
|
||||
Kinds: sdnKinds,
|
||||
Clients: []config.Client{{
|
||||
Cluster: config.Cluster{Name: "site-a"},
|
||||
Mapping: map[model.Kind]map[string]map[string]any{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/model"
|
||||
)
|
||||
|
||||
@@ -50,6 +51,11 @@ func Filter(p Plan, keys []string) Plan {
|
||||
if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate {
|
||||
add(string(model.KindVNet) + "/" + v)
|
||||
}
|
||||
for _, p := range adapter.Parents(Desired(c)) {
|
||||
if byKey[p].Op == OpCreate {
|
||||
add(p)
|
||||
}
|
||||
}
|
||||
case OpDelete:
|
||||
// children deleted in the same plan must go first
|
||||
for _, d := range p.Changes {
|
||||
@@ -79,6 +85,9 @@ func Filter(p Plan, keys []string) Plan {
|
||||
if v, ok := c.Set["vnet"].(string); ok {
|
||||
blocking[string(model.KindVNet)+"/"+v] = true
|
||||
}
|
||||
for _, p := range adapter.Parents(Desired(c)) {
|
||||
blocking[p] = true
|
||||
}
|
||||
}
|
||||
out := Plan{Client: p.Client, Warnings: p.Warnings}
|
||||
// errors about other objects ("kind/id: ...") do not block this selection
|
||||
|
||||
Reference in new issue
Block a user