From 5d980514b8ea66d06d2052aa0837be6ddc4850f7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:16:52 +0200 Subject: [PATCH 1/7] f-15: code: add generate dhcp file Signed-off-by: GnomeZworc --- internal/dhcp/generate.go | 47 +++++++++++++++++++++++++++++++++++++++ internal/dhcp/struct.go | 12 ++++++++++ 2 files changed, 59 insertions(+) create mode 100644 internal/dhcp/generate.go create mode 100644 internal/dhcp/struct.go diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go new file mode 100644 index 0000000..01bfe20 --- /dev/null +++ b/internal/dhcp/generate.go @@ -0,0 +1,47 @@ +package dhcp + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strings" +) + +func GenerateConfig(c Config) (string, error) { + mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3]) + + var sb strings.Builder + fmt.Fprintf(&sb, "no-resolv\n") + fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) + fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String()) + fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n") + + i := 0 + for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { + fmt.Fprintf(&sb, "dhcp-host=00:22:33:%02X:%02X:%02X,%s\n", + (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF, ip) + i++ + } + + outPath := filepath.Join(c.ConfDir, c.Name+".conf") + if err := os.MkdirAll(c.ConfDir, 0755); err != nil { + return "", err + } + return outPath, os.WriteFile(outPath, []byte(sb.String()), 0644) +} + +func incrementIP(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] != 0 { + break + } + } +} + +func cloneIP(ip net.IP) net.IP { + clone := make(net.IP, len(ip)) + copy(clone, ip) + return clone +} diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go new file mode 100644 index 0000000..4c69b9c --- /dev/null +++ b/internal/dhcp/struct.go @@ -0,0 +1,12 @@ +package dhcp + +import ( + "net" +) + +type Config struct { + Network *net.IPNet + Gateway net.IP + Name string + ConfDir string +} From a346876cfb8e308d2e3aed526b01f0bb173ea67e Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:19:34 +0200 Subject: [PATCH 2/7] f-15: code add code for binary Signed-off-by: GnomeZworc --- cmd/dhcp/main.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 cmd/dhcp/main.go diff --git a/cmd/dhcp/main.go b/cmd/dhcp/main.go new file mode 100644 index 0000000..b2ce08d --- /dev/null +++ b/cmd/dhcp/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + + "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/pkg/systemd" +) + +func main() { + subnet := flag.String("subnet", "", "Subnet CIDR (e.g. 10.10.10.0/24)") + name := flag.String("name", "", "Config name (e.g. vpc1_br-00002)") + gateway := flag.String("gateway", "", "Gateway IP (e.g. 10.10.10.1)") + confDir := flag.String("confdir", "/etc/dnsmasq.d", "dnsmasq config directory") + flag.Parse() + + if *subnet == "" || *name == "" || *gateway == "" { + flag.Usage() + os.Exit(1) + } + + _, network, err := net.ParseCIDR(*subnet) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid subnet: %v\n", err) + os.Exit(1) + } + + gw := net.ParseIP(*gateway) + if gw == nil { + fmt.Fprintf(os.Stderr, "invalid gateway IP: %q\n", *gateway) + os.Exit(1) + } + + conf := dhcp.Config{ + Network: network, + Gateway: gw, + Name: *name, + ConfDir: *confDir, + } + + confPath, err := dhcp.GenerateConfig(conf) + if err != nil { + fmt.Fprintf(os.Stderr, "error generating config: %v\n", err) + os.Exit(1) + } + fmt.Printf("dnsmasq config written to %s\n", confPath) + + svc, err := systemd.New() + if err != nil { + fmt.Fprintf(os.Stderr, "error connecting to systemd: %v\n", err) + os.Exit(1) + } + defer svc.Close() + + unit := "dnsmasq@" + *name + ".service" + if err := svc.Start(unit); err != nil { + fmt.Fprintf(os.Stderr, "error starting %s: %v\n", unit, err) + os.Exit(1) + } + fmt.Printf("started %s\n", unit) +} From cfbac0034b5ab9e73b975d55282a58d142d6f8f5 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:20:24 +0200 Subject: [PATCH 3/7] f-15: ci: add dhcp binary build Signed-off-by: GnomeZworc --- .forgejo/workflows/prerelease.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/prerelease.yml b/.forgejo/workflows/prerelease.yml index f20ca66..8b2fe2d 100644 --- a/.forgejo/workflows/prerelease.yml +++ b/.forgejo/workflows/prerelease.yml @@ -38,6 +38,7 @@ jobs: - metacli - agent - vpc + - dhcp uses: ./.forgejo/workflows/build.yml with: tag: ${{ needs.set-release-target.outputs.release_cible }} @@ -51,4 +52,4 @@ jobs: uses: ./.forgejo/workflows/release.yml with: tag: ${{ needs.set-release-target.outputs.release_cible }} - secrets: inherit \ No newline at end of file + secrets: inherit From 02e558e0e2cc59b7768117ef299f507cc5d7bf64 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:20:51 +0200 Subject: [PATCH 4/7] f-15: systemd: add systemd unit for dhcp Signed-off-by: GnomeZworc --- systemd/dnsmasq@.service | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 systemd/dnsmasq@.service diff --git a/systemd/dnsmasq@.service b/systemd/dnsmasq@.service new file mode 100644 index 0000000..0f9d72e --- /dev/null +++ b/systemd/dnsmasq@.service @@ -0,0 +1,11 @@ +[Unit] +Description=dnsmasq in netns %i +After=network.target + +[Service] +Type=simple +ExecStart=/opt/two/bin/run-dnsmasq-in-netns.sh %i +ExecStopPost=/bin/rm -f /run/dnsmasq-%i.pid + +[Install] +WantedBy=multi-user.target From deac1afe9fd1230dfa1d3a598ec54c2dcb47db78 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:21:27 +0200 Subject: [PATCH 5/7] f-15: script: add script for running dhcp in netns Signed-off-by: GnomeZworc --- scripts/run-dnsmasq-in-netns.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scripts/run-dnsmasq-in-netns.sh diff --git a/scripts/run-dnsmasq-in-netns.sh b/scripts/run-dnsmasq-in-netns.sh new file mode 100644 index 0000000..c0f9253 --- /dev/null +++ b/scripts/run-dnsmasq-in-netns.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# Expects one argument: netns_bridge (e.g. vpc-00003_br-00002 or vpc1_br0) +arg="$1" +NETNS="${arg%%_*}" +BRIDGE="${arg#*_}" + +echo "start dnsmasq ${NETNS} ${BRIDGE}" + +exec ip netns exec "${NETNS}" \ + dnsmasq \ + --no-daemon \ + --interface="${BRIDGE}" \ + --bind-interfaces \ + --pid-file="/run/dnsmasq-$arg.pid" \ + --conf-file="/etc/dnsmasq.d/$arg.conf" \ + --no-hosts \ + --no-resolv \ + --log-facility="/var/log/dnsmasq-$arg.log" \ + --no-daemon -p0 From 3288a2a413e35379185e80d992564117ac5e1a5a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 30 Mar 2026 23:30:18 +0200 Subject: [PATCH 6/7] f-15: ci: add upload scripts Signed-off-by: GnomeZworc --- .forgejo/workflows/prerelease.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.forgejo/workflows/prerelease.yml b/.forgejo/workflows/prerelease.yml index 8b2fe2d..f9b8a43 100644 --- a/.forgejo/workflows/prerelease.yml +++ b/.forgejo/workflows/prerelease.yml @@ -46,6 +46,24 @@ jobs: goarch: ${{ matrix.goarch }} binari: ${{ matrix.binaries }} secrets: inherit + upload-scripts: + runs-on: docker + needs: [set-release-target] + strategy: + matrix: + script: + - run-dnsmasq-in-netns.sh + steps: + - uses: actions/checkout@v3 + - name: Move asset + run: | + mkdir -p "dist" + cp scripts/${{ matrix.script }} dist/ + - name: Upload script + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.script }}-${{ needs.set-release-target.outputs.release_cible }} + path: dist/${{ matrix.script }} prerelease: runs-on: docker needs: [set-release-target, build] From c7d20b412429b7976b5f9257a3b8e4bdc6d2bbe1 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 31 Mar 2026 20:03:52 +0200 Subject: [PATCH 7/7] f-19: test: ajouter des test Signed-off-by: GnomeZworc --- internal/config/agent/config_test.go | 83 +++++++++++ internal/dhcp/dhcp_test.go | 157 +++++++++++++++++++++ internal/metadata/metadata_test.go | 200 +++++++++++++++++++++++++++ pkg/db/kv/kv_test.go | 153 ++++++++++++++++++++ 4 files changed, 593 insertions(+) create mode 100644 internal/config/agent/config_test.go create mode 100644 internal/dhcp/dhcp_test.go create mode 100644 internal/metadata/metadata_test.go create mode 100644 pkg/db/kv/kv_test.go diff --git a/internal/config/agent/config_test.go b/internal/config/agent/config_test.go new file mode 100644 index 0000000..d0f3d31 --- /dev/null +++ b/internal/config/agent/config_test.go @@ -0,0 +1,83 @@ +package configuration + +import ( + "os" + "path/filepath" + "testing" +) + +func writeYAML(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("impossible d'écrire le fichier de config : %v", err) + } + return path +} + +// --- LoadConfig --- + +func TestLoadConfig_ValidFile(t *testing.T) { + path := writeYAML(t, ` +database: + path: /tmp/mydb +`) + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig a échoué : %v", err) + } + if cfg.Database.Path != "/tmp/mydb" { + t.Errorf("database.path attendu %q, obtenu %q", "/tmp/mydb", cfg.Database.Path) + } +} + +func TestLoadConfig_DefaultPath(t *testing.T) { + // Fichier vide → viper applique la valeur par défaut + path := writeYAML(t, "") + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig a échoué : %v", err) + } + if cfg.Database.Path != "/var/lib/two/data/" { + t.Errorf("valeur par défaut attendue %q, obtenu %q", "/var/lib/two/data/", cfg.Database.Path) + } +} + +func TestLoadConfig_MissingFile_UsesDefaults(t *testing.T) { + // Fichier inexistant : viper ignore l'erreur ReadInConfig et retourne les défauts + cfg, err := LoadConfig("/chemin/inexistant/config.yml") + if err != nil { + t.Fatalf("LoadConfig devrait retourner les défauts si le fichier est absent : %v", err) + } + if cfg.Database.Path != "/var/lib/two/data/" { + t.Errorf("valeur par défaut attendue, obtenu %q", cfg.Database.Path) + } +} + +func TestLoadConfig_PartialConfig_MissingDatabaseKey(t *testing.T) { + // Fichier sans la clé database → valeur par défaut + path := writeYAML(t, ` +autrekey: valeur +`) + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig a échoué : %v", err) + } + if cfg.Database.Path != "/var/lib/two/data/" { + t.Errorf("valeur par défaut attendue, obtenu %q", cfg.Database.Path) + } +} + +func TestLoadConfig_CustomPath(t *testing.T) { + path := writeYAML(t, ` +database: + path: /opt/two/data +`) + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig a échoué : %v", err) + } + if cfg.Database.Path != "/opt/two/data" { + t.Errorf("attendu %q, obtenu %q", "/opt/two/data", cfg.Database.Path) + } +} diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go new file mode 100644 index 0000000..c4a659f --- /dev/null +++ b/internal/dhcp/dhcp_test.go @@ -0,0 +1,157 @@ +package dhcp + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +func parseNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + _, network, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("ParseCIDR(%q) : %v", cidr, err) + } + return network +} + +// --- cloneIP --- + +func TestCloneIP_IsIndependent(t *testing.T) { + ip := net.ParseIP("10.0.0.1").To4() + clone := cloneIP(ip) + clone[3] = 99 + if ip[3] == 99 { + t.Error("cloneIP devrait retourner une copie indépendante") + } +} + +// --- incrementIP --- + +func TestIncrementIP_Simple(t *testing.T) { + ip := net.ParseIP("10.0.0.1").To4() + incrementIP(ip) + if ip.String() != "10.0.0.2" { + t.Errorf("attendu 10.0.0.2, obtenu %s", ip) + } +} + +func TestIncrementIP_Carry(t *testing.T) { + ip := net.ParseIP("10.0.0.255").To4() + incrementIP(ip) + if ip.String() != "10.0.1.0" { + t.Errorf("attendu 10.0.1.0, obtenu %s", ip) + } +} + +// --- GenerateConfig --- + +func newConf(t *testing.T, cidr string) Config { + t.Helper() + _, network, _ := net.ParseCIDR(cidr) + return Config{ + Network: network, + Gateway: net.ParseIP("192.168.1.1").To4(), + Name: "test", + ConfDir: t.TempDir(), + } +} + +func TestGenerateConfig_CreatesFile(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") // 6 hôtes + path, err := GenerateConfig(conf) + if err != nil { + t.Fatalf("GenerateConfig a échoué : %v", err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("le fichier %q n'a pas été créé", path) + } +} + +func TestGenerateConfig_FilenameMatchesName(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + path, err := GenerateConfig(conf) + if err != nil { + t.Fatalf("GenerateConfig a échoué : %v", err) + } + + expected := filepath.Join(conf.ConfDir, "test.conf") + if path != expected { + t.Errorf("chemin attendu %q, obtenu %q", expected, path) + } +} + +func TestGenerateConfig_ContainsGateway(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + path, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") { + t.Errorf("gateway absente du fichier généré :\n%s", content) + } +} + +func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { + _, network, _ := net.ParseCIDR("10.10.0.0/24") + conf := Config{ + Network: network, + Gateway: net.ParseIP("10.10.0.1").To4(), + Name: "vpc1", + ConfDir: t.TempDir(), + } + path, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if !strings.Contains(string(content), "dhcp-range=10.10.0.0,static,255.255.255.0,12h") { + t.Errorf("dhcp-range absent ou incorrect :\n%s", content) + } +} + +func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) { + // /29 = réseau + broadcast + 6 hôtes → 8 adresses + conf := newConf(t, "10.0.0.0/29") + path, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + lines := strings.Split(string(content), "\n") + count := 0 + for _, l := range lines { + if strings.HasPrefix(l, "dhcp-host=") { + count++ + } + } + // /29 contient 8 adresses (0 à 7) + if count != 8 { + t.Errorf("attendu 8 entrées dhcp-host, obtenu %d", count) + } +} + +func TestGenerateConfig_MACPrefix(t *testing.T) { + conf := newConf(t, "10.0.0.0/30") // 4 adresses + path, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if !strings.Contains(string(content), "00:22:33:") { + t.Errorf("préfixe MAC 00:22:33: absent :\n%s", content) + } +} + +func TestGenerateConfig_CreatesConfDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sous", "dossier") + _, network, _ := net.ParseCIDR("10.0.0.0/30") + conf := Config{ + Network: network, + Gateway: net.ParseIP("10.0.0.1").To4(), + Name: "net", + ConfDir: dir, + } + if _, err := GenerateConfig(conf); err != nil { + t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err) + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("répertoire %q non créé", dir) + } +} diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go new file mode 100644 index 0000000..62a2830 --- /dev/null +++ b/internal/metadata/metadata_test.go @@ -0,0 +1,200 @@ +package metadata + +import ( + "strings" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func newCfg() NoCloudConfig { + return NoCloudConfig{ + VpcName: "vpc-test", + BindIP: "169.254.169.254", + BindPort: "80", + Name: "vm1", + Password: "s3cr3t", + SSHKEY: "ssh-ed25519 AAAA... user@host", + } +} + +func newTestDB(t *testing.T) interface{ Close() error } { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + return db +} + +// --- RenderConfig --- + +func TestRenderConfig_MetaData(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/meta-data.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig meta-data : %v", err) + } + if !strings.Contains(out, "instance-id: vm1") { + t.Errorf("instance-id absent :\n%s", out) + } + if !strings.Contains(out, "local-hostname: vm1") { + t.Errorf("local-hostname absent :\n%s", out) + } +} + +func TestRenderConfig_VendorData_ContainsPassword(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/vendor-data.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig vendor-data : %v", err) + } + if !strings.Contains(out, "s3cr3t") { + t.Errorf("password absent du vendor-data :\n%s", out) + } +} + +func TestRenderConfig_VendorData_ContainsSSHKey(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/vendor-data.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig vendor-data : %v", err) + } + if !strings.Contains(out, "ssh-ed25519 AAAA... user@host") { + t.Errorf("clé SSH absente du vendor-data :\n%s", out) + } +} + +func TestRenderConfig_NetworkConfig(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/network-config.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig network-config : %v", err) + } + if !strings.Contains(out, "dhcp4: true") { + t.Errorf("dhcp4 absent du network-config :\n%s", out) + } +} + +func TestRenderConfig_UserData(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/user-data.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig user-data : %v", err) + } + if !strings.Contains(out, "passwd -d root") { + t.Errorf("user-data inattendu :\n%s", out) + } +} + +func TestRenderConfig_InvalidTemplate(t *testing.T) { + _, err := RenderConfig("templates/inexistant.tmpl", newCfg()) + if err == nil { + t.Error("RenderConfig devrait retourner une erreur pour un template inexistant") + } +} + +func TestRenderConfig_SpecialCharsInName(t *testing.T) { + cfg := newCfg() + cfg.Name = "vm-prod-01" + out, err := RenderConfig("templates/meta-data.tmpl", cfg) + if err != nil { + t.Fatalf("RenderConfig : %v", err) + } + if !strings.Contains(out, "vm-prod-01") { + t.Errorf("nom vm-prod-01 absent :\n%s", out) + } +} + +// --- LoadNcCloudInDB / UnLoadNoCloudInDB --- + +func TestLoadNcCloudInDB_StoresAllKeys(t *testing.T) { + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + + cfg := newCfg() + LoadNcCloudInDB(cfg, db) + + keys := []string{ + "metadata/vm1/meta-data", + "metadata/vm1/user-data", + "metadata/vm1/network-config", + "metadata/vm1/vendor-data", + "metadata/vm1/vpc", + "metadata/vm1/bind_ip", + "metadata/vm1/bind_port", + } + for _, key := range keys { + val, err := kv.GetFromDB(db, key) + if err != nil { + t.Errorf("clé %q absente après LoadNcCloudInDB : %v", key, err) + } + if val == "" && key != "metadata/vm1/user-data" { + t.Errorf("clé %q vide après LoadNcCloudInDB", key) + } + } +} + +func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) { + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + + cfg := newCfg() + LoadNcCloudInDB(cfg, db) + + vpc, _ := kv.GetFromDB(db, "metadata/vm1/vpc") + if vpc != "vpc-test" { + t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc) + } + + ip, _ := kv.GetFromDB(db, "metadata/vm1/bind_ip") + if ip != "169.254.169.254" { + t.Errorf("bind_ip attendu %q, obtenu %q", "169.254.169.254", ip) + } + + port, _ := kv.GetFromDB(db, "metadata/vm1/bind_port") + if port != "80" { + t.Errorf("bind_port attendu %q, obtenu %q", "80", port) + } +} + +func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) { + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + + cfg := newCfg() + LoadNcCloudInDB(cfg, db) + UnLoadNoCloudInDB("vm1", db) + + keys := []string{ + "metadata/vm1/meta-data", + "metadata/vm1/user-data", + "metadata/vm1/network-config", + "metadata/vm1/vendor-data", + "metadata/vm1/vpc", + "metadata/vm1/bind_ip", + "metadata/vm1/bind_port", + } + for _, key := range keys { + _, err := kv.GetFromDB(db, key) + if err == nil { + t.Errorf("clé %q devrait être supprimée après UnLoadNoCloudInDB", key) + } + } +} + +func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + + cfg1 := newCfg() + cfg2 := newCfg() + cfg2.Name = "vm2" + LoadNcCloudInDB(cfg1, db) + LoadNcCloudInDB(cfg2, db) + + UnLoadNoCloudInDB("vm1", db) + + _, err := kv.GetFromDB(db, "metadata/vm2/vpc") + if err != nil { + t.Errorf("vm2 ne devrait pas être supprimée : %v", err) + } +} diff --git a/pkg/db/kv/kv_test.go b/pkg/db/kv/kv_test.go new file mode 100644 index 0000000..de4fd68 --- /dev/null +++ b/pkg/db/kv/kv_test.go @@ -0,0 +1,153 @@ +package kv + +import ( + "errors" + "testing" + + "github.com/dgraph-io/badger/v4" +) + +// newTestDB ouvre une base BadgerDB dans un répertoire temporaire. +// La base est fermée automatiquement en fin de test. +func newTestDB(t *testing.T) *badger.DB { + t.Helper() + db := InitDB(Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + return db +} + +// --- InitDB --- + +func TestInitDB_ValidPath(t *testing.T) { + db := newTestDB(t) + if db == nil { + t.Fatal("InitDB devrait retourner une DB non-nil") + } +} + +func TestInitDB_InvalidPath_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("InitDB avec un chemin invalide devrait paniquer") + } + }() + InitDB(Config{Path: "/chemin/inexistant/absolu"}, false) +} + +// --- AddInDB --- + +func TestAddInDB_NewKey(t *testing.T) { + db := newTestDB(t) + if err := AddInDB(db, "vpc/test", "valeur"); err != nil { + t.Fatalf("AddInDB a échoué : %v", err) + } +} + +func TestAddInDB_OverwriteExistingKey(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/test", "premiere") + if err := AddInDB(db, "vpc/test", "deuxieme"); err != nil { + t.Fatalf("AddInDB (écrasement) a échoué : %v", err) + } + + val, _ := GetFromDB(db, "vpc/test") + if val != "deuxieme" { + t.Errorf("valeur attendue %q, obtenu %q", "deuxieme", val) + } +} + +// --- GetFromDB --- + +func TestGetFromDB_ExistingKey(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/foo", "bar") + + val, err := GetFromDB(db, "vpc/foo") + if err != nil { + t.Fatalf("GetFromDB a échoué : %v", err) + } + if val != "bar" { + t.Errorf("valeur attendue %q, obtenu %q", "bar", val) + } +} + +func TestGetFromDB_MissingKey(t *testing.T) { + db := newTestDB(t) + + _, err := GetFromDB(db, "inexistant") + if !errors.Is(err, badger.ErrKeyNotFound) { + t.Errorf("erreur attendue ErrKeyNotFound, obtenu : %v", err) + } +} + +func TestGetFromDB_EmptyValue(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/vide", "") + + val, err := GetFromDB(db, "vpc/vide") + if err != nil { + t.Fatalf("GetFromDB a échoué : %v", err) + } + if val != "" { + t.Errorf("valeur attendue vide, obtenu %q", val) + } +} + +// --- DeleteInDB --- + +func TestDeleteInDB_SimpleKey(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/a", "v") + + if err := DeleteInDB(db, "vpc/a"); err != nil { + t.Fatalf("DeleteInDB a échoué : %v", err) + } + + _, err := GetFromDB(db, "vpc/a") + if !errors.Is(err, badger.ErrKeyNotFound) { + t.Errorf("la clé devrait être supprimée, obtenu : %v", err) + } +} + +func TestDeleteInDB_WithSubkeys(t *testing.T) { + db := newTestDB(t) + // Clé parente + sous-clés (préfixe "vpc/net1/") + AddInDB(db, "vpc/net1", "parent") + AddInDB(db, "vpc/net1/ip", "10.0.0.1") + AddInDB(db, "vpc/net1/gw", "10.0.0.254") + + if err := DeleteInDB(db, "vpc/net1"); err != nil { + t.Fatalf("DeleteInDB a échoué : %v", err) + } + + for _, key := range []string{"vpc/net1", "vpc/net1/ip", "vpc/net1/gw"} { + _, err := GetFromDB(db, key) + if !errors.Is(err, badger.ErrKeyNotFound) { + t.Errorf("clé %q devrait être supprimée, obtenu : %v", key, err) + } + } +} + +func TestDeleteInDB_DoesNotDeleteSiblings(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/net1", "a") + AddInDB(db, "vpc/net2", "b") // ne doit pas être supprimée + + DeleteInDB(db, "vpc/net1") + + val, err := GetFromDB(db, "vpc/net2") + if err != nil { + t.Fatalf("vpc/net2 ne devrait pas être supprimée : %v", err) + } + if val != "b" { + t.Errorf("valeur attendue %q, obtenu %q", "b", val) + } +} + +func TestDeleteInDB_MissingKey(t *testing.T) { + db := newTestDB(t) + // Supprimer une clé inexistante ne doit pas crasher + if err := DeleteInDB(db, "inexistant"); err != nil { + t.Logf("DeleteInDB clé inexistante retourne : %v (non bloquant)", err) + } +}