diff --git a/.forgejo/workflows/prerelease.yml b/.forgejo/workflows/prerelease.yml index 1869374..2440fbd 100644 --- a/.forgejo/workflows/prerelease.yml +++ b/.forgejo/workflows/prerelease.yml @@ -33,7 +33,9 @@ jobs: goos: [linux] goarch: [amd64] binaries: + - db - metadata + - metacli - agent uses: ./.forgejo/workflows/build.yml with: diff --git a/api/agent.yaml b/api/agent.yaml index 7e9c461..1d3a77f 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -44,12 +44,6 @@ paths: application/json: schema: $ref: "#/components/schemas/VPC" - "400": - description: Missing required field or invalid request body - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "409": description: VPC already exists content: @@ -99,95 +93,6 @@ paths: "500": $ref: "#/components/responses/InternalError" - # ── VM ───────────────────────────────────────────────────────────────────── - - /vms: - get: - summary: List all VMs - operationId: listVMs - responses: - "200": - description: List of VMs - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/VM" - "500": - $ref: "#/components/responses/InternalError" - - post: - summary: Start a VM - operationId: startVM - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/VMCreateRequest" - responses: - "202": - description: VM start accepted - content: - application/json: - schema: - $ref: "#/components/schemas/VM" - "400": - description: Missing required field or invalid request body - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: VM already exists - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "422": - description: Subnet not found or not in created state - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "500": - $ref: "#/components/responses/InternalError" - - /vms/{name}: - parameters: - - $ref: "#/components/parameters/ResourceName" - - get: - summary: Get VM status and info - operationId: getVM - responses: - "200": - description: VM found - content: - application/json: - schema: - $ref: "#/components/schemas/VM" - "404": - $ref: "#/components/responses/NotFound" - "500": - $ref: "#/components/responses/InternalError" - - delete: - summary: Stop a VM - operationId: stopVM - responses: - "202": - description: VM stop accepted - content: - application/json: - schema: - $ref: "#/components/schemas/VM" - "404": - $ref: "#/components/responses/NotFound" - "500": - $ref: "#/components/responses/InternalError" - # ── Subnet ───────────────────────────────────────────────────────────────── /subnets: @@ -274,6 +179,12 @@ paths: $ref: "#/components/schemas/Subnet" "404": $ref: "#/components/responses/NotFound" + "409": + description: Subnet not in a deletable state + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" @@ -294,17 +205,13 @@ components: VPCCreateRequest: type: object - required: [name, cidr] + required: [name] properties: name: type: string description: Unique name for the VPC, must follow the format vp-[id] pattern: '^vp-.+' example: vp-00001 - cidr: - type: string - description: CIDR block for the entire VPC address space - example: "10.0.0.0/16" VPC: type: object @@ -316,13 +223,10 @@ components: type: string enum: [creating, created, deleting, deleted] example: created - cidr: - type: string - example: "10.0.0.0/16" SubnetCreateRequest: type: object - required: [name, vpc, interface_ip, cidr] + required: [name, vpc, vxlan_id, gateway_ip, cidr] properties: name: type: string @@ -332,24 +236,15 @@ components: type: string description: Parent VPC name example: vpc1 - mode: - type: string - description: > - Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge. - "bridge": attaches directly to an existing bridge resolved from iface_type in the agent config. - "vlan" is reserved for future use. - enum: [vxlan, bridge] - default: vxlan - example: vxlan vxlan_id: type: integer - description: VXLAN VNI identifier. Required when mode is "vxlan", ignored otherwise. + description: VXLAN VNI identifier example: 100 iface_type: type: string description: Interface type key defined in the agent config (e.g. vms, internet, admin). Falls back to default_interface if omitted or unknown. example: vms - interface_ip: + gateway_ip: type: string format: ipv4 description: Gateway IP for the subnet @@ -358,12 +253,6 @@ components: type: string description: Subnet CIDR block example: "10.10.10.0/24" - default_route: - type: boolean - description: > - If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP. - For bridge mode the gateway is read from the host routing table. - default: false Subnet: type: object @@ -378,122 +267,19 @@ components: vpc: type: string example: vpc1 - mode: - type: string - enum: [vxlan, bridge] - example: vxlan vxlan_id: type: integer - description: VXLAN VNI. Present only when mode is "vxlan". example: 100 local_iface: type: string - description: Resolved interface name from agent config + description: Resolved interface name example: br-000000 - interface_ip: + gateway_ip: type: string example: "10.10.10.1" cidr: type: string example: "10.10.10.0/24" - default_route: - type: boolean - example: false - - VMCreateRequest: - type: object - required: [name, interfaces, storage] - properties: - name: - type: string - example: vm-00001 - memory: - type: integer - description: Memory in MB (default 512) - example: 1024 - cpus: - type: integer - description: Number of vCPUs (default 1) - example: 2 - password: - type: string - sshkey: - type: string - example: "ssh-ed25519 AAAA..." - interfaces: - type: array - minItems: 1 - items: - $ref: "#/components/schemas/VMInterface" - storage: - type: array - minItems: 1 - items: - $ref: "#/components/schemas/VMStorage" - uefi: - type: boolean - description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS). - example: false - - VMInterface: - type: object - required: [subnet, ip, primary] - properties: - subnet: - type: string - example: sn-00001 - ip: - type: string - format: ipv4 - example: "10.0.0.5" - primary: - type: boolean - example: true - - VMStorage: - type: object - required: [path, dev] - properties: - path: - type: string - description: Path to the disk image on the host - example: /var/lib/two/volumes/abc.qcow2 - dev: - type: string - description: Device name inside the VM - pattern: '^[sv]d[a-z]$' - example: vda - - VM: - type: object - properties: - name: - type: string - example: vm-00001 - state: - type: string - enum: [starting, started, stopping, stopped] - example: started - metadata_port: - type: string - example: "80" - memory: - type: integer - example: 1024 - cpus: - type: integer - example: 2 - interfaces: - type: array - items: - $ref: "#/components/schemas/VMInterface" - storage: - type: array - items: - $ref: "#/components/schemas/VMStorage" - uefi: - type: boolean - example: false Error: type: object diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7083076..7b87bde 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -51,10 +51,6 @@ func main() { d := dispatcher.New(q, db, cfg, log.With(slog.String("component", "dispatcher"))) go agentapi.New(d, db, log.With(slog.String("component", "api"))).Start(apiAddr) go promserver.Start(promAddr, registry) - if cfg.Admin.Enabled { - adminAddr := fmt.Sprintf("%s:%d", cfg.Admin.Address, cfg.Admin.Port) - go kv.NewAdminServer(db, log.With(slog.String("component", "admin"))).Start(adminAddr) - } select {} } diff --git a/cmd/metacli/main.go b/cmd/metacli/main.go new file mode 100644 index 0000000..140bde6 --- /dev/null +++ b/cmd/metacli/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/metadata" + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func main() { + conf_file := flag.String("conf", "/etc/two/agent.yml", "configuration file") + vm_name := flag.String("vm_name", "", "Nom de la vm") + vpc := flag.String("vpc_name", "", "vpc name") + bind_ip := flag.String("ip", "", "bind ip") + bind_port := flag.String("port", "", "bind port") + ssh_key := flag.String("key", "", "Clef ssh") + password := flag.String("pass", "", "password user") + start := flag.Bool("start", false, "start metadata server") + stop := flag.Bool("stop", false, "stop metadata server") + dryrun := flag.Bool("dryrun", false, "launch in dry node") + + flag.Parse() + + conf, err := configuration.LoadConfig(*conf_file) + if err != nil { + fmt.Println(err) + return + } + + db := kv.InitDB(kv.Config{ + Path: conf.Database.Path, + }, false) + defer db.Close() + + if *start { + if err := metadata.StartMetadata(metadata.NoCloudConfig{ + VpcName: *vpc, + Name: *vm_name, + BindIP: *bind_ip, + BindPort: *bind_port, + Password: *password, + SSHKEY: *ssh_key, + }, db, *dryrun); err != nil { + fmt.Println(err) + } + } else if *stop { + if err := metadata.StopMetadata(*vm_name, db, *dryrun); err != nil { + fmt.Println(err) + } + } +} diff --git a/cmd/metadata/main.go b/cmd/metadata/main.go index 82abaed..96e3f75 100644 --- a/cmd/metadata/main.go +++ b/cmd/metadata/main.go @@ -2,29 +2,26 @@ package main import ( "flag" - "fmt" - "os" - configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/metadata" ) var ( - confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file") - vm_name = flag.String("vm", "", "Name of the vm") + iface = flag.String("interface", "0.0.0.0", "Interface IP à écouter") + port = flag.Int("port", 0, "Port à utiliser") + netns_name = flag.String("netns", "", "Network namespace à utiliser") + conf_file = flag.String("conf", "/etc/two/agent.yml", "configuration file") + vm_name = flag.String("vm", "", "Name of the vm") ) func main() { flag.Parse() - cfg, err := configuration.LoadConfig(*confFile) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) - os.Exit(1) - } - metadata.StartServer(metadata.ServerConfig{ - VmName: *vm_name, - RunDir: cfg.Metadata.RunDir, + Netns: *netns_name, + Iface: *iface, + Port: *port, + ConfFile: *conf_file, + VmName: *vm_name, }) } diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 0517eac..fb8e604 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -35,28 +35,6 @@ interfaces: internet: br-000000 admin: br-000000 -# Metadata server runtime directory (cloud-init files per VM) -metadata: - run_dir: "/run/two/metadata" - -# QEMU runtime paths -qemu: - # UEFI firmware (requires apt install ovmf on Debian/Ubuntu) - ovmf_code_path: "/usr/share/OVMF/OVMF_CODE.fd" - ovmf_vars_template: "/usr/share/OVMF/OVMF_VARS.fd" - # Per-VM UEFI variable store (writable copy, created at start / deleted at stop) - uefi_vars_dir: "/run/two/vms/efi" - # QEMU Unix socket directories - serial_dir: "/run/two/vms/serial" - monitor_dir: "/run/two/vms/monitor" - qmp_dir: "/run/two/vms/qmp" - -# Admin API (read-only DB inspection, loopback only) -admin: - enabled: false - address: "127.0.0.1" - port: 9091 - # Logging configuration logger: # Log level: debug, info, warn, error (default: info) diff --git a/internal/api/agent/helpers_test.go b/internal/api/agent/helpers_test.go deleted file mode 100644 index 206874c..0000000 --- a/internal/api/agent/helpers_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package agentapi - -import ( - "io" - "log/slog" - "testing" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" - "git.g3e.fr/syonad/two/pkg/db/kv" - "git.g3e.fr/syonad/two/pkg/worker" - "github.com/dgraph-io/badger/v4" -) - -// newTestServer builds a Server backed by an in-memory Badger DB. -// The worker queue is buffered but has no running goroutines: Dispatch enqueues -// without blocking and Execute never runs, so DB state reflects only Prepare writes. -func newTestServer(t *testing.T) (*Server, *badger.DB) { - t.Helper() - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - q := worker.New(100) - cfg := &configuration.Config{DefaultInterface: "br-test"} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - d := dispatcher.New(q, db, cfg, logger) - return New(d, db, logger), db -} diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index 079d8db..e826082 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -2,69 +2,30 @@ package agentapi type VPCCreateRequest struct { Name string `json:"name"` - CIDR string `json:"cidr"` } type VPC struct { Name string `json:"name"` State string `json:"state"` - CIDR string `json:"cidr"` } type SubnetCreateRequest struct { - Name string `json:"name"` - VPC string `json:"vpc"` - Mode string `json:"mode"` - VxlanID int `json:"vxlan_id"` - IfaceType string `json:"iface_type"` - InterfaceIP string `json:"interface_ip"` - CIDR string `json:"cidr"` - DefaultRoute bool `json:"default_route"` + Name string `json:"name"` + VPC string `json:"vpc"` + VxlanID int `json:"vxlan_id"` + IfaceType string `json:"iface_type"` + GatewayIP string `json:"gateway_ip"` + CIDR string `json:"cidr"` } type Subnet struct { - Name string `json:"name"` - State string `json:"state"` - VPC string `json:"vpc"` - Mode string `json:"mode"` - VxlanID int `json:"vxlan_id"` - LocalIface string `json:"local_iface"` - InterfaceIP string `json:"interface_ip"` - CIDR string `json:"cidr"` - DefaultRoute bool `json:"default_route"` -} - -type VMInterface struct { - Subnet string `json:"subnet"` - IP string `json:"ip"` - Primary bool `json:"primary"` -} - -type VMStorage struct { - Path string `json:"path"` - Dev string `json:"dev"` -} - -type VMCreateRequest struct { - Name string `json:"name"` - Memory int `json:"memory"` - CPUs int `json:"cpus"` - UEFI bool `json:"uefi"` - Password string `json:"password"` - SSHKey string `json:"sshkey"` - Interfaces []VMInterface `json:"interfaces"` - Storage []VMStorage `json:"storage"` -} - -type VM struct { - Name string `json:"name"` - State string `json:"state"` - MetadataPort string `json:"metadata_port"` - Memory int `json:"memory"` - CPUs int `json:"cpus"` - UEFI bool `json:"uefi"` - Interfaces []VMInterface `json:"interfaces"` - Storage []VMStorage `json:"storage"` + Name string `json:"name"` + State string `json:"state"` + VPC string `json:"vpc"` + VxlanID int `json:"vxlan_id"` + LocalIface string `json:"local_iface"` + GatewayIP string `json:"gateway_ip"` + CIDR string `json:"cidr"` } type ErrorResponse struct { diff --git a/internal/api/agent/server.go b/internal/api/agent/server.go index 4a0fba4..8e3c4e3 100644 --- a/internal/api/agent/server.go +++ b/internal/api/agent/server.go @@ -27,8 +27,6 @@ func (s *Server) Start(address string) { mux.HandleFunc("/vpcs/", s.VpcByNameHandler) mux.HandleFunc("/subnets", s.SubnetsHandler) mux.HandleFunc("/subnets/", s.SubnetByNameHandler) - mux.HandleFunc("/vms", s.VmsHandler) - mux.HandleFunc("/vms/", s.VmByNameHandler) s.logger.Info("API server listening", "address", address) if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil { s.logger.Error("API server stopped", "error", err) diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 36ee9d7..556b7c3 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -47,18 +47,14 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.State = value case "vpc": sub.VPC = value - case "mode": - sub.Mode = value case "vxlan_id": sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": sub.LocalIface = value - case "interface_ip": - sub.InterfaceIP = value + case "gateway_ip": + sub.GatewayIP = value case "cidr": sub.CIDR = value - case "default_route": - sub.DefaultRoute = value == "true" } } w.WriteHeader(http.StatusOK) @@ -73,12 +69,7 @@ func (s *Server) deleteSubnet(w http.ResponseWriter, _ *http.Request, name strin return } s.dispatcher.Dispatch(cmd) - state, err := kv.GetFromDB(s.db, "subnet/"+name+"/state") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) - return - } + state, _ := kv.GetFromDB(s.db, "subnet/"+name+"/state") w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(Subnet{Name: name, State: state}) } diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go deleted file mode 100644 index 36e42f7..0000000 --- a/internal/api/agent/subnet_test.go +++ /dev/null @@ -1,297 +0,0 @@ -package agentapi - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -// --- SubnetsHandler --- - -func TestListSubnets_Empty(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result []Subnet - json.NewDecoder(w.Body).Decode(&result) - if len(result) != 0 { - t.Errorf("attendu liste vide, obtenu %v", result) - } -} - -func TestListSubnets_WithData(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - kv.AddInDB(db, "subnet/sn-2/state", "creating") - kv.AddInDB(db, "subnet/sn-2/vpc", "vpc-1") - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result []Subnet - json.NewDecoder(w.Body).Decode(&result) - if len(result) != 2 { - t.Errorf("attendu 2 subnets, obtenu %d", len(result)) - } -} - -func TestListSubnets_InvalidMethod(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPut, "/subnets", nil)) - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("attendu 405, obtenu %d", w.Code) - } -} - -func TestPostSubnet_Created(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := SubnetCreateRequest{ - Name: "sn-new", - VPC: "vpc-1", - IfaceType: "vms", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } - var result Subnet - json.NewDecoder(w.Body).Decode(&result) - if result.Name != "sn-new" { - t.Errorf("name attendu sn-new, obtenu %q", result.Name) - } - if result.State != "creating" { - t.Errorf("state attendu creating, obtenu %q", result.State) - } -} - -func TestPostSubnet_MissingFields(t *testing.T) { - s, _ := newTestServer(t) - body, _ := json.Marshal(SubnetCreateRequest{Name: "sn-1"}) // vpc, gateway_ip, cidr manquants - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -func TestPostSubnet_IfaceTypeOptional(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := SubnetCreateRequest{ - Name: "sn-opt", - VPC: "vpc-1", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - // IfaceType omis — doit utiliser default_interface - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } -} - -func TestPostSubnet_VPCNotFound(t *testing.T) { - s, _ := newTestServer(t) - req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-inexistant", - IfaceType: "vms", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusUnprocessableEntity { - t.Errorf("attendu 422, obtenu %d", w.Code) - } -} - -func TestPostSubnet_Duplicate(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - kv.AddInDB(db, "subnet/sn-exist/state", "created") - req := SubnetCreateRequest{ - Name: "sn-exist", - VPC: "vpc-1", - IfaceType: "vms", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusConflict { - t.Errorf("attendu 409, obtenu %d", w.Code) - } -} - -func TestPostSubnet_VPCDeleting(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") - req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-dying", - IfaceType: "vms", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusUnprocessableEntity { - t.Errorf("attendu 422, obtenu %d", w.Code) - } -} - -func TestPostSubnet_BridgeMode_Success(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := SubnetCreateRequest{ - Name: "sn-br", - VPC: "vpc-1", - Mode: "bridge", - IfaceType: "vms", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } - var result Subnet - json.NewDecoder(w.Body).Decode(&result) - if result.Mode != "bridge" { - t.Errorf("mode attendu bridge, obtenu %q", result.Mode) - } - if result.VxlanID != 0 { - t.Errorf("vxlan_id devrait être 0 en mode bridge, obtenu %d", result.VxlanID) - } -} - -func TestPostSubnet_UnknownMode(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-1", - Mode: "vlan", - InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", - } - body, _ := json.Marshal(req) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusUnprocessableEntity { - t.Errorf("attendu 422, obtenu %d", w.Code) - } -} - -func TestPostSubnet_InvalidBody(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader([]byte("not json")))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -// --- SubnetByNameHandler --- - -func TestGetSubnet_Found(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - kv.AddInDB(db, "subnet/sn-1/cidr", "10.0.0.0/24") - kv.AddInDB(db, "subnet/sn-1/interface_ip", "10.0.0.1") - req := httptest.NewRequest(http.MethodGet, "/subnets/sn-1", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result Subnet - json.NewDecoder(w.Body).Decode(&result) - if result.Name != "sn-1" || result.State != "created" { - t.Errorf("résultat inattendu : %+v", result) - } - if result.VPC != "vpc-1" { - t.Errorf("vpc attendu vpc-1, obtenu %q", result.VPC) - } -} - -func TestGetSubnet_NotFound(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/subnets/inexistant", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestGetSubnet_EmptyName(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/subnets/", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestDeleteSubnet_Success(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-del/state", "created") - req := httptest.NewRequest(http.MethodDelete, "/subnets/sn-del", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } - var result Subnet - json.NewDecoder(w.Body).Decode(&result) - if result.State != "deleting" { - t.Errorf("state attendu deleting, obtenu %q", result.State) - } -} - -func TestDeleteSubnet_NotFound(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodDelete, "/subnets/inexistant", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestSubnetByName_InvalidMethod(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - req := httptest.NewRequest(http.MethodPut, "/subnets/sn-1", nil) - w := httptest.NewRecorder() - s.SubnetByNameHandler(w, req) - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("attendu 405, obtenu %d", w.Code) - } -} diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 3cea247..9e99a45 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -44,18 +44,14 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) { subnets[name].State = value case "vpc": subnets[name].VPC = value - case "mode": - subnets[name].Mode = value case "vxlan_id": subnets[name].VxlanID, _ = strconv.Atoi(value) case "local_iface": subnets[name].LocalIface = value - case "interface_ip": - subnets[name].InterfaceIP = value + case "gateway_ip": + subnets[name].GatewayIP = value case "cidr": subnets[name].CIDR = value - case "default_route": - subnets[name].DefaultRoute = value == "true" } } result := make([]Subnet, 0, len(subnets)) @@ -73,37 +69,26 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) return } - if req.Name == "" || req.VPC == "" || req.InterfaceIP == "" || req.CIDR == "" { + if req.Name == "" || req.VPC == "" || req.IfaceType == "" || req.GatewayIP == "" || req.CIDR == "" { w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, interface_ip and cidr are required"}) + json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, iface_type, gateway_ip and cidr are required"}) return } cmd := dispatcher.CreateSubnetCommand{ - Name: req.Name, - VPC: req.VPC, - Mode: req.Mode, - VxlanID: req.VxlanID, - IfaceType: req.IfaceType, - InterfaceIP: req.InterfaceIP, - CIDR: req.CIDR, - DefaultRoute: req.DefaultRoute, + Name: req.Name, + VPC: req.VPC, + VxlanID: req.VxlanID, + IfaceType: req.IfaceType, + GatewayIP: req.GatewayIP, + CIDR: req.CIDR, } if err := s.dispatcher.Prepare(cmd); err != nil { - if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { - w.WriteHeader(http.StatusConflict) - } else { - w.WriteHeader(http.StatusUnprocessableEntity) - } + w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) return } s.dispatcher.Dispatch(cmd) - entries, err := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) - return - } + entries, _ := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") sub := Subnet{Name: req.Name} for key, value := range entries { parts := strings.Split(key, "/") @@ -115,18 +100,14 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { sub.State = value case "vpc": sub.VPC = value - case "mode": - sub.Mode = value case "vxlan_id": sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": sub.LocalIface = value - case "interface_ip": - sub.InterfaceIP = value + case "gateway_ip": + sub.GatewayIP = value case "cidr": sub.CIDR = value - case "default_route": - sub.DefaultRoute = value == "true" } } w.WriteHeader(http.StatusAccepted) diff --git a/internal/api/agent/vm.go b/internal/api/agent/vm.go deleted file mode 100644 index d9f6bf5..0000000 --- a/internal/api/agent/vm.go +++ /dev/null @@ -1,98 +0,0 @@ -package agentapi - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - - dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -func (s *Server) VmByNameHandler(w http.ResponseWriter, r *http.Request) { - name := strings.TrimPrefix(r.URL.Path, "/vms/") - if name == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(ErrorResponse{Error: "resource not found"}) - return - } - w.Header().Set("Content-Type", "application/json") - switch r.Method { - case http.MethodGet: - s.getVM(w, r, name) - case http.MethodDelete: - s.stopVM(w, r, name) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(ErrorResponse{Error: "method not allowed"}) - } -} - -func (s *Server) getVM(w http.ResponseWriter, _ *http.Request, name string) { - entries, err := kv.ListByPrefix(s.db, "vm/"+name+"/") - if err != nil || len(entries) == 0 { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(ErrorResponse{Error: "vm not found"}) - return - } - vm, err := vmFromDB(name, entries) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm"}) - return - } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(vm) -} - -func (s *Server) stopVM(w http.ResponseWriter, _ *http.Request, name string) { - cmd := dispatcher.StopVMCommand{Name: name} - if err := s.dispatcher.Prepare(cmd); err != nil { - if _, dbErr := kv.GetFromDB(s.db, "vm/"+name+"/state"); dbErr != nil { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusConflict) - } - json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) - return - } - s.dispatcher.Dispatch(cmd) - - entries, _ := kv.ListByPrefix(s.db, "vm/"+name+"/") - vm, err := vmFromDB(name, entries) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm state"}) - return - } - w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(vm) -} - -func vmFromDB(name string, entries map[string]string) (VM, error) { - prefix := "vm/" + name + "/" - vm := VM{Name: name} - - vm.State = entries[prefix+"state"] - vm.MetadataPort = entries[prefix+"metadata_port"] - vm.Memory, _ = strconv.Atoi(entries[prefix+"memory"]) - vm.CPUs, _ = strconv.Atoi(entries[prefix+"cpus"]) - vm.UEFI = entries[prefix+"uefi"] == "true" - - subnet := entries[prefix+"subnet"] - ip := entries[prefix+"ip"] - if subnet != "" || ip != "" { - vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}} - } - - diskPrefix := prefix + "disk/" - for key, path := range entries { - if dev := strings.TrimPrefix(key, diskPrefix); dev != key { - vm.Storage = append(vm.Storage, VMStorage{Path: path, Dev: dev}) - } - } - - return vm, nil -} diff --git a/internal/api/agent/vm_test.go b/internal/api/agent/vm_test.go deleted file mode 100644 index 3af5968..0000000 --- a/internal/api/agent/vm_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package agentapi - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "sort" - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -// --- vmFromDB --- - -func TestVmFromDB_SingleDisk(t *testing.T) { - entries := map[string]string{ - "vm/vm-1/state": "started", - "vm/vm-1/subnet": "sn-1", - "vm/vm-1/ip": "10.0.0.5", - "vm/vm-1/metadata_port": "1234", - "vm/vm-1/memory": "512", - "vm/vm-1/cpus": "1", - "vm/vm-1/disk/sda": "/data/root.qcow2", - } - vm, err := vmFromDB("vm-1", entries) - if err != nil { - t.Fatalf("vmFromDB a échoué : %v", err) - } - if len(vm.Storage) != 1 { - t.Fatalf("attendu 1 disque, obtenu %d", len(vm.Storage)) - } - if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { - t.Errorf("disque inattendu : %+v", vm.Storage[0]) - } -} - -func TestVmFromDB_MultiDisk(t *testing.T) { - entries := map[string]string{ - "vm/vm-2/state": "started", - "vm/vm-2/subnet": "sn-1", - "vm/vm-2/ip": "10.0.0.6", - "vm/vm-2/metadata_port": "1235", - "vm/vm-2/memory": "1024", - "vm/vm-2/cpus": "2", - "vm/vm-2/disk/sda": "/data/root.qcow2", - "vm/vm-2/disk/sdb": "/data/data.qcow2", - } - vm, err := vmFromDB("vm-2", entries) - if err != nil { - t.Fatalf("vmFromDB a échoué : %v", err) - } - if len(vm.Storage) != 2 { - t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) - } - sort.Slice(vm.Storage, func(i, j int) bool { return vm.Storage[i].Dev < vm.Storage[j].Dev }) - if vm.Storage[0].Dev != "sda" || vm.Storage[1].Dev != "sdb" { - t.Errorf("devs attendus [sda sdb], obtenus [%s %s]", vm.Storage[0].Dev, vm.Storage[1].Dev) - } -} - -func TestVmFromDB_SlotGap(t *testing.T) { - // sdb absent — sda et sdc seulement - entries := map[string]string{ - "vm/vm-3/state": "started", - "vm/vm-3/subnet": "sn-1", - "vm/vm-3/ip": "10.0.0.7", - "vm/vm-3/metadata_port": "1236", - "vm/vm-3/memory": "512", - "vm/vm-3/cpus": "1", - "vm/vm-3/disk/sda": "/data/root.qcow2", - "vm/vm-3/disk/sdc": "/data/extra.qcow2", - } - vm, err := vmFromDB("vm-3", entries) - if err != nil { - t.Fatalf("vmFromDB a échoué : %v", err) - } - if len(vm.Storage) != 2 { - t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) - } - devs := map[string]bool{} - for _, s := range vm.Storage { - devs[s.Dev] = true - } - if !devs["sda"] || !devs["sdc"] { - t.Errorf("attendu sda et sdc, obtenus %v", devs) - } - if devs["sdb"] { - t.Error("sdb ne devrait pas apparaître") - } -} - -// --- POST /vms --- - -func TestStartVM_MultiDisk(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - body, _ := json.Marshal(VMCreateRequest{ - Name: "vm-10", - Interfaces: []VMInterface{ - {Subnet: "sn-1", IP: "10.0.0.10", Primary: true}, - }, - Storage: []VMStorage{ - {Path: "/data/root.qcow2", Dev: "sda"}, - {Path: "/data/data.qcow2", Dev: "sdb"}, - }, - Memory: 1024, - CPUs: 2, - }) - - w := httptest.NewRecorder() - s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) - } - - for _, dev := range []string{"sda", "sdb"} { - if _, err := kv.GetFromDB(db, "vm/vm-10/disk/"+dev); err != nil { - t.Errorf("disk/%s absent en DB après création", dev) - } - } - if _, err := kv.GetFromDB(db, "vm/vm-10/volume_path"); err == nil { - t.Error("volume_path ne devrait plus exister en DB") - } -} - -func TestStartVM_StorageReturnedInResponse(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - body, _ := json.Marshal(VMCreateRequest{ - Name: "vm-11", - Interfaces: []VMInterface{ - {Subnet: "sn-1", IP: "10.0.0.11", Primary: true}, - }, - Storage: []VMStorage{ - {Path: "/data/root.qcow2", Dev: "sda"}, - }, - }) - - w := httptest.NewRecorder() - s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d", w.Code) - } - - var vm VM - json.NewDecoder(w.Body).Decode(&vm) - if len(vm.Storage) != 1 { - t.Fatalf("attendu 1 disque dans la réponse, obtenu %d", len(vm.Storage)) - } - if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { - t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0]) - } -} diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go deleted file mode 100644 index 2ea5748..0000000 --- a/internal/api/agent/vms.go +++ /dev/null @@ -1,117 +0,0 @@ -package agentapi - -import ( - "encoding/json" - "net/http" - "strings" - - dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -func (s *Server) VmsHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.Method { - case http.MethodGet: - s.listVMs(w, r) - case http.MethodPost: - s.startVM(w, r) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(ErrorResponse{Error: "method not allowed"}) - } -} - -func (s *Server) listVMs(w http.ResponseWriter, _ *http.Request) { - entries, err := kv.ListByPrefix(s.db, "vm/") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to list vms"}) - return - } - - names := map[string]struct{}{} - for key := range entries { - parts := strings.Split(key, "/") - if len(parts) >= 2 { - names[parts[1]] = struct{}{} - } - } - - result := make([]VM, 0, len(names)) - for name := range names { - vm, err := vmFromDB(name, entries) - if err != nil { - continue - } - result = append(result, vm) - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(result) -} - -func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { - var req VMCreateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) - return - } - if req.Name == "" || len(req.Interfaces) == 0 || len(req.Storage) == 0 { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "name, interfaces and storage are required"}) - return - } - - var primary *VMInterface - for i := range req.Interfaces { - if req.Interfaces[i].Primary { - primary = &req.Interfaces[i] - break - } - } - if primary == nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "one interface must be primary"}) - return - } - - disks := make([]dispatcher.VMDisk, len(req.Storage)) - for i, s := range req.Storage { - disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev} - } - - cmd := dispatcher.StartVMCommand{ - Name: req.Name, - Subnet: primary.Subnet, - IP: primary.IP, - Disks: disks, - Memory: req.Memory, - CPUs: req.CPUs, - UEFI: req.UEFI, - Password: req.Password, - SSHKey: req.SSHKey, - } - - if err := s.dispatcher.Prepare(cmd); err != nil { - if _, dbErr := kv.GetFromDB(s.db, "vm/"+req.Name+"/state"); dbErr == nil { - w.WriteHeader(http.StatusConflict) - } else { - w.WriteHeader(http.StatusUnprocessableEntity) - } - json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) - return - } - s.dispatcher.Dispatch(cmd) - - entries, _ := kv.ListByPrefix(s.db, "vm/"+req.Name+"/") - vm, err := vmFromDB(req.Name, entries) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm state"}) - return - } - w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(vm) -} diff --git a/internal/api/agent/vpc.go b/internal/api/agent/vpc.go index bb3f5d7..be0724e 100644 --- a/internal/api/agent/vpc.go +++ b/internal/api/agent/vpc.go @@ -35,29 +35,19 @@ func (s *Server) getVpc(w http.ResponseWriter, _ *http.Request, name string) { json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"}) return } - cidr, _ := kv.GetFromDB(s.db, "vpc/"+name+"/cidr") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(VPC{Name: name, State: state, CIDR: cidr}) + json.NewEncoder(w).Encode(VPC{Name: name, State: state}) } func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) { cmd := dispatcher.DeleteVPCCommand{Name: name} if err := s.dispatcher.Prepare(cmd); err != nil { - if _, dbErr := kv.GetFromDB(s.db, "vpc/"+name+"/state"); dbErr != nil { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusConflict) - } + w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) return } s.dispatcher.Dispatch(cmd) - state, err := kv.GetFromDB(s.db, "vpc/"+name+"/state") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) - return - } + state, _ := kv.GetFromDB(s.db, "vpc/"+name+"/state") w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(VPC{Name: name, State: state}) } diff --git a/internal/api/agent/vpc_test.go b/internal/api/agent/vpc_test.go deleted file mode 100644 index 3516305..0000000 --- a/internal/api/agent/vpc_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package agentapi - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -// --- VpcsHandler --- - -func TestListVpcs_Empty(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result []VPC - json.NewDecoder(w.Body).Decode(&result) - if len(result) != 0 { - t.Errorf("attendu liste vide, obtenu %v", result) - } -} - -func TestListVpcs_WithData(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/v1/state", "created") - kv.AddInDB(db, "vpc/v2/state", "creating") - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result []VPC - json.NewDecoder(w.Body).Decode(&result) - if len(result) != 2 { - t.Errorf("attendu 2 VPCs, obtenu %d", len(result)) - } -} - -func TestListVpcs_InvalidMethod(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPut, "/vpcs", nil)) - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("attendu 405, obtenu %d", w.Code) - } -} - -func TestPostVpc_Created(t *testing.T) { - s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new", CIDR: "10.0.0.0/16"}) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } - var result VPC - json.NewDecoder(w.Body).Decode(&result) - if result.Name != "vpc-new" { - t.Errorf("name attendu vpc-new, obtenu %q", result.Name) - } - if result.State != "creating" { - t.Errorf("state attendu creating, obtenu %q", result.State) - } - if result.CIDR != "10.0.0.0/16" { - t.Errorf("cidr attendu 10.0.0.0/16, obtenu %q", result.CIDR) - } -} - -func TestPostVpc_MissingName(t *testing.T) { - s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{CIDR: "10.0.0.0/16"}) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -func TestPostVpc_MissingCIDR(t *testing.T) { - s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new"}) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -func TestPostVpc_InvalidCIDR(t *testing.T) { - s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new", CIDR: "not-a-cidr"}) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -func TestPostVpc_Duplicate(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-exist/state", "created") - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist", CIDR: "10.0.0.0/16"}) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) - if w.Code != http.StatusConflict { - t.Errorf("attendu 409, obtenu %d", w.Code) - } -} - -func TestPostVpc_InvalidBody(t *testing.T) { - s, _ := newTestServer(t) - w := httptest.NewRecorder() - s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader([]byte("not json")))) - if w.Code != http.StatusBadRequest { - t.Errorf("attendu 400, obtenu %d", w.Code) - } -} - -// --- VpcByNameHandler --- - -func TestGetVpc_Found(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := httptest.NewRequest(http.MethodGet, "/vpcs/vpc-1", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusOK { - t.Fatalf("attendu 200, obtenu %d", w.Code) - } - var result VPC - json.NewDecoder(w.Body).Decode(&result) - if result.Name != "vpc-1" || result.State != "created" { - t.Errorf("résultat inattendu : %+v", result) - } -} - -func TestGetVpc_NotFound(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/vpcs/inexistant", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestGetVpc_EmptyName(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/vpcs/", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestDeleteVpc_Success(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-del/state", "created") - req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-del", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) - } - var result VPC - json.NewDecoder(w.Body).Decode(&result) - if result.State != "deleting" { - t.Errorf("state attendu deleting, obtenu %q", result.State) - } -} - -func TestDeleteVpc_NotFound(t *testing.T) { - s, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodDelete, "/vpcs/inexistant", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-busy/state", "created") - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") - req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-busy", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusConflict { - t.Errorf("attendu 409, obtenu %d: %s", w.Code, w.Body.String()) - } -} - -func TestVpcByName_InvalidMethod(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - req := httptest.NewRequest(http.MethodPut, "/vpcs/vpc-1", nil) - w := httptest.NewRecorder() - s.VpcByNameHandler(w, req) - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("attendu 405, obtenu %d", w.Code) - } -} diff --git a/internal/api/agent/vpcs.go b/internal/api/agent/vpcs.go index 905cdee..3087456 100644 --- a/internal/api/agent/vpcs.go +++ b/internal/api/agent/vpcs.go @@ -2,7 +2,6 @@ package agentapi import ( "encoding/json" - "net" "net/http" "strings" @@ -39,11 +38,8 @@ func (s *Server) listVpcs(w http.ResponseWriter, _ *http.Request) { if _, ok := vpcs[name]; !ok { vpcs[name] = &VPC{Name: name} } - switch parts[2] { - case "state": + if parts[2] == "state" { vpcs[name].State = value - case "cidr": - vpcs[name].CIDR = value } } result := make([]VPC, 0, len(vpcs)) @@ -66,29 +62,14 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "name is required"}) return } - if req.CIDR == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "cidr is required"}) - return - } - if _, _, err := net.ParseCIDR(req.CIDR); err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid cidr"}) - return - } - cmd := dispatcher.CreateVPCCommand{Name: req.Name, CIDR: req.CIDR} + cmd := dispatcher.CreateVPCCommand{Name: req.Name} if err := s.dispatcher.Prepare(cmd); err != nil { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) return } s.dispatcher.Dispatch(cmd) - state, err := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) - return - } + state, _ := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state, CIDR: req.CIDR}) + json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state}) } diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 8b5d406..d8e4ee5 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -28,22 +28,6 @@ type Config struct { Level string `mapstructure:"level"` Debug bool `mapstructure:"debug"` } `mapstructure:"logger"` - Metadata struct { - RunDir string `mapstructure:"run_dir"` - } `mapstructure:"metadata"` - Admin struct { - Enabled bool `mapstructure:"enabled"` - Address string `mapstructure:"address"` - Port int `mapstructure:"port"` - } `mapstructure:"admin"` - QEMU struct { - OVMFCodePath string `mapstructure:"ovmf_code_path"` - OVMFVarsTemplate string `mapstructure:"ovmf_vars_template"` - UEFIVarsDir string `mapstructure:"uefi_vars_dir"` - SerialDir string `mapstructure:"serial_dir"` - MonitorDir string `mapstructure:"monitor_dir"` - QMPDir string `mapstructure:"qmp_dir"` - } `mapstructure:"qemu"` DefaultInterface string `mapstructure:"default_interface"` Interfaces map[string]string `mapstructure:"interfaces"` } @@ -62,16 +46,6 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("worker.buffer_size", 100) v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) - v.SetDefault("metadata.run_dir", "/run/two/metadata") - v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd") - v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd") - v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi") - v.SetDefault("qemu.serial_dir", "/run/two/vms/serial") - v.SetDefault("qemu.monitor_dir", "/run/two/vms/monitor") - v.SetDefault("qemu.qmp_dir", "/run/two/vms/qmp") - v.SetDefault("admin.enabled", false) - v.SetDefault("admin.address", "127.0.0.1") - v.SetDefault("admin.port", 9091) v.SetDefault("default_interface", "br-000000") v.SetDefault("logger.level", "info") v.SetDefault("logger.debug", false) diff --git a/internal/dhcp/db.go b/internal/dhcp/db.go deleted file mode 100644 index c93aa1b..0000000 --- a/internal/dhcp/db.go +++ /dev/null @@ -1,19 +0,0 @@ -package dhcp - -import ( - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" -) - -func StoreDHCPEntries(db *badger.DB, subnetName string, entries map[string]string) error { - for ip, mac := range entries { - if err := kv.AddInDB(db, "subnet/"+subnetName+"/dhcp/"+ip, mac); err != nil { - return err - } - } - return nil -} - -func GetMACForIP(db *badger.DB, subnetName, ip string) (string, error) { - return kv.GetFromDB(db, "subnet/"+subnetName+"/dhcp/"+ip) -} diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index a2d5e60..c4a659f 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -51,21 +51,17 @@ func TestIncrementIP_Carry(t *testing.T) { func newConf(t *testing.T, cidr string) Config { t.Helper() _, network, _ := net.ParseCIDR(cidr) - _, vpcNet, _ := net.ParseCIDR("10.0.0.0/16") - gw := net.ParseIP("192.168.1.1").To4() return Config{ - Network: network, - VPCGateway: gw, - VPCRoute: vpcNet, - DefaultGateway: gw, - Name: "test", - ConfDir: t.TempDir(), + 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) + path, err := GenerateConfig(conf) if err != nil { t.Fatalf("GenerateConfig a échoué : %v", err) } @@ -77,7 +73,7 @@ func TestGenerateConfig_CreatesFile(t *testing.T) { func TestGenerateConfig_FilenameMatchesName(t *testing.T) { conf := newConf(t, "192.168.1.0/29") - path, _, err := GenerateConfig(conf) + path, err := GenerateConfig(conf) if err != nil { t.Fatalf("GenerateConfig a échoué : %v", err) } @@ -88,45 +84,13 @@ func TestGenerateConfig_FilenameMatchesName(t *testing.T) { } } -func TestGenerateConfig_ContainsDefaultGateway(t *testing.T) { +func TestGenerateConfig_ContainsGateway(t *testing.T) { conf := newConf(t, "192.168.1.0/29") - path, _, _ := GenerateConfig(conf) + path, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") { - t.Errorf("dhcp-option=3 absente du fichier généré :\n%s", content) - } -} - -func TestGenerateConfig_NoDefaultGateway(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.DefaultGateway = nil - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) - - if strings.Contains(string(content), "dhcp-option=3,") { - t.Errorf("dhcp-option=3 présente alors que DefaultGateway=nil :\n%s", content) - } -} - -func TestGenerateConfig_ContainsVPCRoute(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=121,10.0.0.0/16,192.168.1.1") { - t.Errorf("dhcp-option=121 absente ou incorrecte :\n%s", content) - } -} - -func TestGenerateConfig_NoVPCRoute(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.VPCRoute = nil - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) - - if strings.Contains(string(content), "dhcp-option=121,") { - t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content) + t.Errorf("gateway absente du fichier généré :\n%s", content) } } @@ -134,10 +98,11 @@ 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) + path, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-range=10.10.0.0,static,255.255.255.0,12h") { @@ -148,7 +113,7 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { 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) + path, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) lines := strings.Split(string(content), "\n") @@ -166,7 +131,7 @@ func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) { func TestGenerateConfig_MACPrefix(t *testing.T) { conf := newConf(t, "10.0.0.0/30") // 4 adresses - path, _, _ := GenerateConfig(conf) + path, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "00:22:33:") { @@ -179,10 +144,11 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { _, 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 { + 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) { diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index 2b6c72f..01bfe20 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -8,34 +8,27 @@ import ( "strings" ) -func GenerateConfig(c Config) (string, map[string]string, error) { +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) - if c.VPCRoute != nil { - fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String()) - } - if c.DefaultGateway != nil { - fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String()) - } + 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") - entries := make(map[string]string) i := 0 for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { - mac := fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) - fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", mac, ip) - entries[ip.String()] = mac + 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 "", nil, err + return "", err } - return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) + return outPath, os.WriteFile(outPath, []byte(sb.String()), 0644) } func incrementIP(ip net.IP) { diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go index 316667c..4c69b9c 100644 --- a/internal/dhcp/struct.go +++ b/internal/dhcp/struct.go @@ -5,10 +5,8 @@ import ( ) type Config struct { - Network *net.IPNet - VPCGateway net.IP // next-hop for VPCRoute (option 121) - VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway - DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway - Name string - ConfDir string + Network *net.IPNet + Gateway net.IP + Name string + ConfDir string } diff --git a/internal/dispatcher/agent/dispatcher_test.go b/internal/dispatcher/agent/dispatcher_test.go deleted file mode 100644 index b4881d9..0000000 --- a/internal/dispatcher/agent/dispatcher_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package dispatcher - -import ( - "errors" - "sync" - "testing" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "github.com/dgraph-io/badger/v4" -) - -func TestDispatcher_Prepare_Success(t *testing.T) { - d, _ := newTestDispatcher(t) - cmd := mockCmd{ - prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, - executeFn: func(*badger.DB, *configuration.Config) error { return nil }, - } - if err := d.Prepare(cmd); err != nil { - t.Errorf("Prepare devrait retourner nil, obtenu : %v", err) - } -} - -func TestDispatcher_Prepare_PropagatesError(t *testing.T) { - d, _ := newTestDispatcher(t) - want := errors.New("prepare failed") - cmd := mockCmd{ - prepareFn: func(*badger.DB, *configuration.Config) error { return want }, - executeFn: func(*badger.DB, *configuration.Config) error { return nil }, - } - if err := d.Prepare(cmd); !errors.Is(err, want) { - t.Errorf("attendu %v, obtenu %v", want, err) - } -} - -func TestDispatcher_Dispatch_ExecutesCommand(t *testing.T) { - d, _ := newTestDispatcher(t) - var wg sync.WaitGroup - wg.Add(1) - cmd := mockCmd{ - prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, - executeFn: func(*badger.DB, *configuration.Config) error { - wg.Done() - return nil - }, - } - d.Dispatch(cmd) - wg.Wait() -} - -func TestDispatcher_Dispatch_ExecuteErrorLogged(t *testing.T) { - d, _ := newTestDispatcher(t) - var wg sync.WaitGroup - wg.Add(1) - cmd := mockCmd{ - prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, - executeFn: func(*badger.DB, *configuration.Config) error { - defer wg.Done() - return errors.New("execute failed") - }, - } - d.Dispatch(cmd) - wg.Wait() // Execute s'est terminé — l'erreur est loggée, pas propagée -} diff --git a/internal/dispatcher/agent/helpers_test.go b/internal/dispatcher/agent/helpers_test.go deleted file mode 100644 index 2cee6fc..0000000 --- a/internal/dispatcher/agent/helpers_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package dispatcher - -import ( - "io" - "log/slog" - "testing" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/pkg/db/kv" - "git.g3e.fr/syonad/two/pkg/worker" - "github.com/dgraph-io/badger/v4" -) - -func newTestDispatcher(t *testing.T) (*Dispatcher, *badger.DB) { - t.Helper() - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - q := worker.New(100) - q.Start(2) - cfg := &configuration.Config{DefaultInterface: "br-default"} - cfg.Interfaces = map[string]string{"vms": "br-vms"} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return New(q, db, cfg, logger), db -} - -// mockCmd implémente Command sans aucune dépendance système. -type mockCmd struct { - prepareFn func(*badger.DB, *configuration.Config) error - executeFn func(*badger.DB, *configuration.Config) error -} - -func (m mockCmd) Prepare(db *badger.DB, cfg *configuration.Config) error { - return m.prepareFn(db, cfg) -} - -func (m mockCmd) Execute(db *badger.DB, cfg *configuration.Config) error { - return m.executeFn(db, cfg) -} diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index e488842..18f2cd3 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -12,23 +12,15 @@ import ( ) type CreateSubnetCommand struct { - Name string - VPC string - Mode string - VxlanID int - IfaceType string - InterfaceIP string - CIDR string - DefaultRoute bool + Name string + VPC string + VxlanID int + IfaceType string + GatewayIP string + CIDR string } func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { - if c.Mode == "" { - c.Mode = "vxlan" - } - if c.Mode != "vxlan" && c.Mode != "bridge" { - return fmt.Errorf("unknown subnet mode %q", c.Mode) - } if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil { return fmt.Errorf("subnet %q already exists", c.Name) } @@ -45,14 +37,10 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e } kv.AddInDB(db, "subnet/"+c.Name+"/state", "creating") kv.AddInDB(db, "subnet/"+c.Name+"/vpc", c.VPC) - kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode) + kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface) - kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP) + kv.AddInDB(db, "subnet/"+c.Name+"/gateway_ip", c.GatewayIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) - kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute)) - if c.Mode == "vxlan" { - kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) - } return nil } diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go deleted file mode 100644 index b6aacee..0000000 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package dispatcher - -import ( - "testing" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -func testCfg() *configuration.Config { - cfg := &configuration.Config{DefaultInterface: "br-default"} - cfg.Interfaces = map[string]string{"vms": "br-vms"} - return cfg -} - -// --- CreateSubnetCommand.Prepare --- - -func TestCreateSubnetCommand_Prepare_Success(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - state, _ := kv.GetFromDB(db, "subnet/sn-1/state") - if state != "creating" { - t.Errorf("state attendu creating, obtenu %q", state) - } - vpc, _ := kv.GetFromDB(db, "subnet/sn-1/vpc") - if vpc != "vpc-1" { - t.Errorf("vpc attendu vpc-1, obtenu %q", vpc) - } -} - -func TestCreateSubnetCommand_Prepare_UsesIfaceTypeMapping(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - cmd.Prepare(db, testCfg()) - iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") - if iface != "br-vms" { - t.Errorf("local_iface attendu br-vms, obtenu %q", iface) - } -} - -func TestCreateSubnetCommand_Prepare_UsesDefaultIfaceWhenTypeUnknown(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "inconnu", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - cmd.Prepare(db, testCfg()) - iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") - if iface != "br-default" { - t.Errorf("local_iface attendu br-default, obtenu %q", iface) - } -} - -func TestCreateSubnetCommand_Prepare_Duplicate(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - kv.AddInDB(db, "subnet/sn-exist/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-exist", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err == nil { - t.Error("Prepare devrait échouer sur un subnet déjà existant") - } -} - -func TestCreateSubnetCommand_Prepare_VPCNotFound(t *testing.T) { - _, db := newTestDispatcher(t) - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-inexistant", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err == nil { - t.Error("Prepare devrait échouer si le VPC n'existe pas") - } -} - -func TestCreateSubnetCommand_Prepare_VPCDeleting(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-dying", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err == nil { - t.Error("Prepare devrait échouer si le VPC est en cours de suppression") - } -} - -func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-gone/state", "deleted") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-gone", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err == nil { - t.Error("Prepare devrait échouer si le VPC est supprimé") - } -} - -func TestCreateSubnetCommand_Prepare_DefaultsToVxlanMode(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - cmd.Prepare(db, testCfg()) - mode, _ := kv.GetFromDB(db, "subnet/sn-1/mode") - if mode != "vxlan" { - t.Errorf("mode attendu vxlan, obtenu %q", mode) - } - if _, err := kv.GetFromDB(db, "subnet/sn-1/vxlan_id"); err != nil { - t.Error("vxlan_id devrait être écrit en mode vxlan") - } -} - -func TestCreateSubnetCommand_Prepare_BridgeMode_Success(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", Mode: "bridge", - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - mode, _ := kv.GetFromDB(db, "subnet/sn-1/mode") - if mode != "bridge" { - t.Errorf("mode attendu bridge, obtenu %q", mode) - } - iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") - if iface != "br-vms" { - t.Errorf("local_iface attendu br-vms, obtenu %q", iface) - } -} - -func TestCreateSubnetCommand_Prepare_BridgeMode_NoVxlanID(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", Mode: "bridge", - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - cmd.Prepare(db, testCfg()) - if _, err := kv.GetFromDB(db, "subnet/sn-1/vxlan_id"); err == nil { - t.Error("vxlan_id ne devrait pas être écrit en mode bridge") - } -} - -func TestCreateSubnetCommand_Prepare_UnknownMode(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", Mode: "vlan", - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - if err := cmd.Prepare(db, testCfg()); err == nil { - t.Error("Prepare devrait échouer pour un mode inconnu") - } -} - -func TestCreateSubnetCommand_Prepare_DefaultRouteStored(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - DefaultRoute: true, - } - if err := cmd.Prepare(db, testCfg()); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - val, err := kv.GetFromDB(db, "subnet/sn-1/default_route") - if err != nil { - t.Fatalf("default_route non écrit en DB : %v", err) - } - if val != "true" { - t.Errorf("default_route attendu true, obtenu %q", val) - } -} - -func TestCreateSubnetCommand_Prepare_DefaultRouteFalseByDefault(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "created") - cmd := CreateSubnetCommand{ - Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - } - cmd.Prepare(db, testCfg()) - val, _ := kv.GetFromDB(db, "subnet/sn-1/default_route") - if val != "false" { - t.Errorf("default_route attendu false, obtenu %q", val) - } -} - -// --- DeleteSubnetCommand.Prepare --- - -func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-del/state", "created") - cmd := DeleteSubnetCommand{Name: "sn-del"} - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - state, _ := kv.GetFromDB(db, "subnet/sn-del/state") - if state != "deleting" { - t.Errorf("state attendu deleting, obtenu %q", state) - } -} - -func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) { - _, db := newTestDispatcher(t) - cmd := DeleteSubnetCommand{Name: "sn-inexistant"} - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer si le subnet n'existe pas") - } -} diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go deleted file mode 100644 index bdfeaec..0000000 --- a/internal/dispatcher/agent/vm_commands.go +++ /dev/null @@ -1,133 +0,0 @@ -package dispatcher - -import ( - "fmt" - "math/rand" - "strconv" - "strings" - "time" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/vm" - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" -) - -type VMDisk struct { - Path string - Dev string -} - -type StartVMCommand struct { - Name string - Subnet string - IP string - Disks []VMDisk - Memory int - CPUs int - UEFI bool - Password string - SSHKey string -} - -func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { - if _, err := kv.GetFromDB(db, "vm/"+c.Name+"/state"); err == nil { - return fmt.Errorf("vm %q already exists", c.Name) - } - subnetState, err := kv.GetFromDB(db, "subnet/"+c.Subnet+"/state") - if err != nil { - return fmt.Errorf("subnet %q not found", c.Subnet) - } - if subnetState == "deleting" || subnetState == "deleted" { - return fmt.Errorf("subnet %q is %s", c.Subnet, subnetState) - } - port, err := allocateMetadataPort(db) - if err != nil { - return fmt.Errorf("allocate metadata port: %w", err) - } - kv.AddInDB(db, "vm/"+c.Name+"/state", "starting") - kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) - kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) - kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", strconv.Itoa(port)) - for _, d := range c.Disks { - kv.AddInDB(db, "vm/"+c.Name+"/disk/"+d.Dev, d.Path) - } - kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) - kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) - if c.UEFI { - kv.AddInDB(db, "vm/"+c.Name+"/uefi", "true") - } - if c.Password != "" { - kv.AddInDB(db, "vm/"+c.Name+"/password", c.Password) - } - if c.SSHKey != "" { - kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey) - } - return nil -} - -func allocateMetadataPort(db *badger.DB) (int, error) { - entries, err := kv.ListByPrefix(db, "vm/") - if err != nil { - return 0, err - } - used := make(map[int]struct{}) - for key, value := range entries { - if strings.HasSuffix(key, "/metadata_port") { - if p, err := strconv.Atoi(value); err == nil { - used[p] = struct{}{} - } - } - } - for range 100 { - p := rand.Intn(9000) + 1000 - if _, taken := used[p]; !taken { - return p, nil - } - } - return 0, fmt.Errorf("no free metadata port available in [1000, 9999]") -} - -func (c StartVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error { - timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) - for { - state, err := kv.GetFromDB(db, "subnet/"+c.Subnet+"/state") - if err != nil { - return fmt.Errorf("subnet %q not found while waiting", c.Subnet) - } - if state == "created" { - break - } - select { - case <-timeout: - return fmt.Errorf("timed out waiting for subnet %q to be created", c.Subnet) - case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second): - } - } - return vm.StartVM(db, c.Name, cfg) -} - -type StopVMCommand struct { - Name string -} - -func (c StopVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { - if _, err := kv.GetFromDB(db, "vm/"+c.Name+"/state"); err != nil { - return fmt.Errorf("vm %q not found", c.Name) - } - return kv.AddInDB(db, "vm/"+c.Name+"/state", "stopping") -} - -func (c StopVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error { - if err := vm.StopVM(db, c.Name, cfg); err != nil { - return err - } - state, err := kv.GetFromDB(db, "vm/"+c.Name+"/state") - if err != nil { - return err - } - if state == "stopped" { - kv.DeleteInDB(db, "vm/"+c.Name) - } - return nil -} diff --git a/internal/dispatcher/agent/vm_commands_test.go b/internal/dispatcher/agent/vm_commands_test.go deleted file mode 100644 index ba400b9..0000000 --- a/internal/dispatcher/agent/vm_commands_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package dispatcher - -import ( - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -// --- StartVMCommand.Prepare : écriture des disques en DB --- - -func TestStartVMCommand_Prepare_SingleDisk(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - cmd := StartVMCommand{ - Name: "vm-1", - Subnet: "sn-1", - IP: "10.0.0.5", - Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, - } - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - - path, err := kv.GetFromDB(db, "vm/vm-1/disk/sda") - if err != nil { - t.Fatalf("clé disk/sda absente en DB : %v", err) - } - if path != "/data/root.qcow2" { - t.Errorf("path attendu /data/root.qcow2, obtenu %q", path) - } -} - -func TestStartVMCommand_Prepare_MultiDisk(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - cmd := StartVMCommand{ - Name: "vm-2", - Subnet: "sn-1", - IP: "10.0.0.6", - Disks: []VMDisk{ - {Path: "/data/root.qcow2", Dev: "sda"}, - {Path: "/data/data.qcow2", Dev: "sdb"}, - }, - } - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - - for dev, want := range map[string]string{ - "sda": "/data/root.qcow2", - "sdb": "/data/data.qcow2", - } { - got, err := kv.GetFromDB(db, "vm/vm-2/disk/"+dev) - if err != nil { - t.Fatalf("clé disk/%s absente en DB : %v", dev, err) - } - if got != want { - t.Errorf("disk/%s : attendu %q, obtenu %q", dev, want, got) - } - } -} - -func TestStartVMCommand_Prepare_SlotGap(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - // sdb absent au boot — slot réservé pour hotplug - cmd := StartVMCommand{ - Name: "vm-3", - Subnet: "sn-1", - IP: "10.0.0.7", - Disks: []VMDisk{ - {Path: "/data/root.qcow2", Dev: "sda"}, - {Path: "/data/extra.qcow2", Dev: "sdc"}, - }, - } - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - - if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sda"); err != nil { - t.Fatalf("disk/sda absent : %v", err) - } - if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdc"); err != nil { - t.Fatalf("disk/sdc absent : %v", err) - } - if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdb"); err == nil { - t.Error("disk/sdb ne devrait pas exister en DB") - } -} - -func TestStartVMCommand_Prepare_NoVolumePath(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - cmd := StartVMCommand{ - Name: "vm-4", - Subnet: "sn-1", - IP: "10.0.0.8", - Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, - } - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - - if _, err := kv.GetFromDB(db, "vm/vm-4/volume_path"); err == nil { - t.Error("volume_path ne devrait plus être écrit en DB") - } -} - -func TestStartVMCommand_Prepare_Duplicate(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vm/vm-exist/state", "started") - - cmd := StartVMCommand{ - Name: "vm-exist", - Subnet: "sn-1", - IP: "10.0.0.9", - Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, - } - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer si la VM existe déjà") - } -} diff --git a/internal/dispatcher/agent/vpc_commands.go b/internal/dispatcher/agent/vpc_commands.go index a129d88..c03dd77 100644 --- a/internal/dispatcher/agent/vpc_commands.go +++ b/internal/dispatcher/agent/vpc_commands.go @@ -2,7 +2,6 @@ package dispatcher import ( "fmt" - "net" "strings" "time" @@ -14,19 +13,12 @@ import ( type CreateVPCCommand struct { Name string - CIDR string } func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error { if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil { return fmt.Errorf("vpc %q already exists", c.Name) } - if _, _, err := net.ParseCIDR(c.CIDR); err != nil { - return fmt.Errorf("invalid cidr %q: %w", c.CIDR, err) - } - if err := kv.AddInDB(db, "vpc/"+c.Name+"/cidr", c.CIDR); err != nil { - return err - } return kv.AddInDB(db, "vpc/"+c.Name+"/state", "creating") } diff --git a/internal/dispatcher/agent/vpc_commands_test.go b/internal/dispatcher/agent/vpc_commands_test.go deleted file mode 100644 index 9633733..0000000 --- a/internal/dispatcher/agent/vpc_commands_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package dispatcher - -import ( - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -// --- CreateVPCCommand.Prepare --- - -func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { - _, db := newTestDispatcher(t) - cmd := CreateVPCCommand{Name: "vpc-1", CIDR: "10.0.0.0/16"} - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - state, err := kv.GetFromDB(db, "vpc/vpc-1/state") - if err != nil { - t.Fatalf("état non écrit en DB : %v", err) - } - if state != "creating" { - t.Errorf("state attendu creating, obtenu %q", state) - } - cidr, err := kv.GetFromDB(db, "vpc/vpc-1/cidr") - if err != nil { - t.Fatalf("cidr non écrit en DB : %v", err) - } - if cidr != "10.0.0.0/16" { - t.Errorf("cidr attendu 10.0.0.0/16, obtenu %q", cidr) - } -} - -func TestCreateVPCCommand_Prepare_Duplicate(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-exist/state", "created") - cmd := CreateVPCCommand{Name: "vpc-exist", CIDR: "10.0.0.0/16"} - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer sur un VPC déjà existant") - } -} - -func TestCreateVPCCommand_Prepare_InvalidCIDR(t *testing.T) { - _, db := newTestDispatcher(t) - cmd := CreateVPCCommand{Name: "vpc-bad", CIDR: "not-a-cidr"} - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer avec un CIDR invalide") - } -} - -// --- DeleteVPCCommand.Prepare --- - -func TestDeleteVPCCommand_Prepare_Success(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-del/state", "created") - cmd := DeleteVPCCommand{Name: "vpc-del"} - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - state, _ := kv.GetFromDB(db, "vpc/vpc-del/state") - if state != "deleting" { - t.Errorf("state attendu deleting, obtenu %q", state) - } -} - -func TestDeleteVPCCommand_Prepare_NotFound(t *testing.T) { - _, db := newTestDispatcher(t) - cmd := DeleteVPCCommand{Name: "vpc-inexistant"} - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer si le VPC n'existe pas") - } -} - -func TestDeleteVPCCommand_Prepare_BlockedByActiveSubnet(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-busy/state", "created") - kv.AddInDB(db, "subnet/sn-1/state", "created") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") - cmd := DeleteVPCCommand{Name: "vpc-busy"} - if err := cmd.Prepare(db, nil); err == nil { - t.Error("Prepare devrait échouer si un subnet actif existe") - } -} - -func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleted(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-ok/state", "created") - kv.AddInDB(db, "subnet/sn-1/state", "deleted") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") - cmd := DeleteVPCCommand{Name: "vpc-ok"} - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare devrait réussir si le subnet est deleted : %v", err) - } -} - -func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleting(t *testing.T) { - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-ok/state", "created") - kv.AddInDB(db, "subnet/sn-1/state", "deleting") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") - cmd := DeleteVPCCommand{Name: "vpc-ok"} - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare devrait réussir si le subnet est deleting : %v", err) - } -} diff --git a/internal/ebtables/ebtables.go b/internal/ebtables/ebtables.go deleted file mode 100644 index 40ea0f0..0000000 --- a/internal/ebtables/ebtables.go +++ /dev/null @@ -1,60 +0,0 @@ -package ebtables - -import ( - "fmt" - "os/exec" -) - -func addRule(args ...string) error { - return exec.Command("ebtables", append([]string{"-A"}, args...)...).Run() -} - -func deleteRule(args ...string) error { - return exec.Command("ebtables", append([]string{"-D"}, args...)...).Run() -} - -func DropARPToGateway(iface, ip string) error { - if err := addRule("FORWARD", - "--out-interface", iface, - "-p", "arp", - "--arp-op", "Request", - "--arp-ip-dst", ip, - "-j", "DROP"); err != nil { - return fmt.Errorf("ebtables arp rule: %w", err) - } - return nil -} - -func DropDHCP(iface, ip string) error { - if err := addRule("FORWARD", - "--out-interface", iface, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "--ip-source", ip, - "-j", "DROP"); err != nil { - return fmt.Errorf("ebtables dhcp rule: %w", err) - } - return nil -} - -func DeleteARPToGateway(iface, ip string) error { - return deleteRule("FORWARD", - "--out-interface", iface, - "-p", "arp", - "--arp-op", "Request", - "--arp-ip-dst", ip, - "-j", "DROP") -} - -func DeleteDHCP(iface, ip string) error { - return deleteRule("FORWARD", - "--out-interface", iface, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "--ip-source", ip, - "-j", "DROP") -} diff --git a/internal/iptables/iptables.go b/internal/iptables/iptables.go deleted file mode 100644 index 1d156a7..0000000 --- a/internal/iptables/iptables.go +++ /dev/null @@ -1,42 +0,0 @@ -package iptables - -import ( - "fmt" - "os/exec" -) - -func addRule(args ...string) error { - return exec.Command("iptables", append([]string{"-t", "nat", "-A"}, args...)...).Run() -} - -func deleteRule(args ...string) error { - return exec.Command("iptables", append([]string{"-t", "nat", "-D"}, args...)...).Run() -} - -func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { - if err := addRule("PREROUTING", - "-s", vmIP+"/32", - "-d", "169.254.169.254/32", - "-p", "tcp", "-m", "tcp", - "--dport", "80", - "-j", "DNAT", - "--to-destination", gatewayIP+":"+metadataPort, - ); err != nil { - return fmt.Errorf("iptables metadata redirect: %w", err) - } - return nil -} - -func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { - if err := deleteRule("PREROUTING", - "-s", vmIP+"/32", - "-d", "169.254.169.254/32", - "-p", "tcp", "-m", "tcp", - "--dport", "80", - "-j", "DNAT", - "--to-destination", gatewayIP+":"+metadataPort, - ); err != nil { - return fmt.Errorf("iptables delete metadata redirect: %w", err) - } - return nil -} diff --git a/internal/metadata/handle.go b/internal/metadata/handle.go index d34dd20..9ec5486 100644 --- a/internal/metadata/handle.go +++ b/internal/metadata/handle.go @@ -3,18 +3,18 @@ package metadata import ( "fmt" - configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/pkg/systemd" + "github.com/dgraph-io/badger/v4" ) -func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error { +func StartMetadata(config NoCloudConfig, db *badger.DB, dryrun bool) error { service, err := systemd.New() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - LoadNcCloudInDB(config, cfg.Metadata.RunDir) + LoadNcCloudInDB(config, db) if !dryrun { if err := service.Start("metadata@" + config.Name + ".service"); err != nil { return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) @@ -23,17 +23,17 @@ func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) return nil } -func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error { +func StopMetadata(vm_name string, db *badger.DB, dryrun bool) error { service, err := systemd.New() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir) + UnLoadNoCloudInDB(vm_name, db) if !dryrun { - if err := service.Stop("metadata@" + vmName + ".service"); err != nil { - return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err) + if err := service.Stop("metadata@" + vm_name + ".service"); err != nil { + return fmt.Errorf("failed to stop metadata@%s: %w", vm_name, err) } } return nil diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index cc54530..62a2830 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -1,12 +1,10 @@ package metadata import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" "strings" "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" ) func newCfg() NoCloudConfig { @@ -20,9 +18,11 @@ func newCfg() NoCloudConfig { } } -func useTestDir(t *testing.T) string { +func newTestDB(t *testing.T) interface{ Close() error } { t.Helper() - return t.TempDir() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + return db } // --- RenderConfig --- @@ -106,161 +106,95 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) { // --- LoadNcCloudInDB / UnLoadNoCloudInDB --- -func readTestFile(t *testing.T, dir, vmName, name string) string { - t.Helper() - b, err := os.ReadFile(filepath.Join(dir, vmName, name)) - if err != nil { - t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err) - return "" +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", } - return string(b) -} - -func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) { - dir := useTestDir(t) - LoadNcCloudInDB(newCfg(), dir) - - files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"} - for _, f := range files { - path := filepath.Join(dir, "vm1", f) - if _, err := os.Stat(path); err != nil { - t.Errorf("fichier %q absent : %v", f, err) + 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) { - dir := useTestDir(t) - LoadNcCloudInDB(newCfg(), dir) + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) - if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" { + 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) } - if ip := readTestFile(t, dir, "vm1", "bind_ip"); ip != "169.254.169.254" { + + 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) } - if port := readTestFile(t, dir, "vm1", "bind_port"); port != "80" { + + port, _ := kv.GetFromDB(db, "metadata/vm1/bind_port") + if port != "80" { t.Errorf("bind_port attendu %q, obtenu %q", "80", port) } } -func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) { - dir := useTestDir(t) - LoadNcCloudInDB(newCfg(), dir) - UnLoadNoCloudInDB("vm1", dir) +func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) { + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) - if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) { - t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB") + 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) { - dir := useTestDir(t) + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) cfg1 := newCfg() cfg2 := newCfg() cfg2.Name = "vm2" - LoadNcCloudInDB(cfg1, dir) - LoadNcCloudInDB(cfg2, dir) + LoadNcCloudInDB(cfg1, db) + LoadNcCloudInDB(cfg2, db) - UnLoadNoCloudInDB("vm1", dir) + UnLoadNoCloudInDB("vm1", db) - if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil { + _, err := kv.GetFromDB(db, "metadata/vm2/vpc") + if err != nil { t.Errorf("vm2 ne devrait pas être supprimée : %v", err) } } - -// --- getIP --- - -func TestGetIP_ValidHostPort(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.RemoteAddr = "10.0.0.1:4567" - if ip := getIP(req); ip != "10.0.0.1" { - t.Errorf("attendu 10.0.0.1, obtenu %q", ip) - } -} - -func TestGetIP_IPv6(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.RemoteAddr = "[::1]:8080" - if ip := getIP(req); ip != "::1" { - t.Errorf("attendu ::1, obtenu %q", ip) - } -} - -func TestGetIP_NoPort(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.RemoteAddr = "10.0.0.1" - if ip := getIP(req); ip != "10.0.0.1" { - t.Errorf("attendu RemoteAddr brut, obtenu %q", ip) - } -} - -// --- rootHandler --- - -func TestRootHandler_UserData(t *testing.T) { - data = NoCloudData{UserData: "userdata-content"} - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/user-data", nil) - rootHandler(w, req) - if w.Code != http.StatusOK { - t.Errorf("attendu 200, obtenu %d", w.Code) - } - if body := w.Body.String(); body != "userdata-content" { - t.Errorf("body inattendu : %q", body) - } -} - -func TestRootHandler_MetaData(t *testing.T) { - data = NoCloudData{MetaData: "metadata-content"} - w := httptest.NewRecorder() - rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) - if w.Code != http.StatusOK { - t.Errorf("attendu 200, obtenu %d", w.Code) - } - if body := w.Body.String(); body != "metadata-content" { - t.Errorf("body inattendu : %q", body) - } -} - -func TestRootHandler_NetworkConfig(t *testing.T) { - data = NoCloudData{NetworkConfig: "network-content"} - w := httptest.NewRecorder() - rootHandler(w, httptest.NewRequest(http.MethodGet, "/network-config", nil)) - if w.Code != http.StatusOK { - t.Errorf("attendu 200, obtenu %d", w.Code) - } - if body := w.Body.String(); body != "network-content" { - t.Errorf("body inattendu : %q", body) - } -} - -func TestRootHandler_VendorData(t *testing.T) { - data = NoCloudData{VendorData: "vendor-content"} - w := httptest.NewRecorder() - rootHandler(w, httptest.NewRequest(http.MethodGet, "/vendor-data", nil)) - if w.Code != http.StatusOK { - t.Errorf("attendu 200, obtenu %d", w.Code) - } - if body := w.Body.String(); body != "vendor-content" { - t.Errorf("body inattendu : %q", body) - } -} - -func TestRootHandler_UnknownPath(t *testing.T) { - data = NoCloudData{} - w := httptest.NewRecorder() - rootHandler(w, httptest.NewRequest(http.MethodGet, "/unknown", nil)) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404, obtenu %d", w.Code) - } -} - -func TestRootHandler_ContentType(t *testing.T) { - data = NoCloudData{MetaData: "x"} - w := httptest.NewRecorder() - rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) - if ct := w.Header().Get("Content-Type"); ct != "text/yaml" { - t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) - } -} diff --git a/internal/metadata/render.go b/internal/metadata/render.go index 0d4fd59..f6c3cae 100644 --- a/internal/metadata/render.go +++ b/internal/metadata/render.go @@ -3,9 +3,10 @@ package metadata import ( "bytes" "embed" - "os" - "path/filepath" "text/template" + + "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" ) //go:embed templates/*.tmpl @@ -25,25 +26,21 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) { return buf.String(), nil } -func LoadNcCloudInDB(config NoCloudConfig, runDir string) { +func LoadNcCloudInDB(config NoCloudConfig, db *badger.DB) { meta_data, _ := RenderConfig("templates/meta-data.tmpl", config) user_data, _ := RenderConfig("templates/user-data.tmpl", config) network_config, _ := RenderConfig("templates/network-config.tmpl", config) vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config) - dir := filepath.Join(runDir, config.Name) - if err := os.MkdirAll(dir, 0755); err != nil { - return - } - os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644) - os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644) - os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644) - os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644) - os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644) - os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644) - os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644) + kv.AddInDB(db, "metadata/"+config.Name+"/meta-data", meta_data) + kv.AddInDB(db, "metadata/"+config.Name+"/user-data", user_data) + kv.AddInDB(db, "metadata/"+config.Name+"/network-config", network_config) + kv.AddInDB(db, "metadata/"+config.Name+"/vendor-data", vendor_data) + kv.AddInDB(db, "metadata/"+config.Name+"/vpc", config.VpcName) + kv.AddInDB(db, "metadata/"+config.Name+"/bind_ip", config.BindIP) + kv.AddInDB(db, "metadata/"+config.Name+"/bind_port", config.BindPort) } -func UnLoadNoCloudInDB(vmName string, runDir string) { - os.RemoveAll(filepath.Join(runDir, vmName)) +func UnLoadNoCloudInDB(vm_name string, db *badger.DB) { + kv.DeleteInDB(db, "metadata/"+vm_name) } diff --git a/internal/metadata/server.go b/internal/metadata/server.go index b1f8943..3b4e12f 100644 --- a/internal/metadata/server.go +++ b/internal/metadata/server.go @@ -5,13 +5,12 @@ import ( "log" "net" "net/http" - "os" - "path/filepath" "strconv" - "strings" "time" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/netns" + "git.g3e.fr/syonad/two/pkg/db/kv" ) var data NoCloudData @@ -24,23 +23,47 @@ func getIP(r *http.Request) string { return ip } -func readFile(dir, name string) string { - b, _ := os.ReadFile(filepath.Join(dir, name)) - return strings.TrimRight(string(b), "\n") -} +func getFromDB(config ServerConfig) NoCloudData { + var netns_name string + var port int + var iface string -func getFromFiles(config ServerConfig) NoCloudData { - dir := filepath.Join(config.RunDir, config.VmName) + conf_db, _ := configuration.LoadConfig(config.ConfFile) - port, _ := strconv.Atoi(readFile(dir, "bind_port")) + db := kv.InitDB(kv.Config{Path: conf_db.Database.Path}, true) + defer db.Close() + + metadata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/meta-data") + userdata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/user-data") + networkconfig, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/network-config") + vendordata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/vendor-data") + + if config.Netns == "" { + netns_name, _ = kv.GetFromDB(db, "metadata/"+config.VmName+"/vpc") + } else { + netns_name = config.Netns + } + + if config.Iface == "" { + iface, _ = kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_ip") + } else { + iface = config.Iface + } + + if config.Port == 0 { + sport, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_port") + port, _ = strconv.Atoi(sport) + } else { + port = config.Port + } return NoCloudData{ - MetaData: readFile(dir, "meta-data"), - UserData: readFile(dir, "user-data"), - NetworkConfig: readFile(dir, "network-config"), - VendorData: readFile(dir, "vendor-data"), - NetNs: readFile(dir, "vpc"), - Iface: readFile(dir, "bind_ip"), + MetaData: metadata, + UserData: userdata, + NetworkConfig: networkconfig, + VendorData: vendordata, + NetNs: netns_name, + Iface: iface, Port: port, } } @@ -70,7 +93,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { } func StartServer(config ServerConfig) { - data = getFromFiles(config) + data = getFromDB(config) if data.NetNs != "" { if err := netns.Enter(data.NetNs); err != nil { diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index e706087..5810ff4 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -11,8 +11,12 @@ type NoCloudData struct { } type ServerConfig struct { - VmName string - RunDir string + Netns string + File string + Iface string + Port int + ConfFile string + VmName string } type NoCloudConfig struct { diff --git a/internal/metadata/templates/vendor-data.tmpl b/internal/metadata/templates/vendor-data.tmpl index fe0dcd8..148d6db 100644 --- a/internal/metadata/templates/vendor-data.tmpl +++ b/internal/metadata/templates/vendor-data.tmpl @@ -2,8 +2,12 @@ users: - name: syonad lock_passwd: false + gecos: alpine Cloud User + groups: [adm, wheel] + doas: + - permit nopass syonad sudo: ["ALL=(ALL) NOPASSWD:ALL"] - shell: /bin/bash + shell: /bin/ash passwd: "{{ .Password }}" ssh_authorized_keys: - - "{{ .SSHKEY }}" + - "{{ .SSHKEY }}" \ No newline at end of file diff --git a/internal/netif/gateway_linux.go b/internal/netif/gateway_linux.go deleted file mode 100644 index 6141c64..0000000 --- a/internal/netif/gateway_linux.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build linux - -package netif - -import ( - "fmt" - "net" - - "github.com/vishvananda/netlink" -) - -func GetDefaultGateway() (net.IP, error) { - routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) - if err != nil { - return nil, fmt.Errorf("list routes: %w", err) - } - for _, r := range routes { - if r.Gw == nil { - continue - } - if r.Dst == nil { - return r.Gw, nil - } - if ones, _ := r.Dst.Mask.Size(); ones == 0 { - return r.Gw, nil - } - } - return nil, fmt.Errorf("no default gateway found") -} diff --git a/internal/netif/gateway_other.go b/internal/netif/gateway_other.go deleted file mode 100644 index 764867c..0000000 --- a/internal/netif/gateway_other.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !linux - -package netif - -import ( - "fmt" - "net" -) - -func GetDefaultGateway() (net.IP, error) { - return nil, fmt.Errorf("not supported on this platform") -} diff --git a/internal/netif/tap_linux.go b/internal/netif/tap_linux.go deleted file mode 100644 index 15313af..0000000 --- a/internal/netif/tap_linux.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build linux - -package netif - -import ( - "fmt" - - "git.g3e.fr/syonad/two/internal/netns" - "github.com/vishvananda/netlink" -) - -func CreateTap(tapID int, bridgeName, vpcName string) error { - name := fmt.Sprintf("tap%d", tapID) - - return netns.Call(vpcName, func() error { - tap := &netlink.Tuntap{ - LinkAttrs: netlink.LinkAttrs{Name: name}, - Mode: netlink.TUNTAP_MODE_TAP, - } - if err := netlink.LinkAdd(tap); err != nil { - return err - } - if err := BridgeSetMaster(name, bridgeName); err != nil { - return err - } - return LinkSetUp(name) - }) -} - -func DeleteTap(tapID int, vpcName string) error { - return netns.Call(vpcName, func() error { - return DeleteLink(fmt.Sprintf("tap%d", tapID)) - }) -} diff --git a/internal/netif/tap_other.go b/internal/netif/tap_other.go deleted file mode 100644 index 18afcbe..0000000 --- a/internal/netif/tap_other.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !linux - -package netif - -import "errors" - -func CreateTap(_ int, _, _ string) error { - return errors.New("netif: tap not supported on this platform") -} - -func DeleteTap(_ int, _ string) error { - return errors.New("netif: tap not supported on this platform") -} diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go index 70740bc..6523f37 100644 --- a/internal/netif/vxlan.go +++ b/internal/netif/vxlan.go @@ -4,7 +4,7 @@ import ( "github.com/vishvananda/netlink" ) -func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { +func CreateVxlan(name string, vxlanID int, localIface string) error { link, err := netlink.LinkByName(localIface) if err != nil { return err @@ -12,7 +12,6 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, - MTU: mtu, }, VxlanId: vxlanID, Port: 4789, diff --git a/internal/qemu/config.go b/internal/qemu/config.go deleted file mode 100644 index 9b50add..0000000 --- a/internal/qemu/config.go +++ /dev/null @@ -1,20 +0,0 @@ -package qemu - -type DiskConfig struct { - Path string - Dev string -} - -type Config struct { - Name string - TapID int - Mac string - Disks []DiskConfig - Memory int - CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string -} diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go deleted file mode 100644 index a7869a2..0000000 --- a/internal/qemu/start_linux.go +++ /dev/null @@ -1,106 +0,0 @@ -//go:build linux - -package qemu - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" -) - -func Start(cfg Config) error { - memory := cfg.Memory - if memory == 0 { - memory = 512 - } - - cpus := cfg.CPUs - if cpus == 0 { - cpus = 1 - } - - for _, dir := range []string{cfg.SerialDir, cfg.MonitorDir, cfg.QMPDir} { - if dir != "" { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - } - } - - serialSock := filepath.Join(cfg.SerialDir, cfg.Name+".sock") - monitorSock := filepath.Join(cfg.MonitorDir, cfg.Name+".sock") - qmpSock := filepath.Join(cfg.QMPDir, cfg.Name+".sock") - - args := []string{ - "-enable-kvm", - "-cpu", "host", - "-m", fmt.Sprintf("%d", memory), - "-smp", fmt.Sprintf("%d", cpus), - "-serial", fmt.Sprintf("unix:%s,server,nowait", serialSock), - "-monitor", fmt.Sprintf("unix:%s,server,nowait", monitorSock), - "-qmp", fmt.Sprintf("unix:%s,server,nowait", qmpSock), - "-display", "none", - } - - if cfg.UEFICodePath != "" && cfg.UEFIVarsPath != "" { - args = append(args, - "-drive", fmt.Sprintf("if=pflash,format=raw,readonly=on,file=%s", cfg.UEFICodePath), - "-drive", fmt.Sprintf("if=pflash,format=raw,file=%s", cfg.UEFIVarsPath), - ) - } - - hasScsi := false - for _, d := range cfg.Disks { - if strings.HasPrefix(d.Dev, "sd") { - hasScsi = true - break - } - } - if hasScsi { - args = append(args, "-device", "virtio-scsi-pci,id=scsi0") - } - - sorted := make([]DiskConfig, len(cfg.Disks)) - copy(sorted, cfg.Disks) - // vd* avant sd* : les disques virtio-blk bootent en premier. - // À lettre égale de type, ordre alphabétique. - sort.Slice(sorted, func(i, j int) bool { - iVirtio := strings.HasPrefix(sorted[i].Dev, "vd") - jVirtio := strings.HasPrefix(sorted[j].Dev, "vd") - if iVirtio != jVirtio { - return iVirtio - } - return sorted[i].Dev < sorted[j].Dev - }) - - for idx, d := range sorted { - bootindex := idx + 1 - if strings.HasPrefix(d.Dev, "sd") { - scsiID := int(d.Dev[2] - 'a') - args = append(args, - "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), - "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d,bootindex=%d", d.Dev, scsiID, bootindex), - ) - } else { - args = append(args, - "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), - "-device", fmt.Sprintf("virtio-blk-pci,drive=%s,bootindex=%d", d.Dev, bootindex), - ) - } - } - - args = append(args, - "-netdev", fmt.Sprintf("tap,id=net0,ifname=tap%d,script=no,downscript=no", cfg.TapID), - "-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", cfg.Mac), - "-daemonize", - ) - - cmd := exec.Command("qemu-system-x86_64", args...) - if err := cmd.Run(); err != nil { - return fmt.Errorf("qemu-system-x86_64: %w", err) - } - return nil -} diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go deleted file mode 100644 index c28411c..0000000 --- a/internal/qemu/start_other.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !linux - -package qemu - -import ( - "errors" -) - -func Start(_ Config) error { - return errors.New("vm: not supported on this platform") -} diff --git a/internal/qmp/send.go b/internal/qmp/send.go deleted file mode 100644 index 8ce6711..0000000 --- a/internal/qmp/send.go +++ /dev/null @@ -1,50 +0,0 @@ -package qmp - -import ( - "bufio" - "encoding/json" - "fmt" - "net" - "strings" -) - -func Send(socketPath string, commands []string) ([]json.RawMessage, error) { - conn, err := net.Dial("unix", socketPath) - if err != nil { - return nil, fmt.Errorf("qmp dial: %w", err) - } - defer conn.Close() - - r := bufio.NewReader(conn) - - if _, err := r.ReadString('\n'); err != nil { - return nil, fmt.Errorf("qmp read greeting: %w", err) - } - - var sb strings.Builder - sb.WriteString(`{ "execute": "qmp_capabilities" }`) - for _, cmd := range commands { - sb.WriteByte('\n') - sb.WriteString(cmd) - } - sb.WriteByte('\n') - - if _, err := fmt.Fprint(conn, sb.String()); err != nil { - return nil, fmt.Errorf("qmp write: %w", err) - } - - if _, err := r.ReadString('\n'); err != nil { - return nil, fmt.Errorf("qmp read capabilities response: %w", err) - } - - var results []json.RawMessage - for range commands { - line, err := r.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("qmp read response: %w", err) - } - results = append(results, json.RawMessage(line)) - } - - return results, nil -} diff --git a/internal/subnet/create.go b/internal/subnet/create.go index a3faf46..1ed514e 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -2,9 +2,12 @@ package subnet import ( "fmt" + "net" + "os/exec" + "strconv" + "strings" "git.g3e.fr/syonad/two/internal/dhcp" - "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -22,53 +25,94 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return nil } - d, err := loadSubnet(db, subnetName) + // lecture des paramètres depuis la DB + vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") if err != nil { - return err + return fmt.Errorf("get vpc: %w", err) } - if err := createSubnet(db, subnetName, d); err != nil { - return err + vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id") + if err != nil { + return fmt.Errorf("get vxlan_id: %w", err) + } + vxlanID, err := strconv.Atoi(vxlanIDStr) + if err != nil { + return fmt.Errorf("parse vxlan_id: %w", err) } - return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created") -} + localIface, err := kv.GetFromDB(db, "subnet/"+subnetName+"/local_iface") + if err != nil { + return fmt.Errorf("get local_iface: %w", err) + } -func createSubnet(db *badger.DB, subnetName string, d subnetData) error { - vethE := "v-" + d.subnetID + "-e" - vethI := "v-" + d.subnetID + "-i" + gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + gatewayIP := net.ParseIP(gatewayIPStr) + if gatewayIP == nil { + return fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr) + } - if err := netif.CreateVethToNetns(vethE, vethI, "/var/run/netns/"+d.vpc, 1500); err != nil { + cidr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/cidr") + if err != nil { + return fmt.Errorf("get cidr: %w", err) + } + _, subnet, err := net.ParseCIDR(cidr) + if err != nil { + return fmt.Errorf("parse cidr: %w", err) + } + + // subnet_id = partie après le premier '-' (ex: "sn-00001" -> "00001") + subnetID := strings.SplitN(subnetName, "-", 2)[1] + bridge := "br-" + subnetID + vxlanIface := fmt.Sprintf("vxlan-%d", vxlanID) + + // veth pair + if err := netif.CreateVethToNetns("v-"+subnetID+"-e", "v-"+subnetID+"-i", "/var/run/netns/"+vpcName, 1500); err != nil { return fmt.Errorf("create veth: %w", err) } - switch d.mode { - case "vxlan": - if err := setupVxlanHost(d, vethE); err != nil { - return err - } - case "bridge": - if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil { - return fmt.Errorf("add veth-e to bridge: %w", err) - } - if err := netif.LinkSetUp(vethE); err != nil { - return fmt.Errorf("set up %s: %w", vethE, err) - } - default: - return fmt.Errorf("unknown subnet mode %q", d.mode) + // bridge dans le root netns + if err := netif.CreateBridge(bridge, 1500); err != nil { + return fmt.Errorf("create bridge: %w", err) } - if err := netns.Call(d.vpc, func() error { - if err := netif.CreateBridge(d.bridge, 1500); err != nil { - return fmt.Errorf("create bridge: %w", err) - } - return netif.BridgeSetMaster(vethI, d.bridge) + // bridge dans le netns VPC + if err := netns.Call(vpcName, func() error { + return netif.CreateBridge(bridge, 1500) }); err != nil { - return fmt.Errorf("setup bridge in netns: %w", err) + return fmt.Errorf("create bridge in netns: %w", err) } - if err := netns.Call(d.vpc, func() error { - for _, iface := range []string{vethI, d.bridge} { + // vxlan + if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface); err != nil { + return fmt.Errorf("create vxlan: %w", err) + } + + // ajout des interfaces dans les bridges + if err := netif.BridgeSetMaster("v-"+subnetID+"-e", bridge); err != nil { + return fmt.Errorf("add veth-e to bridge: %w", err) + } + if err := netns.Call(vpcName, func() error { + return netif.BridgeSetMaster("v-"+subnetID+"-i", bridge) + }); err != nil { + return fmt.Errorf("add veth-i to bridge in netns: %w", err) + } + if err := netif.BridgeSetMaster(vxlanIface, bridge); err != nil { + return fmt.Errorf("add vxlan to bridge: %w", err) + } + + // montée des interfaces dans le root netns + for _, iface := range []string{"v-" + subnetID + "-e", vxlanIface, bridge} { + if err := netif.LinkSetUp(iface); err != nil { + return fmt.Errorf("set up %s: %w", iface, err) + } + } + + // montée des interfaces dans le netns VPC + if err := netns.Call(vpcName, func() error { + for _, iface := range []string{"v-" + subnetID + "-i", bridge} { if err := netif.LinkSetUp(iface); err != nil { return fmt.Errorf("set up %s: %w", iface, err) } @@ -78,88 +122,51 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { return fmt.Errorf("set up interfaces in netns: %w", err) } - switch d.mode { - case "vxlan": - if err := netns.Call(d.vpc, func() error { - if err := netif.AddrAdd(d.bridge, d.interfaceIP); err != nil { - return fmt.Errorf("add addr: %w", err) - } - if err := netif.RouteAdd(d.bridge, d.cidr); err != nil { - return fmt.Errorf("add route: %w", err) - } - if err := ebtables.DropARPToGateway(vethI, d.interfaceIP.String()); err != nil { - return err - } - return ebtables.DropDHCP(vethI, d.interfaceIP.String()) - }); err != nil { - return fmt.Errorf("configure netns: %w", err) - } - case "bridge": - if err := netns.Call(d.vpc, func() error { - if err := netif.AddrAdd(d.bridge, d.interfaceIP); err != nil { - return fmt.Errorf("add addr: %w", err) - } - if err := netif.RouteAdd(d.bridge, d.cidr); err != nil { - return fmt.Errorf("add route: %w", err) - } - return ebtables.DropDHCP(vethI, d.interfaceIP.String()) - }); err != nil { - return fmt.Errorf("configure netns: %w", err) - } + // IP gateway (/32) sur le bridge interne + if err := netns.Call(vpcName, func() error { + return netif.AddrAdd(bridge, gatewayIP) + }); err != nil { + return fmt.Errorf("add addr to bridge in netns: %w", err) } - return startDHCP(db, subnetName, d) -} + // route subnet (scope link) dans le netns VPC + if err := netns.Call(vpcName, func() error { + return netif.RouteAdd(bridge, subnet) + }); err != nil { + return fmt.Errorf("add route in netns: %w", err) + } -func setupVxlanHost(d subnetData, vethE string) error { - vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) + // ebtables : drop ARP Request vers la gateway sur ce bridge + if err := exec.Command("ebtables", "-A", "FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "--arp-ip-dst", gatewayIP.String(), + "-j", "DROP").Run(); err != nil { + return fmt.Errorf("ebtables arp rule: %w", err) + } - if err := netif.CreateBridge(d.bridge, 1500); err != nil { - return fmt.Errorf("create bridge: %w", err) + // ebtables : drop trafic DHCP sur ce bridge + if err := exec.Command("ebtables", "-A", "FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP").Run(); err != nil { + return fmt.Errorf("ebtables dhcp rule: %w", err) } - if err := netif.CreateVxlan(vxlanIface, d.vxlanID, d.localIface, 1500); err != nil { - return fmt.Errorf("create vxlan: %w", err) - } - if err := netif.BridgeSetMaster(vethE, d.bridge); err != nil { - return fmt.Errorf("add veth-e to bridge: %w", err) - } - if err := netif.BridgeSetMaster(vxlanIface, d.bridge); err != nil { - return fmt.Errorf("add vxlan to bridge: %w", err) - } - for _, iface := range []string{vethE, vxlanIface, d.bridge} { - if err := netif.LinkSetUp(iface); err != nil { - return fmt.Errorf("set up %s: %w", iface, err) - } - } - return nil -} -func startDHCP(db *badger.DB, subnetName string, d subnetData) error { + // génération de la config dnsmasq et démarrage du service conf := dhcp.Config{ - Network: d.cidr, - Name: d.vpc + "_" + d.bridge, + Network: subnet, + Gateway: gatewayIP, + Name: vpcName + "_" + bridge, ConfDir: "/etc/dnsmasq.d", } - switch d.mode { - case "vxlan": - conf.VPCGateway = d.interfaceIP - conf.VPCRoute = d.vpcCIDR - case "bridge": - if d.defaultRoute { - gw, err := netif.GetDefaultGateway() - if err != nil { - return fmt.Errorf("get default gateway: %w", err) - } - conf.DefaultGateway = gw - } - } - _, entries, err := dhcp.GenerateConfig(conf) - if err != nil { + if _, err := dhcp.GenerateConfig(conf); err != nil { return fmt.Errorf("generate dhcp config: %w", err) } - if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil { - return fmt.Errorf("store dhcp entries: %w", err) - } svc, err := systemd.New() if err != nil { @@ -170,5 +177,6 @@ func startDHCP(db *badger.DB, subnetName string, d subnetData) error { if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil { return fmt.Errorf("start dnsmasq: %w", err) } - return nil + + return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created") } diff --git a/internal/subnet/data.go b/internal/subnet/data.go deleted file mode 100644 index 0f1f62a..0000000 --- a/internal/subnet/data.go +++ /dev/null @@ -1,99 +0,0 @@ -package subnet - -import ( - "fmt" - "net" - "strconv" - "strings" - - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" -) - -type subnetData struct { - vpc string - subnetID string - bridge string - mode string - vxlanID int - localIface string - interfaceIP net.IP - cidr *net.IPNet - vpcCIDR *net.IPNet - defaultRoute bool -} - -func loadSubnet(db *badger.DB, name string) (subnetData, error) { - var d subnetData - - d.subnetID = strings.SplitN(name, "-", 2)[1] - d.bridge = "br-" + d.subnetID - - vpc, err := kv.GetFromDB(db, "subnet/"+name+"/vpc") - if err != nil { - return d, fmt.Errorf("get vpc: %w", err) - } - d.vpc = vpc - - mode, err := kv.GetFromDB(db, "subnet/"+name+"/mode") - if err != nil { - return d, fmt.Errorf("get mode: %w", err) - } - d.mode = mode - - if d.mode == "vxlan" { - vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+name+"/vxlan_id") - if err != nil { - return d, fmt.Errorf("get vxlan_id: %w", err) - } - vxlanID, err := strconv.Atoi(vxlanIDStr) - if err != nil { - return d, fmt.Errorf("parse vxlan_id: %w", err) - } - d.vxlanID = vxlanID - } - - localIface, err := kv.GetFromDB(db, "subnet/"+name+"/local_iface") - if err != nil { - return d, fmt.Errorf("get local_iface: %w", err) - } - d.localIface = localIface - - interfaceIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/interface_ip") - if err != nil { - return d, fmt.Errorf("get interface_ip: %w", err) - } - interfaceIP := net.ParseIP(interfaceIPStr) - if interfaceIP == nil { - return d, fmt.Errorf("invalid interface_ip: %s", interfaceIPStr) - } - d.interfaceIP = interfaceIP - - cidrStr, err := kv.GetFromDB(db, "subnet/"+name+"/cidr") - if err != nil { - return d, fmt.Errorf("get cidr: %w", err) - } - _, ipNet, err := net.ParseCIDR(cidrStr) - if err != nil { - return d, fmt.Errorf("parse cidr: %w", err) - } - d.cidr = ipNet - - defaultRouteStr, err := kv.GetFromDB(db, "subnet/"+name+"/default_route") - if err != nil { - return d, fmt.Errorf("get default_route: %w", err) - } - d.defaultRoute = defaultRouteStr == "true" - - vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr") - if err != nil { - return d, fmt.Errorf("get vpc cidr: %w", err) - } - _, vpcIPNet, err := net.ParseCIDR(vpcCIDRStr) - if err != nil { - return d, fmt.Errorf("parse vpc cidr: %w", err) - } - d.vpcCIDR = vpcIPNet - - return d, nil -} diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index f745eda..650aa6f 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -3,8 +3,9 @@ package subnet import ( "fmt" "os" + "os/exec" + "strings" - "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -22,99 +23,73 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return nil } - d, err := loadSubnet(db, subnetName) + vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") if err != nil { - return err + return fmt.Errorf("get vpc: %w", err) } - if err := stopDHCP(db, subnetName, d); err != nil { - return err + vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id") + if err != nil { + return fmt.Errorf("get vxlan_id: %w", err) } - switch d.mode { - case "vxlan": - if err := deleteSubnetVxlan(d); err != nil { - return err - } - case "bridge": - if err := deleteSubnetBridge(d); err != nil { - return err - } - default: - return fmt.Errorf("unknown subnet mode %q", d.mode) - } + subnetID := strings.SplitN(subnetName, "-", 2)[1] + bridge := "br-" + subnetID + vxlanIface := "vxlan-" + vxlanIDStr - return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted") -} - -func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { + // arrêt du service dnsmasq svc, err := systemd.New() if err != nil { return fmt.Errorf("connect to systemd: %w", err) } defer svc.Close() - svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service" - if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" { - if err := svc.Stop(svcName); err != nil { - return fmt.Errorf("stop dnsmasq: %w", err) - } + svcName := "dnsmasq@" + vpcName + "_" + bridge + ".service" + if err := svc.Stop(svcName); err != nil { + return fmt.Errorf("stop dnsmasq: %w", err) } - if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) { + // suppression de la config dnsmasq + if err := os.Remove("/etc/dnsmasq.d/" + vpcName + "_" + bridge + ".conf"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove dnsmasq config: %w", err) } - if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { - return fmt.Errorf("delete dhcp entries: %w", err) - } - return nil -} + // suppression des règles ebtables + exec.Command("ebtables", "-D", "FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "-j", "DROP").Run() -func deleteSubnetVxlan(d subnetData) error { - vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) - vethI := "v-" + d.subnetID + "-i" + exec.Command("ebtables", "-D", "FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP").Run() - if err := netns.Call(d.vpc, func() error { - if err := ebtables.DeleteARPToGateway(vethI, d.interfaceIP.String()); err != nil { - return fmt.Errorf("delete ebtables arp rule: %w", err) - } - if err := ebtables.DeleteDHCP(vethI, d.interfaceIP.String()); err != nil { - return fmt.Errorf("delete ebtables dhcp rule: %w", err) - } - return netif.DeleteLink(d.bridge) + // suppression du bridge dans le netns VPC + if err := netns.Call(vpcName, func() error { + return netif.DeleteLink(bridge) }); err != nil { - return fmt.Errorf("delete netns resources: %w", err) + return fmt.Errorf("delete bridge in netns: %w", err) } + // suppression du vxlan if err := netif.DeleteLink(vxlanIface); err != nil { return fmt.Errorf("delete vxlan: %w", err) } - if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil { + // suppression du veth pair (supprime les deux côtés) + if err := netif.DeleteLink("v-" + subnetID + "-e"); err != nil { return fmt.Errorf("delete veth: %w", err) } - if err := netif.DeleteLink(d.bridge); err != nil { + // suppression du bridge dans le root netns + if err := netif.DeleteLink(bridge); err != nil { return fmt.Errorf("delete bridge: %w", err) } - return nil -} - -func deleteSubnetBridge(d subnetData) error { - vethI := "v-" + d.subnetID + "-i" - - if err := netns.Call(d.vpc, func() error { - if err := ebtables.DeleteDHCP(vethI, d.interfaceIP.String()); err != nil { - return fmt.Errorf("delete ebtables dhcp rule: %w", err) - } - return netif.DeleteLink(d.bridge) - }); err != nil { - return fmt.Errorf("delete netns resources: %w", err) - } - - if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil { - return fmt.Errorf("delete veth: %w", err) - } - return nil + + return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted") } diff --git a/internal/vm/create.go b/internal/vm/create.go deleted file mode 100644 index 3e0d4a7..0000000 --- a/internal/vm/create.go +++ /dev/null @@ -1,110 +0,0 @@ -package vm - -import ( - "fmt" - "io" - "os" - "path/filepath" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/iptables" - "git.g3e.fr/syonad/two/internal/metadata" - "git.g3e.fr/syonad/two/internal/netif" - "git.g3e.fr/syonad/two/internal/netns" - "git.g3e.fr/syonad/two/internal/qemu" - "git.g3e.fr/syonad/two/pkg/db/kv" - - "github.com/dgraph-io/badger/v4" -) - -func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { - state, err := kv.GetFromDB(db, "vm/"+name+"/state") - if err != nil { - return err - } - if state != "starting" { - return nil - } - - d, err := loadVM(db, name) - if err != nil { - return err - } - - if err := netif.CreateTap(d.tapID, d.bridge, d.vpcName); err != nil { - return fmt.Errorf("create tap: %w", err) - } - - if err := netns.Call(d.vpcName, func() error { - return iptables.AddMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) - }); err != nil { - return fmt.Errorf("add metadata redirect: %w", err) - } - - if err := metadata.StartMetadata(metadata.NoCloudConfig{ - Name: name, - VpcName: d.vpcName, - BindIP: d.interfaceIP, - BindPort: d.metadataPort, - Password: d.password, - SSHKEY: d.sshkey, - }, cfg, false); err != nil { - return fmt.Errorf("start metadata: %w", err) - } - - qDisks := make([]qemu.DiskConfig, len(d.disks)) - for i, disk := range d.disks { - qDisks[i] = qemu.DiskConfig{Path: disk.path, Dev: disk.dev} - } - - qcfg := qemu.Config{ - Name: name, - TapID: d.tapID, - Mac: d.mac, - Disks: qDisks, - Memory: d.memory, - CPUs: d.cpus, - SerialDir: cfg.QEMU.SerialDir, - MonitorDir: cfg.QEMU.MonitorDir, - QMPDir: cfg.QEMU.QMPDir, - } - - if d.uefi { - varsPath := filepath.Join(cfg.QEMU.UEFIVarsDir, name+"-uefi-vars.fd") - if err := copyFile(cfg.QEMU.OVMFVarsTemplate, varsPath); err != nil { - return fmt.Errorf("copy uefi vars: %w", err) - } - qcfg.UEFICodePath = cfg.QEMU.OVMFCodePath - qcfg.UEFIVarsPath = varsPath - } - - if err := netns.Call(d.vpcName, func() error { - return qemu.Start(qcfg) - }); err != nil { - return fmt.Errorf("start qemu: %w", err) - } - - return kv.AddInDB(db, "vm/"+name+"/state", "started") -} - -func copyFile(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - if _, err := io.Copy(out, in); err != nil { - return err - } - return out.Sync() -} diff --git a/internal/vm/data.go b/internal/vm/data.go deleted file mode 100644 index 3435fb1..0000000 --- a/internal/vm/data.go +++ /dev/null @@ -1,128 +0,0 @@ -package vm - -import ( - "fmt" - "math/rand" - "strconv" - "strings" - - "git.g3e.fr/syonad/two/internal/dhcp" - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" -) - -type diskEntry struct { - path string - dev string -} - -type vmData struct { - subnetName string - vpcName string - interfaceIP string - bridge string - tapID int - ip string - metadataPort string - mac string - disks []diskEntry - memory int - cpus int - uefi bool - password string - sshkey string -} - -func loadVM(db *badger.DB, name string) (vmData, error) { - var d vmData - - subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") - if err != nil { - return d, fmt.Errorf("get subnet: %w", err) - } - d.subnetName = subnetName - d.bridge = "br-" + strings.SplitN(subnetName, "-", 2)[1] - - vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") - if err != nil { - return d, fmt.Errorf("get vpc: %w", err) - } - d.vpcName = vpcName - - interfaceIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/interface_ip") - if err != nil { - return d, fmt.Errorf("get interface_ip: %w", err) - } - d.interfaceIP = interfaceIP - - tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") - if err != nil { - d.tapID = rand.Intn(90000000) + 10000000 - if err := kv.AddInDB(db, "vm/"+name+"/tap_id", strconv.Itoa(d.tapID)); err != nil { - return d, fmt.Errorf("store tap_id: %w", err) - } - } else { - tapID, err := strconv.Atoi(tapIDStr) - if err != nil { - return d, fmt.Errorf("parse tap_id: %w", err) - } - d.tapID = tapID - } - - ip, err := kv.GetFromDB(db, "vm/"+name+"/ip") - if err != nil { - return d, fmt.Errorf("get ip: %w", err) - } - d.ip = ip - - metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") - if err != nil { - return d, fmt.Errorf("get metadata_port: %w", err) - } - d.metadataPort = metadataPort - - mac, err := dhcp.GetMACForIP(db, d.subnetName, d.ip) - if err != nil { - return d, fmt.Errorf("get mac for ip %s: %w", d.ip, err) - } - d.mac = mac - - diskEntries, err := kv.ListByPrefix(db, "vm/"+name+"/disk/") - if err != nil { - return d, fmt.Errorf("list disks: %w", err) - } - if len(diskEntries) == 0 { - return d, fmt.Errorf("no disks found for vm %q", name) - } - diskPrefix := "vm/" + name + "/disk/" - for key, path := range diskEntries { - dev := strings.TrimPrefix(key, diskPrefix) - d.disks = append(d.disks, diskEntry{path: path, dev: dev}) - } - - memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") - if err != nil { - return d, fmt.Errorf("get memory: %w", err) - } - d.memory, err = strconv.Atoi(memoryStr) - if err != nil { - return d, fmt.Errorf("parse memory: %w", err) - } - - cpusStr, err := kv.GetFromDB(db, "vm/"+name+"/cpus") - if err != nil { - return d, fmt.Errorf("get cpus: %w", err) - } - d.cpus, err = strconv.Atoi(cpusStr) - if err != nil { - return d, fmt.Errorf("parse cpus: %w", err) - } - - if v, _ := kv.GetFromDB(db, "vm/"+name+"/uefi"); v == "true" { - d.uefi = true - } - d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") - d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") - - return d, nil -} diff --git a/internal/vm/delete.go b/internal/vm/delete.go deleted file mode 100644 index 3d808e6..0000000 --- a/internal/vm/delete.go +++ /dev/null @@ -1,84 +0,0 @@ -package vm - -import ( - "fmt" - "os" - "path/filepath" - "time" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/iptables" - "git.g3e.fr/syonad/two/internal/metadata" - "git.g3e.fr/syonad/two/internal/netif" - "git.g3e.fr/syonad/two/internal/netns" - "git.g3e.fr/syonad/two/internal/qmp" - "git.g3e.fr/syonad/two/pkg/db/kv" - - "github.com/dgraph-io/badger/v4" -) - -func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { - state, err := kv.GetFromDB(db, "vm/"+name+"/state") - if err != nil { - return err - } - if state != "stopping" { - return nil - } - - d, err := loadVM(db, name) - if err != nil { - return err - } - - socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") - - if _, err := os.Stat(socketPath); err == nil { - // socket présent : tenter l'arrêt gracieux - if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err == nil { - waitQMPDead(socketPath, - time.Duration(cfg.Dispatcher.TimeoutSeconds)*time.Second, - time.Duration(cfg.Dispatcher.PollSeconds)*time.Second, - ) - } - // connexion QMP échouée : QEMU déjà mort - } - // socket absent ou QEMU déjà arrêté : cleanup direct - - - if err := netns.Call(d.vpcName, func() error { - return iptables.DeleteMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) - }); err != nil { - return fmt.Errorf("delete metadata redirect: %w", err) - } - - if err := metadata.StopMetadata(name, cfg, false); err != nil { - return fmt.Errorf("stop metadata: %w", err) - } - - if err := netif.DeleteTap(d.tapID, d.vpcName); err != nil { - return fmt.Errorf("delete tap: %w", err) - } - - if d.uefi { - varsPath := filepath.Join(cfg.QEMU.UEFIVarsDir, name+"-uefi-vars.fd") - os.Remove(varsPath) - } - - return kv.AddInDB(db, "vm/"+name+"/state", "stopped") -} - -func waitQMPDead(socketPath string, timeout, poll time.Duration) { - timer := time.After(timeout) - for { - select { - case <-timer: - qmp.Send(socketPath, []string{`{"execute":"quit"}`}) - return - case <-time.After(poll): - if _, err := qmp.Send(socketPath, nil); err != nil { - return - } - } - } -} diff --git a/pkg/db/kv/admin_server.go b/pkg/db/kv/admin_server.go deleted file mode 100644 index 1216244..0000000 --- a/pkg/db/kv/admin_server.go +++ /dev/null @@ -1,52 +0,0 @@ -package kv - -import ( - "fmt" - "log/slog" - "net/http" - "sort" - - "github.com/dgraph-io/badger/v4" -) - -type AdminServer struct { - db *badger.DB - logger *slog.Logger -} - -func NewAdminServer(db *badger.DB, logger *slog.Logger) *AdminServer { - return &AdminServer{db: db, logger: logger} -} - -func (s *AdminServer) Start(address string) { - mux := http.NewServeMux() - mux.HandleFunc("/db", s.dbHandler) - s.logger.Info("admin server listening", "address", address) - if err := http.ListenAndServe(address, mux); err != nil { - s.logger.Error("admin server stopped", "error", err) - } -} - -func (s *AdminServer) dbHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - entries, err := ListByPrefix(s.db, r.URL.Query().Get("prefix")) - if err != nil { - http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) - return - } - - keys := make([]string, 0, len(entries)) - for k := range entries { - keys = append(keys, k) - } - sort.Strings(keys) - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - for _, k := range keys { - fmt.Fprintf(w, "%s=%s\n", k, entries[k]) - } -} diff --git a/pkg/db/kv/deleteInDB.go b/pkg/db/kv/deleteInDB.go index e1335de..1943c81 100644 --- a/pkg/db/kv/deleteInDB.go +++ b/pkg/db/kv/deleteInDB.go @@ -1,6 +1,8 @@ package kv import ( + "log" + "github.com/dgraph-io/badger/v4" ) @@ -33,7 +35,7 @@ func DeleteInDB(db *badger.DB, key string) error { return nil }) if err != nil { - return err + log.Fatal(err) } return deleteKey(db, key) diff --git a/pkg/db/kv/init.go b/pkg/db/kv/init.go index e250096..677c656 100644 --- a/pkg/db/kv/init.go +++ b/pkg/db/kv/init.go @@ -1,8 +1,6 @@ package kv import ( - "log" - "github.com/dgraph-io/badger/v4" ) @@ -17,7 +15,6 @@ func InitDB(conf Config, readonly bool) *badger.DB { opts.NumLevelZeroTablesStall = 2 db, err := badger.Open(opts) if err != nil { - log.Printf("kv.InitDB (readonly=%v, path=%s): %v", readonly, conf.Path, err) panic(err) } return db diff --git a/pkg/db/kv/kv_test.go b/pkg/db/kv/kv_test.go index b45422b..de4fd68 100644 --- a/pkg/db/kv/kv_test.go +++ b/pkg/db/kv/kv_test.go @@ -151,69 +151,3 @@ func TestDeleteInDB_MissingKey(t *testing.T) { t.Logf("DeleteInDB clé inexistante retourne : %v (non bloquant)", err) } } - -// --- ListByPrefix --- - -func TestListByPrefix_MatchingKeys(t *testing.T) { - db := newTestDB(t) - AddInDB(db, "subnet/sn1/state", "created") - AddInDB(db, "subnet/sn1/vpc", "vpc-1") - AddInDB(db, "subnet/sn1/cidr", "10.0.0.0/24") - - entries, err := ListByPrefix(db, "subnet/sn1/") - if err != nil { - t.Fatalf("ListByPrefix a échoué : %v", err) - } - if len(entries) != 3 { - t.Fatalf("attendu 3 entrées, obtenu %d", len(entries)) - } - if entries["subnet/sn1/state"] != "created" { - t.Errorf("valeur inattendue pour state : %q", entries["subnet/sn1/state"]) - } - if entries["subnet/sn1/vpc"] != "vpc-1" { - t.Errorf("valeur inattendue pour vpc : %q", entries["subnet/sn1/vpc"]) - } -} - -func TestListByPrefix_NoMatch(t *testing.T) { - db := newTestDB(t) - AddInDB(db, "vpc/v1/state", "created") - - entries, err := ListByPrefix(db, "subnet/") - if err != nil { - t.Fatalf("ListByPrefix a échoué : %v", err) - } - if len(entries) != 0 { - t.Errorf("attendu 0 entrées, obtenu %d", len(entries)) - } -} - -func TestListByPrefix_IsolatesPrefix(t *testing.T) { - db := newTestDB(t) - AddInDB(db, "subnet/sn1/state", "created") - AddInDB(db, "subnet/sn2/state", "creating") - AddInDB(db, "vpc/v1/state", "created") - - entries, err := ListByPrefix(db, "subnet/sn1/") - if err != nil { - t.Fatalf("ListByPrefix a échoué : %v", err) - } - if len(entries) != 1 { - t.Errorf("attendu 1 entrée, obtenu %d : %v", len(entries), entries) - } - if _, ok := entries["subnet/sn1/state"]; !ok { - t.Error("subnet/sn1/state devrait être présent") - } -} - -func TestListByPrefix_EmptyDB(t *testing.T) { - db := newTestDB(t) - - entries, err := ListByPrefix(db, "subnet/") - if err != nil { - t.Fatalf("ListByPrefix a échoué : %v", err) - } - if len(entries) != 0 { - t.Errorf("attendu 0 entrées sur DB vide, obtenu %d", len(entries)) - } -} diff --git a/pkg/worker/queue_test.go b/pkg/worker/queue_test.go deleted file mode 100644 index 5353b06..0000000 --- a/pkg/worker/queue_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package worker - -import ( - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestNew_ReturnsQueue(t *testing.T) { - q := New(10) - if q == nil { - t.Fatal("New devrait retourner une queue non-nil") - } -} - -func TestQueue_SingleTaskExecuted(t *testing.T) { - q := New(1) - q.Start(1) - - var done atomic.Bool - var wg sync.WaitGroup - wg.Add(1) - q.Submit(func() { - done.Store(true) - wg.Done() - }) - - wg.Wait() - if !done.Load() { - t.Error("la tâche n'a pas été exécutée") - } -} - -func TestQueue_AllTasksExecuted(t *testing.T) { - const n = 50 - q := New(n) - q.Start(1) - - var count atomic.Int32 - var wg sync.WaitGroup - wg.Add(n) - for range n { - q.Submit(func() { - count.Add(1) - wg.Done() - }) - } - - wg.Wait() - if count.Load() != n { - t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) - } -} - -func TestQueue_MultipleWorkers(t *testing.T) { - const n = 100 - q := New(n) - q.Start(4) - - var count atomic.Int32 - var wg sync.WaitGroup - wg.Add(n) - for range n { - q.Submit(func() { - count.Add(1) - wg.Done() - }) - } - - wg.Wait() - if count.Load() != n { - t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) - } -} - -func TestQueue_SubmitBlocksWhenFull(t *testing.T) { - q := New(1) - // Remplit le buffer sans worker - q.Submit(func() {}) - - submitted := make(chan struct{}) - go func() { - q.Submit(func() {}) // doit bloquer jusqu'à ce qu'un worker consomme - close(submitted) - }() - - select { - case <-submitted: - t.Error("Submit aurait dû bloquer sur une queue pleine") - case <-time.After(50 * time.Millisecond): - // comportement attendu : goroutine bloquée - } - - // Démarre un worker pour débloquer - q.Start(1) - select { - case <-submitted: - // Submit a pu avancer - case <-time.After(time.Second): - t.Error("Submit aurait dû se débloquer après démarrage d'un worker") - } -}