diff --git a/api/agent.yaml b/api/agent.yaml index 9fcd5d0..0861353 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -349,10 +349,8 @@ components: 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. - "public_ip": accepted and routed like vxlan for DHCP purposes, but its host network - setup is not implemented yet — creating such a subnet currently fails in Execute. "vlan" is reserved for future use. - enum: [vxlan, bridge, public_ip] + enum: [vxlan, bridge] default: vxlan example: vxlan vxlan_id: @@ -375,20 +373,9 @@ components: default_route: type: boolean description: > - A default route is always advertised via DHCP; this flag only chooses its next-hop. - When false, the next-hop is the subnet interface_ip. When true, it is the `gateway` - field if supplied, otherwise the gateway read from the host routing table. - The route to the VPC CIDR always keeps interface_ip as its next-hop (except in bridge - mode, which has no VPC route), so VPC traffic never leaves through a public gateway. + 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 - gateway: - type: string - format: ipv4 - description: > - Optional next-hop for the default route. Only used when default_route is true; - supplied with default_route false, it is ignored. Not validated by the agent: - reachability and coherence with the subnet CIDR are the caller's responsibility. - example: "10.10.10.254" Subnet: type: object @@ -405,7 +392,7 @@ components: example: vpc1 mode: type: string - enum: [vxlan, bridge, public_ip] + enum: [vxlan, bridge] example: vxlan vxlan_id: type: integer @@ -424,9 +411,6 @@ components: default_route: type: boolean example: false - gateway: - type: string - example: "10.10.10.254" VMCreateRequest: type: object @@ -443,8 +427,11 @@ components: type: integer description: Number of vCPUs (default 1) example: 2 - metadata: - $ref: "#/components/schemas/VMMetadata" + password: + type: string + sshkey: + type: string + example: "ssh-ed25519 AAAA..." interfaces: type: array minItems: 1 @@ -460,37 +447,6 @@ components: description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS). example: false - VMMetadata: - type: object - description: > - cloud-init inputs for this VM. Every field is optional. Documents not - supplied here fall back to the agent's built-in templates; a document - supplied as an empty string is served empty, which is not the same - thing. - properties: - password: - type: string - description: > - Password *hash* for the syonad account, as expected by the - cloud-config `passwd` key (e.g. `$6$...`) — not a plaintext - password. When omitted, the account is created locked - (`lock_passwd: true`); when neither password nor sshkey is given, - no account is created at all. - example: "$6$rounds=4096$saltsalt$hash..." - sshkey: - type: string - description: Public SSH key added to the syonad account. Sent as-is, not encoded. - example: "ssh-ed25519 AAAA..." - user_data: - type: string - format: byte - description: > - cloud-init user-data, **base64-encoded**. Encoding keeps multi-line - documents out of JSON escaping and allows gzip+base64 payloads. - Invalid base64 is rejected with 400 rather than silently serving an - empty document. The agent never interprets this content. - example: "I2Nsb3VkLWNvbmZpZwpwYWNrYWdlczoKICAtIG5naW54Cg==" - VMInterface: type: object required: [subnet, ip, primary] diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index f30ddf0..079d8db 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -20,7 +20,6 @@ type SubnetCreateRequest struct { InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` DefaultRoute bool `json:"default_route"` - Gateway string `json:"gateway"` } type Subnet struct { @@ -33,7 +32,6 @@ type Subnet struct { InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` DefaultRoute bool `json:"default_route"` - Gateway string `json:"gateway"` } type VMInterface struct { @@ -47,18 +45,13 @@ type VMStorage struct { Dev string `json:"dev"` } -type VMMetadata struct { - Password string `json:"password"` - SSHKey string `json:"sshkey"` - UserData string `json:"user_data"` -} - type VMCreateRequest struct { Name string `json:"name"` Memory int `json:"memory"` CPUs int `json:"cpus"` UEFI bool `json:"uefi"` - Metadata VMMetadata `json:"metadata"` + Password string `json:"password"` + SSHKey string `json:"sshkey"` Interfaces []VMInterface `json:"interfaces"` Storage []VMStorage `json:"storage"` } diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 4473489..f0671f0 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -59,8 +59,6 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.CIDR = value case "default_route": sub.DefaultRoute = value == "true" - case "gateway": - sub.Gateway = value } } w.WriteHeader(http.StatusOK) diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go index 6cf4176..77ab5e4 100644 --- a/internal/api/agent/subnet_test.go +++ b/internal/api/agent/subnet_test.go @@ -57,11 +57,11 @@ func TestPostSubnet_Created(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-new", - VPC: "vpc-1", - IfaceType: "vms", + Name: "sn-new", + VPC: "vpc-1", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -93,10 +93,10 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-opt", - VPC: "vpc-1", + Name: "sn-opt", + VPC: "vpc-1", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", // IfaceType omis — doit utiliser default_interface } body, _ := json.Marshal(req) @@ -110,11 +110,11 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) { func TestPostSubnet_VPCNotFound(t *testing.T) { s, _ := newTestServer(t) req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-inexistant", - IfaceType: "vms", + Name: "sn-1", + VPC: "vpc-inexistant", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -129,11 +129,11 @@ func TestPostSubnet_Duplicate(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "running") kv.AddInDB(db, "subnet/sn-exist/state", "running") req := SubnetCreateRequest{ - Name: "sn-exist", - VPC: "vpc-1", - IfaceType: "vms", + Name: "sn-exist", + VPC: "vpc-1", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -147,11 +147,11 @@ 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", + Name: "sn-1", + VPC: "vpc-dying", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -165,12 +165,12 @@ func TestPostSubnet_BridgeMode_Success(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-br", - VPC: "vpc-1", - Mode: "bridge", - IfaceType: "vms", + Name: "sn-br", + VPC: "vpc-1", + Mode: "bridge", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -192,11 +192,11 @@ func TestPostSubnet_UnknownMode(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-1", - Mode: "vlan", + Name: "sn-1", + VPC: "vpc-1", + Mode: "vlan", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -306,34 +306,3 @@ func TestSubnetByName_InvalidMethod(t *testing.T) { t.Errorf("attendu 405, obtenu %d", w.Code) } } - -func TestCreateSubnet_GatewayRoundTrip(t *testing.T) { - s, db := newTestServer(t) - kv.AddInDB(db, "vpc/vpc-1/state", "running") - - body, _ := json.Marshal(SubnetCreateRequest{ - Name: "sn-gw", VPC: "vpc-1", Mode: "public_ip", - IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - DefaultRoute: true, Gateway: "203.0.113.1", - }) - - 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()) - } - - if gw, _ := kv.GetFromDB(db, "subnet/sn-gw/gateway"); gw != "203.0.113.1" { - t.Errorf("gateway attendue en DB, obtenu %q", gw) - } - - w = httptest.NewRecorder() - s.SubnetByNameHandler(w, httptest.NewRequest(http.MethodGet, "/subnets/sn-gw", nil)) - var got Subnet - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("réponse illisible : %v", err) - } - if got.Gateway != "203.0.113.1" { - t.Errorf("gateway absente de la réponse GET : %+v", got) - } -} diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index efa480b..3cea247 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -87,7 +87,6 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { InterfaceIP: req.InterfaceIP, CIDR: req.CIDR, DefaultRoute: req.DefaultRoute, - Gateway: req.Gateway, } if err := s.dispatcher.Prepare(cmd); err != nil { if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { diff --git a/internal/api/agent/vm_test.go b/internal/api/agent/vm_test.go index 629c315..db8bbd5 100644 --- a/internal/api/agent/vm_test.go +++ b/internal/api/agent/vm_test.go @@ -2,16 +2,13 @@ package agentapi import ( "bytes" - "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "sort" - "strings" "testing" "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" ) // --- vmFromDB --- @@ -159,106 +156,3 @@ func TestStartVM_StorageReturnedInResponse(t *testing.T) { t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0]) } } - -// --- POST /vms : objet metadata --- - -func postVM(t *testing.T, name string, meta VMMetadata) (*httptest.ResponseRecorder, *badger.DB) { - t.Helper() - s, db := newTestServer(t) - kv.AddInDB(db, "subnet/sn-1/state", "running") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - body, _ := json.Marshal(VMCreateRequest{ - Name: name, - Interfaces: []VMInterface{{Subnet: "sn-1", IP: "10.0.0.20", Primary: true}}, - Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}}, - Memory: 1024, - CPUs: 2, - Metadata: meta, - }) - - w := httptest.NewRecorder() - s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) - return w, db -} - -func TestStartVM_UserDataIsDecodedFromBase64(t *testing.T) { - plain := "#cloud-config\npackages:\n - nginx\n" - encoded := base64.StdEncoding.EncodeToString([]byte(plain)) - - w, db := postVM(t, "vm-md1", VMMetadata{UserData: encoded}) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) - } - - got, err := kv.GetFromDB(db, "vm/vm-md1/metadata/user-data") - if err != nil { - t.Fatalf("user-data absent en DB : %v", err) - } - if got != plain { - t.Errorf("user-data décodé attendu %q, obtenu %q", plain, got) - } -} - -func TestStartVM_InvalidBase64IsRejected(t *testing.T) { - w, db := postVM(t, "vm-md2", VMMetadata{UserData: "ceci n'est pas du base64 !!"}) - - if w.Code != http.StatusBadRequest { - t.Fatalf("un base64 invalide doit être refusé en 400, obtenu %d : %s", w.Code, w.Body.String()) - } - if _, err := kv.GetFromDB(db, "vm/vm-md2/state"); err == nil { - t.Error("aucune VM ne doit être créée quand la requête est refusée") - } -} - -func TestStartVM_InvalidBase64ErrorNamesTheField(t *testing.T) { - w, _ := postVM(t, "vm-md3", VMMetadata{UserData: "@@@"}) - - if !strings.Contains(w.Body.String(), "user_data") { - t.Errorf("le message doit nommer le champ fautif : %s", w.Body.String()) - } -} - -func TestStartVM_NoUserDataWritesNoDocument(t *testing.T) { - w, db := postVM(t, "vm-md4", VMMetadata{SSHKey: "ssh-ed25519 AAAA user@host"}) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) - } - - entries, err := kv.ListByPrefix(db, "vm/vm-md4/metadata/") - if err != nil { - t.Fatalf("ListByPrefix : %v", err) - } - if len(entries) != 0 { - t.Errorf("sans user_data, aucun document ne doit être stocké : %v", entries) - } -} - -func TestStartVM_PasswordAndSSHKeyComeFromMetadata(t *testing.T) { - w, db := postVM(t, "vm-md5", VMMetadata{ - Password: "$6$rounds$hash", - SSHKey: "ssh-ed25519 AAAA user@host", - }) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) - } - - if got, _ := kv.GetFromDB(db, "vm/vm-md5/password"); got != "$6$rounds$hash" { - t.Errorf("password attendu depuis metadata, obtenu %q", got) - } - if got, _ := kv.GetFromDB(db, "vm/vm-md5/sshkey"); got != "ssh-ed25519 AAAA user@host" { - t.Errorf("sshkey attendu depuis metadata, obtenu %q", got) - } -} - -func TestStartVM_EmptyBase64MeansNoDocument(t *testing.T) { - w, db := postVM(t, "vm-md6", VMMetadata{UserData: base64.StdEncoding.EncodeToString([]byte(""))}) - if w.Code != http.StatusAccepted { - t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) - } - - entries, _ := kv.ListByPrefix(db, "vm/vm-md6/metadata/") - if len(entries) != 0 { - t.Errorf("un base64 vide est indiscernable d'un champ absent : %v", entries) - } -} diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index a061a00..2ea5748 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -1,14 +1,11 @@ package agentapi import ( - "encoding/base64" "encoding/json" - "fmt" "net/http" "strings" dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" - "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/pkg/db/kv" ) @@ -85,24 +82,16 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev} } - documents, err := decodeDocuments(req.Metadata) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) - return - } - 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.Metadata.Password, - SSHKey: req.Metadata.SSHKey, - Documents: documents, + 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 { @@ -126,14 +115,3 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(vm) } - -func decodeDocuments(m VMMetadata) (map[string]string, error) { - if m.UserData == "" { - return nil, nil - } - content, err := base64.StdEncoding.DecodeString(m.UserData) - if err != nil { - return nil, fmt.Errorf("metadata.user_data is not valid base64: %w", err) - } - return map[string]string{metadata.DocUserData: string(content)}, nil -} diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index 2597457..94c7d7b 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -55,7 +55,7 @@ func newConf(t *testing.T, cidr string) Config { gw := net.ParseIP("192.168.1.1").To4() return Config{ Network: network, - InterfaceIP: net.ParseIP("192.168.1.254").To4(), + VPCGateway: gw, VPCRoute: vpcNet, DefaultGateway: gw, Name: "test", @@ -120,10 +120,7 @@ func TestGenerateConfig_NoDefaultGatewaySuppressesRouterOption(t *testing.T) { } } -func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) { - // Contrat du paquet, pas politique de subnet : depuis 2026-08-24 `startDHCP` - // renseigne toujours DefaultGateway, donc ce chemin n'est plus emprunté en - // production. Il reste valide — le générateur ne doit rien inventer. +func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) { conf := newConf(t, "192.168.1.0/29") conf.DefaultGateway = nil @@ -131,37 +128,40 @@ func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) { content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-option=121,") { - t.Fatalf("dhcp-option=121 toujours attendue, ne serait-ce que pour la route metadata :\n%s", content) + t.Fatalf("dhcp-option=121 attendue pour un subnet vxlan :\n%s", content) } if strings.Contains(string(content), "dhcp-option=3,") { - t.Errorf("DefaultGateway nulle : aucune route par défaut ne doit être émise\n%s", content) + t.Errorf("un subnet vxlan est privé : aucune route par défaut ne doit être émise\n%s", content) } } func TestGenerateConfig_ContainsVPCRoute(t *testing.T) { - routes := route121(t, confLines(t, newConf(t, "192.168.1.0/29"))) - if !strings.Contains(routes, "10.0.0.0/16,192.168.1.254") { - t.Errorf("route VPC absente, ou next-hop autre que l'interface_ip du subnet :\n%s", routes) + 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) - routes := route121(t, confLines(t, conf)) - if strings.Contains(routes, "10.0.0.0/16") { - t.Errorf("route VPC présente alors que VPCRoute=nil :\n%s", routes) + if strings.Contains(string(content), "dhcp-option=121,") { + t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content) } } func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { _, network, _ := net.ParseCIDR("10.10.0.0/24") conf := Config{ - Network: network, - InterfaceIP: net.ParseIP("10.10.0.1").To4(), - Name: "vpc1", - ConfDir: t.TempDir(), + Network: network, + Name: "vpc1", + ConfDir: t.TempDir(), } path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) @@ -204,10 +204,9 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "sous", "dossier") _, network, _ := net.ParseCIDR("10.0.0.0/30") conf := Config{ - Network: network, - InterfaceIP: net.ParseIP("10.0.0.1").To4(), - Name: "net", - ConfDir: dir, + Network: network, + Name: "net", + ConfDir: dir, } if _, _, err := GenerateConfig(conf); err != nil { t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err) @@ -216,79 +215,3 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { t.Errorf("répertoire %q non créé", dir) } } - -// --- option 121 : routes classless --- - -func confLines(t *testing.T, c Config) string { - t.Helper() - path, _, err := GenerateConfig(c) - if err != nil { - t.Fatalf("GenerateConfig : %v", err) - } - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("lecture : %v", err) - } - return string(content) -} - -func route121(t *testing.T, content string) string { - t.Helper() - for _, line := range strings.Split(content, "\n") { - if strings.HasPrefix(line, "dhcp-option=121,") { - return strings.TrimPrefix(line, "dhcp-option=121,") - } - } - t.Fatalf("aucune dhcp-option=121 dans :\n%s", content) - return "" -} - -func TestGenerateConfig_AlwaysRoutesToMetadata(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.VPCRoute = nil - conf.DefaultGateway = nil - - routes := route121(t, confLines(t, conf)) - if !strings.Contains(routes, "169.254.169.254/32,192.168.1.254") { - t.Errorf("sans route vers le serveur de métadonnées, cloud-init échoue et la VM n'est pas provisionnée :\n%s", routes) - } -} - -func TestGenerateConfig_MetadataRouteEvenInBridgeMode(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.VPCRoute = nil - - routes := route121(t, confLines(t, conf)) - if !strings.Contains(routes, "169.254.169.254/32") { - t.Errorf("le mode bridge a besoin de la même route :\n%s", routes) - } -} - -func TestGenerateConfig_DefaultRouteAlsoInOption121(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.VPCRoute = nil - - routes := route121(t, confLines(t, conf)) - if !strings.Contains(routes, "0.0.0.0/0,192.168.1.1") { - t.Errorf("RFC 3442 : un client qui lit l'option 121 ignore l'option 3, la route par défaut doit donc figurer dans la 121 :\n%s", routes) - } -} - -func TestGenerateConfig_NoDefaultRouteMeansNoCatchAllInOption121(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.DefaultGateway = nil - - routes := route121(t, confLines(t, conf)) - if strings.Contains(routes, "0.0.0.0/0") { - t.Errorf("aucune route par défaut demandée, la 121 ne doit pas en contenir :\n%s", routes) - } -} - -func TestGenerateConfig_MissingInterfaceIPIsAnError(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - conf.InterfaceIP = nil - - if _, _, err := GenerateConfig(conf); err == nil { - t.Error("sans interface_ip aucune route metadata n'est possible : il faut échouer, pas écrire une conf muette") - } -} diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index ebcb914..40250f6 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -6,20 +6,17 @@ import ( "os" "path/filepath" "strings" - - "git.g3e.fr/syonad/two/internal/metadata" ) func GenerateConfig(c Config) (string, map[string]string, error) { - if c.InterfaceIP == nil { - return "", nil, fmt.Errorf("interface ip is required: guests would have no route to the metadata server") - } mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3]) var sb strings.Builder fmt.Fprintf(&sb, "no-resolv\n") fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) - fmt.Fprintf(&sb, "dhcp-option=121,%s\n", strings.Join(classlessRoutes(c), ",")) + 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()) } else { @@ -43,19 +40,6 @@ func GenerateConfig(c Config) (string, map[string]string, error) { return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } -func classlessRoutes(c Config) []string { - nextHop := c.InterfaceIP.String() - - routes := []string{metadata.ServiceIP + "/32," + nextHop} - if c.VPCRoute != nil { - routes = append(routes, c.VPCRoute.String()+","+nextHop) - } - if c.DefaultGateway != nil { - routes = append(routes, "0.0.0.0/0,"+c.DefaultGateway.String()) - } - return routes -} - func incrementIP(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { ip[j]++ diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go index 9df7c1f..bacb1e0 100644 --- a/internal/dhcp/struct.go +++ b/internal/dhcp/struct.go @@ -8,9 +8,9 @@ const DefaultConfDir = "/etc/dnsmasq.d" type Config struct { Network *net.IPNet - InterfaceIP net.IP // subnet gateway; next-hop for the metadata and VPC routes - VPCRoute *net.IPNet // if non-nil, routed via InterfaceIP in option 121 - DefaultGateway net.IP // if non-nil, default route via option 3 and 0.0.0.0/0 in option 121 + 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; if nil, emit a bare dhcp-option=3 to suppress the dnsmasq default Name string ConfDir string } diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 8d71f0c..6033a40 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -21,16 +21,15 @@ type CreateSubnetCommand struct { InterfaceIP string CIDR string DefaultRoute bool - Gateway string } func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name } func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { if c.Mode == "" { - c.Mode = subnet.ModeVxlan + c.Mode = "vxlan" } - if !subnet.ValidMode(c.Mode) { + 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 { @@ -54,14 +53,9 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute)) - if c.Mode == subnet.ModeVxlan { + if c.Mode == "vxlan" { kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) } - if c.Gateway != "" { - if err := kv.AddInDB(db, "subnet/"+c.Name+"/gateway", c.Gateway); err != nil { - return fmt.Errorf("store gateway: %w", err) - } - } return nil } diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go index d94f5e4..645f620 100644 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -5,7 +5,6 @@ import ( configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" ) func testCfg() *configuration.Config { @@ -256,74 +255,3 @@ func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) { t.Error("Prepare devrait échouer si le subnet n'existe pas") } } - -// --- gateway optionnelle et mode public_ip --- - -func prepareSubnet(t *testing.T, cmd CreateSubnetCommand) (*badger.DB, error) { - t.Helper() - _, db := newTestDispatcher(t) - kv.AddInDB(db, "vpc/vpc-1/state", "running") - if cmd.VPC == "" { - cmd.VPC = "vpc-1" - } - return db, cmd.Prepare(db, testCfg()) -} - -func TestCreateSubnetCommand_Prepare_StoresGateway(t *testing.T) { - db, err := prepareSubnet(t, CreateSubnetCommand{ - Name: "sn-gw", VxlanID: 100, IfaceType: "vms", - InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - DefaultRoute: true, Gateway: "10.0.0.254", - }) - if err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - gw, err := kv.GetFromDB(db, "subnet/sn-gw/gateway") - if err != nil { - t.Fatalf("clé gateway absente : %v", err) - } - if gw != "10.0.0.254" { - t.Errorf("gateway attendue 10.0.0.254, obtenu %q", gw) - } -} - -func TestCreateSubnetCommand_Prepare_NoGatewayWritesNoKey(t *testing.T) { - db, err := prepareSubnet(t, CreateSubnetCommand{ - Name: "sn-nogw", VxlanID: 100, IfaceType: "vms", - InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - }) - if err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - if _, err := kv.GetFromDB(db, "subnet/sn-nogw/gateway"); err == nil { - t.Error("aucune gateway fournie, aucune clé ne doit être écrite") - } -} - -func TestCreateSubnetCommand_Prepare_AcceptsPublicIPMode(t *testing.T) { - db, err := prepareSubnet(t, CreateSubnetCommand{ - Name: "sn-pub", Mode: "public_ip", IfaceType: "vms", - InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - DefaultRoute: true, Gateway: "203.0.113.1", - }) - if err != nil { - t.Fatalf("le mode public_ip doit être accepté : %v", err) - } - mode, _ := kv.GetFromDB(db, "subnet/sn-pub/mode") - if mode != "public_ip" { - t.Errorf("mode attendu public_ip, obtenu %q", mode) - } - if _, err := kv.GetFromDB(db, "subnet/sn-pub/vxlan_id"); err == nil { - t.Error("vxlan_id ne doit être écrit que pour le mode vxlan") - } -} - -func TestCreateSubnetCommand_Prepare_RejectsUnknownMode(t *testing.T) { - _, err := prepareSubnet(t, CreateSubnetCommand{ - Name: "sn-bad", Mode: "public", IfaceType: "vms", - InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", - }) - if err == nil { - t.Error("un mode inconnu doit être refusé") - } -} diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index 9b42289..6b50629 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -20,16 +20,15 @@ type VMDisk struct { } type StartVMCommand struct { - Name string - Subnet string - IP string - Disks []VMDisk - Memory int - CPUs int - UEFI bool - Password string - SSHKey string - Documents map[string]string + Name string + Subnet string + IP string + Disks []VMDisk + Memory int + CPUs int + UEFI bool + Password string + SSHKey string } func (c StartVMCommand) Key() string { return "vm/" + c.Name } @@ -67,11 +66,6 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { if c.SSHKey != "" { kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey) } - for doc, content := range c.Documents { - if err := kv.AddInDB(db, "vm/"+c.Name+"/metadata/"+doc, content); err != nil { - return fmt.Errorf("store metadata %s: %w", doc, err) - } - } return nil } diff --git a/internal/dispatcher/agent/vm_commands_test.go b/internal/dispatcher/agent/vm_commands_test.go index cd483f4..2747b0f 100644 --- a/internal/dispatcher/agent/vm_commands_test.go +++ b/internal/dispatcher/agent/vm_commands_test.go @@ -4,7 +4,6 @@ import ( "testing" "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" ) // --- StartVMCommand.Prepare : écriture des disques en DB --- @@ -174,98 +173,3 @@ func TestStartVMCommand_Prepare_Duplicate(t *testing.T) { t.Error("Prepare devrait échouer si la VM existe déjà") } } - -// --- StartVMCommand.Prepare : documents cloud-init --- - -func prepareWithDocuments(t *testing.T, docs map[string]string) *badger.DB { - t.Helper() - _, db := newTestDispatcher(t) - kv.AddInDB(db, "subnet/sn-1/state", "running") - kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") - - cmd := StartVMCommand{ - Name: "vm-doc", - Subnet: "sn-1", - IP: "10.0.0.5", - Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "vda"}}, - Documents: docs, - } - if err := cmd.Prepare(db, nil); err != nil { - t.Fatalf("Prepare a échoué : %v", err) - } - return db -} - -func TestStartVMCommand_Prepare_StoresDocuments(t *testing.T) { - userData := "#cloud-config\npackages:\n - nginx\n" - db := prepareWithDocuments(t, map[string]string{"user-data": userData}) - - got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/user-data") - if err != nil { - t.Fatalf("clé metadata/user-data absente : %v", err) - } - if got != userData { - t.Errorf("user-data attendu %q, obtenu %q", userData, got) - } -} - -func TestStartVMCommand_Prepare_NoDocumentsWritesNoKey(t *testing.T) { - db := prepareWithDocuments(t, nil) - - entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/") - if err != nil { - t.Fatalf("ListByPrefix : %v", err) - } - if len(entries) != 0 { - t.Errorf("aucun document fourni, aucune clé ne doit être écrite : %v", entries) - } -} - -func TestStartVMCommand_Prepare_EmptyDocumentIsStored(t *testing.T) { - db := prepareWithDocuments(t, map[string]string{"vendor-data": ""}) - - entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/") - if err != nil { - t.Fatalf("ListByPrefix : %v", err) - } - if _, ok := entries["vm/vm-doc/metadata/vendor-data"]; !ok { - t.Error("un document explicitement vide doit être stocké : c'est une demande de ne rien servir, pas une absence de demande") - } -} - -func TestStartVMCommand_Prepare_AllDocumentKinds(t *testing.T) { - docs := map[string]string{ - "user-data": "u", - "meta-data": "m", - "network-config": "n", - "vendor-data": "v", - } - db := prepareWithDocuments(t, docs) - - for doc, want := range docs { - got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/"+doc) - if err != nil { - t.Errorf("clé metadata/%s absente : %v", doc, err) - continue - } - if got != want { - t.Errorf("%s attendu %q, obtenu %q", doc, want, got) - } - } -} - -func TestDeleteInDB_RemovesMetadataDocuments(t *testing.T) { - db := prepareWithDocuments(t, map[string]string{"user-data": "u", "vendor-data": "v"}) - - if err := kv.DeleteInDB(db, "vm/vm-doc"); err != nil { - t.Fatalf("DeleteInDB : %v", err) - } - - entries, err := kv.ListByPrefix(db, "vm/vm-doc/") - if err != nil { - t.Fatalf("ListByPrefix : %v", err) - } - if len(entries) != 0 { - t.Errorf("la suppression de la VM doit emporter ses documents : %v", entries) - } -} diff --git a/internal/iptables/iptables.go b/internal/iptables/iptables.go index 5e39e3e..1d156a7 100644 --- a/internal/iptables/iptables.go +++ b/internal/iptables/iptables.go @@ -3,8 +3,6 @@ package iptables import ( "fmt" "os/exec" - - "git.g3e.fr/syonad/two/internal/metadata" ) func addRule(args ...string) error { @@ -18,7 +16,7 @@ func deleteRule(args ...string) error { func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { if err := addRule("PREROUTING", "-s", vmIP+"/32", - "-d", metadata.ServiceIP+"/32", + "-d", "169.254.169.254/32", "-p", "tcp", "-m", "tcp", "--dport", "80", "-j", "DNAT", @@ -32,7 +30,7 @@ func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { if err := deleteRule("PREROUTING", "-s", vmIP+"/32", - "-d", metadata.ServiceIP+"/32", + "-d", "169.254.169.254/32", "-p", "tcp", "-m", "tcp", "--dport", "80", "-j", "DNAT", diff --git a/internal/metadata/handle.go b/internal/metadata/handle.go index 8f3d1f4..d34dd20 100644 --- a/internal/metadata/handle.go +++ b/internal/metadata/handle.go @@ -8,37 +8,33 @@ import ( ) func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error { - if err := WriteNoCloudFiles(config, cfg.Metadata.RunDir); err != nil { - return fmt.Errorf("write nocloud files for %s: %w", config.Name, err) - } - if dryrun { - return nil - } - service, err := systemd.New() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - if err := service.Start("metadata@" + config.Name + ".service"); err != nil { - return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) + LoadNcCloudInDB(config, cfg.Metadata.RunDir) + if !dryrun { + if err := service.Start("metadata@" + config.Name + ".service"); err != nil { + return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) + } } return nil } func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error { - if !dryrun { - service, err := systemd.New() - if err != nil { - return fmt.Errorf("failed to connect to systemd: %w", err) - } - defer service.Close() + 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) + if !dryrun { if err := service.Stop("metadata@" + vmName + ".service"); err != nil { return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err) } } - - return RemoveNoCloudFiles(vmName, cfg.Metadata.RunDir) + return nil } diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index 4efff81..cc54530 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -25,21 +25,6 @@ func useTestDir(t *testing.T) string { return t.TempDir() } - -func mustWrite(t *testing.T, cfg NoCloudConfig, dir string) { - t.Helper() - if err := WriteNoCloudFiles(cfg, dir); err != nil { - t.Fatalf("WriteNoCloudFiles : %v", err) - } -} - -func mustRemove(t *testing.T, vmName, dir string) { - t.Helper() - if err := RemoveNoCloudFiles(vmName, dir); err != nil { - t.Fatalf("RemoveNoCloudFiles : %v", err) - } -} - // --- RenderConfig --- func TestRenderConfig_MetaData(t *testing.T) { @@ -89,23 +74,14 @@ func TestRenderConfig_NetworkConfig(t *testing.T) { } } -func TestRenderConfig_UserData_DefaultIsEmpty(t *testing.T) { - doc, err := renderDocument(DocUserData, newCfg()) +func TestRenderConfig_UserData(t *testing.T) { + cfg := newCfg() + out, err := RenderConfig("templates/user-data.tmpl", cfg) if err != nil { - t.Fatalf("renderDocument user-data : %v", err) + t.Fatalf("RenderConfig user-data : %v", err) } - if doc != "" { - t.Errorf("le user-data par défaut doit être vide, obtenu :\n%q", doc) - } -} - -func TestRenderConfig_UserData_NeverTouchesRootPassword(t *testing.T) { - doc, err := renderDocument(DocUserData, newCfg()) - if err != nil { - t.Fatalf("renderDocument user-data : %v", err) - } - if strings.Contains(doc, "passwd -d root") { - t.Errorf("l'agent ne doit imposer aucune modification du compte root :\n%s", doc) + if !strings.Contains(out, "passwd -d root") { + t.Errorf("user-data inattendu :\n%s", out) } } @@ -128,21 +104,21 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) { } } -// --- WriteNoCloudFiles / RemoveNoCloudFiles --- +// --- 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 WriteNoCloudFiles : %v", name, err) + t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err) return "" } return string(b) } -func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) { +func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) { dir := useTestDir(t) - mustWrite(t, newCfg(), dir) + LoadNcCloudInDB(newCfg(), dir) files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"} for _, f := range files { @@ -153,9 +129,9 @@ func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) { } } -func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) { +func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) { dir := useTestDir(t) - mustWrite(t, newCfg(), dir) + LoadNcCloudInDB(newCfg(), dir) if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" { t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc) @@ -168,26 +144,26 @@ func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) { } } -func TestRemoveNoCloudFiles_RemovesAllFiles(t *testing.T) { +func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) { dir := useTestDir(t) - mustWrite(t, newCfg(), dir) - mustRemove(t, "vm1", dir) + LoadNcCloudInDB(newCfg(), dir) + UnLoadNoCloudInDB("vm1", dir) if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) { - t.Error("répertoire vm1 devrait être supprimé après RemoveNoCloudFiles") + t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB") } } -func TestRemoveNoCloudFiles_DoesNotAffectOtherVMs(t *testing.T) { +func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { dir := useTestDir(t) cfg1 := newCfg() cfg2 := newCfg() cfg2.Name = "vm2" - mustWrite(t, cfg1, dir) - mustWrite(t, cfg2, dir) + LoadNcCloudInDB(cfg1, dir) + LoadNcCloudInDB(cfg2, dir) - mustRemove(t, "vm1", dir) + UnLoadNoCloudInDB("vm1", dir) if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil { t.Errorf("vm2 ne devrait pas être supprimée : %v", err) @@ -288,138 +264,3 @@ func TestRootHandler_ContentType(t *testing.T) { t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) } } - -// --- vendor-data : blocs conditionnels --- - -func vendorData(t *testing.T, password, sshkey string) string { - t.Helper() - cfg := newCfg() - cfg.Password = password - cfg.SSHKEY = sshkey - out, err := renderDocument(DocVendorData, cfg) - if err != nil { - t.Fatalf("renderDocument vendor-data : %v", err) - } - return out -} - -func TestVendorData_NoCredentials_EmitsNothing(t *testing.T) { - out := vendorData(t, "", "") - if out != "" { - t.Errorf("sans mot de passe ni clé, aucun compte ne doit être créé :\n%s", out) - } -} - -func TestVendorData_NoCredentials_NeverEmitsEmptyValues(t *testing.T) { - out := vendorData(t, "", "") - for _, forbidden := range []string{`passwd: ""`, `- ""`, "lock_passwd: false"} { - if strings.Contains(out, forbidden) { - t.Errorf("valeur vide %q émise :\n%s", forbidden, out) - } - } -} - -func TestVendorData_PasswordOnly(t *testing.T) { - out := vendorData(t, "$6$rounds$hash", "") - if !strings.Contains(out, `passwd: "$6$rounds$hash"`) { - t.Errorf("hash absent :\n%s", out) - } - if !strings.Contains(out, "lock_passwd: false") { - t.Errorf("un compte avec mot de passe doit être déverrouillé :\n%s", out) - } - if strings.Contains(out, "ssh_authorized_keys") { - t.Errorf("aucune clé fournie, le bloc ne doit pas apparaître :\n%s", out) - } -} - -func TestVendorData_SSHKeyOnly_LocksPassword(t *testing.T) { - out := vendorData(t, "", "ssh-ed25519 AAAA user@host") - if !strings.Contains(out, "ssh-ed25519 AAAA user@host") { - t.Errorf("clé absente :\n%s", out) - } - if strings.Contains(out, "\n passwd:") { - t.Errorf("aucun mot de passe fourni, le champ ne doit pas apparaître :\n%s", out) - } - if !strings.Contains(out, "lock_passwd: true") { - t.Errorf("sans mot de passe le compte doit rester verrouillé, sinon il devient un compte sudo sans mot de passe :\n%s", out) - } -} - -func TestVendorData_BothCredentials(t *testing.T) { - out := vendorData(t, "$6$hash", "ssh-ed25519 AAAA user@host") - for _, expected := range []string{"#cloud-config", "name: syonad", `passwd: "$6$hash"`, "lock_passwd: false", "ssh-ed25519 AAAA user@host"} { - if !strings.Contains(out, expected) { - t.Errorf("%q absent :\n%s", expected, out) - } - } -} - -func TestVendorData_CloudConfigHeaderIsFirstLine(t *testing.T) { - out := vendorData(t, "$6$hash", "") - if !strings.HasPrefix(out, "#cloud-config\n") { - t.Errorf("cloud-init exige #cloud-config en première ligne :\n%q", out) - } -} - -// --- documents fournis par l'appelant --- - -func TestRenderDocument_VerbatimOverridesTemplate(t *testing.T) { - cfg := newCfg() - supplied := "#cloud-config\npackages:\n - nginx\n" - cfg.Documents = map[string]string{DocUserData: supplied} - - out, err := renderDocument(DocUserData, cfg) - if err != nil { - t.Fatalf("renderDocument : %v", err) - } - if out != supplied { - t.Errorf("le document fourni doit être écrit verbatim :\nattendu %q\nobtenu %q", supplied, out) - } -} - -func TestRenderDocument_EmptySuppliedDocumentIsHonoured(t *testing.T) { - cfg := newCfg() - cfg.Documents = map[string]string{DocVendorData: ""} - - out, err := renderDocument(DocVendorData, cfg) - if err != nil { - t.Fatalf("renderDocument : %v", err) - } - if out != "" { - t.Errorf("un document explicitement vide ne doit pas retomber sur le template :\n%s", out) - } -} - -func TestWriteNoCloudFiles_WritesSuppliedDocument(t *testing.T) { - dir := useTestDir(t) - cfg := newCfg() - cfg.Documents = map[string]string{DocUserData: "#cloud-config\nruncmd:\n - [ls]\n"} - mustWrite(t, cfg, dir) - - if got := readTestFile(t, dir, "vm1", "user-data"); got != cfg.Documents[DocUserData] { - t.Errorf("user-data servi différent de celui fourni :\n%q", got) - } -} - -// --- remontée des erreurs --- - -func TestWriteNoCloudFiles_ReturnsErrorOnUnwritableDir(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root ignore les permissions de fichiers") - } - dir := useTestDir(t) - if err := os.Chmod(dir, 0500); err != nil { - t.Fatalf("chmod : %v", err) - } - t.Cleanup(func() { os.Chmod(dir, 0700) }) - - if err := WriteNoCloudFiles(newCfg(), dir); err == nil { - t.Error("une écriture impossible doit remonter une erreur : une VM ne doit jamais démarrer sans métadonnées en silence") - } -} - -func TestRemoveNoCloudFiles_AbsentDirIsNotAnError(t *testing.T) { - if err := RemoveNoCloudFiles("jamais-creee", useTestDir(t)); err != nil { - t.Errorf("supprimer une VM sans fichiers ne doit pas échouer : %v", err) - } -} diff --git a/internal/metadata/render.go b/internal/metadata/render.go index a038097..0d4fd59 100644 --- a/internal/metadata/render.go +++ b/internal/metadata/render.go @@ -3,27 +3,14 @@ package metadata import ( "bytes" "embed" - "fmt" "os" "path/filepath" - "strings" "text/template" ) //go:embed templates/*.tmpl var templateFS embed.FS -const ( - DocMetaData = "meta-data" - DocUserData = "user-data" - DocNetworkConfig = "network-config" - DocVendorData = "vendor-data" -) - -func Documents() []string { - return []string{DocMetaData, DocUserData, DocNetworkConfig, DocVendorData} -} - func RenderConfig(path string, cfg NoCloudConfig) (string, error) { tpl, err := template.ParseFS(templateFS, path) if err != nil { @@ -38,55 +25,25 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) { return buf.String(), nil } -func renderDocument(name string, cfg NoCloudConfig) (string, error) { - if doc, ok := cfg.Documents[name]; ok { - return doc, nil - } +func LoadNcCloudInDB(config NoCloudConfig, runDir string) { + 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) - out, err := RenderConfig("templates/"+name+".tmpl", cfg) - if err != nil { - return "", fmt.Errorf("render %s: %w", name, err) - } - - out = strings.TrimSpace(out) - if out == "" { - return "", nil - } - return out + "\n", nil -} - -func WriteNoCloudFiles(config NoCloudConfig, runDir string) error { dir := filepath.Join(runDir, config.Name) if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create %s: %w", dir, err) + return } - - files := map[string]string{ - "vpc": config.VpcName, - "bind_ip": config.BindIP, - "bind_port": config.BindPort, - } - for _, name := range Documents() { - doc, err := renderDocument(name, config) - if err != nil { - return err - } - files[name] = doc - } - - for name, content := range files { - path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - } - return nil + 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) } -func RemoveNoCloudFiles(vmName string, runDir string) error { - dir := filepath.Join(runDir, vmName) - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("remove %s: %w", dir, err) - } - return nil +func UnLoadNoCloudInDB(vmName string, runDir string) { + os.RemoveAll(filepath.Join(runDir, vmName)) } diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index 39592ed..e706087 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -1,10 +1,5 @@ package metadata -// ServiceIP is the well-known address guests use to reach the metadata server. -// Traffic to it is DNATed to the subnet gateway by internal/iptables, and the -// route to it is advertised by internal/dhcp. -const ServiceIP = "169.254.169.254" - type NoCloudData struct { MetaData string UserData string @@ -21,11 +16,10 @@ type ServerConfig struct { } type NoCloudConfig struct { - VpcName string - BindIP string - BindPort string - Name string - Password string - SSHKEY string - Documents map[string]string + VpcName string + BindIP string + BindPort string + Name string + Password string + SSHKEY string } diff --git a/internal/metadata/templates/user-data.tmpl b/internal/metadata/templates/user-data.tmpl index e69de29..84195ab 100644 --- a/internal/metadata/templates/user-data.tmpl +++ b/internal/metadata/templates/user-data.tmpl @@ -0,0 +1,3 @@ +#!/bin/sh + +passwd -d root diff --git a/internal/metadata/templates/vendor-data.tmpl b/internal/metadata/templates/vendor-data.tmpl index c358f69..fe0dcd8 100644 --- a/internal/metadata/templates/vendor-data.tmpl +++ b/internal/metadata/templates/vendor-data.tmpl @@ -1,17 +1,9 @@ -{{- if or .Password .SSHKEY -}} #cloud-config users: - name: syonad + lock_passwd: false sudo: ["ALL=(ALL) NOPASSWD:ALL"] shell: /bin/bash -{{- if .Password }} - lock_passwd: false passwd: "{{ .Password }}" -{{- else }} - lock_passwd: true -{{- end }} -{{- if .SSHKEY }} ssh_authorized_keys: - "{{ .SSHKEY }}" -{{- end }} -{{- end }} diff --git a/internal/subnet/create.go b/internal/subnet/create.go index e37b865..e7b0c70 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -43,13 +43,11 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { } switch d.mode { - case ModeVxlan: + case "vxlan": if err := setupVxlanHost(d, vethE); err != nil { return err } - case ModePublicIP: - return fmt.Errorf("subnet mode %q: host network setup is not implemented yet", d.mode) - case ModeBridge: + case "bridge": if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil { return fmt.Errorf("add veth-e to bridge: %w", err) } @@ -138,17 +136,23 @@ func setupVxlanHost(d subnetData, vethE string) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ - Network: d.cidr, - Name: d.vpc + "_" + d.bridge, - ConfDir: dhcp.DefaultConfDir, - InterfaceIP: d.interfaceIP, + Network: d.cidr, + Name: d.vpc + "_" + d.bridge, + ConfDir: dhcp.DefaultConfDir, } - defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway) - if err != nil { - return err + 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 + } } - conf.DefaultGateway = defaultGateway - conf.VPCRoute = vpcRoute _, entries, err := dhcp.GenerateConfig(conf) if err != nil { return fmt.Errorf("generate dhcp config: %w", err) diff --git a/internal/subnet/data.go b/internal/subnet/data.go index 6f2d302..0f1f62a 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -21,7 +21,6 @@ type subnetData struct { cidr *net.IPNet vpcCIDR *net.IPNet defaultRoute bool - gateway net.IP } func loadSubnet(db *badger.DB, name string) (subnetData, error) { @@ -86,14 +85,6 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.defaultRoute = defaultRouteStr == "true" - if gatewayStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway"); err == nil && gatewayStr != "" { - gateway := net.ParseIP(gatewayStr) - if gateway == nil { - return d, fmt.Errorf("invalid gateway: %s", gatewayStr) - } - d.gateway = gateway - } - vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr") if err != nil { return d, fmt.Errorf("get vpc cidr: %w", err) diff --git a/internal/subnet/mode.go b/internal/subnet/mode.go deleted file mode 100644 index cfc61dd..0000000 --- a/internal/subnet/mode.go +++ /dev/null @@ -1,15 +0,0 @@ -package subnet - -const ( - ModeVxlan = "vxlan" - ModeBridge = "bridge" - ModePublicIP = "public_ip" -) - -func ValidMode(mode string) bool { - switch mode { - case ModeVxlan, ModeBridge, ModePublicIP: - return true - } - return false -} diff --git a/internal/subnet/routing.go b/internal/subnet/routing.go deleted file mode 100644 index 722acb8..0000000 --- a/internal/subnet/routing.go +++ /dev/null @@ -1,34 +0,0 @@ -package subnet - -import ( - "fmt" - "net" -) - -// dhcpRouting resolves what the DHCP server advertises to the guests of a subnet. -// -// The default route always points at the subnet gateway (interface_ip); default_route -// swaps that next-hop for the supplied gateway, or for the deduced one when none was -// supplied. The VPC route keeps interface_ip as its next-hop in every mode but bridge, -// so that traffic to the VPC ranges never leaves through a public gateway. -func dhcpRouting(d subnetData, deduceGateway func() (net.IP, error)) (net.IP, *net.IPNet, error) { - defaultGateway := d.interfaceIP - if d.defaultRoute { - if d.gateway != nil { - defaultGateway = d.gateway - } else { - deduced, err := deduceGateway() - if err != nil { - return nil, nil, fmt.Errorf("get default gateway: %w", err) - } - defaultGateway = deduced - } - } - - var vpcRoute *net.IPNet - if d.mode != ModeBridge { - vpcRoute = d.vpcCIDR - } - - return defaultGateway, vpcRoute, nil -} diff --git a/internal/subnet/routing_test.go b/internal/subnet/routing_test.go deleted file mode 100644 index 8ad0440..0000000 --- a/internal/subnet/routing_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package subnet - -import ( - "errors" - "net" - "testing" -) - -func cidr(t *testing.T, s string) *net.IPNet { - t.Helper() - _, n, err := net.ParseCIDR(s) - if err != nil { - t.Fatalf("ParseCIDR(%q) : %v", s, err) - } - return n -} - -func baseSubnet(t *testing.T, mode string) subnetData { - t.Helper() - return subnetData{ - mode: mode, - interfaceIP: net.ParseIP("10.1.1.1").To4(), - cidr: cidr(t, "10.1.0.0/23"), - vpcCIDR: cidr(t, "192.168.0.0/16"), - } -} - -func deduced(ip string) func() (net.IP, error) { - return func() (net.IP, error) { return net.ParseIP(ip).To4(), nil } -} - -func neverDeduced(t *testing.T) func() (net.IP, error) { - t.Helper() - return func() (net.IP, error) { - t.Error("la gateway de l'host ne doit pas être interrogée dans ce cas") - return nil, nil - } -} - -// --- next-hop de la route par défaut --- - -func TestDhcpRouting_DefaultRouteUsesInterfaceIP(t *testing.T) { - d := baseSubnet(t, ModeVxlan) - - gw, _, err := dhcpRouting(d, neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if gw.String() != "10.1.1.1" { - t.Errorf("sans default_route le next-hop doit être l'interface_ip, obtenu %s", gw) - } -} - -func TestDhcpRouting_DefaultRouteUsesSuppliedGateway(t *testing.T) { - d := baseSubnet(t, ModeVxlan) - d.defaultRoute = true - d.gateway = net.ParseIP("10.1.1.254").To4() - - gw, _, err := dhcpRouting(d, neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if gw.String() != "10.1.1.254" { - t.Errorf("gateway fournie attendue, obtenu %s", gw) - } -} - -func TestDhcpRouting_DefaultRouteFallsBackToDeducedGateway(t *testing.T) { - d := baseSubnet(t, ModeBridge) - d.defaultRoute = true - - gw, _, err := dhcpRouting(d, deduced("192.0.2.1")) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if gw.String() != "192.0.2.1" { - t.Errorf("gateway déduite attendue, obtenu %s", gw) - } -} - -func TestDhcpRouting_SuppliedGatewayIgnoredWithoutDefaultRoute(t *testing.T) { - d := baseSubnet(t, ModeVxlan) - d.gateway = net.ParseIP("10.1.1.254").To4() - - gw, _, err := dhcpRouting(d, neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if gw.String() != "10.1.1.1" { - t.Errorf("gateway fournie sans default_route : ignorée en silence, next-hop attendu 10.1.1.1, obtenu %s", gw) - } -} - -func TestDhcpRouting_DeductionFailureIsReported(t *testing.T) { - d := baseSubnet(t, ModeBridge) - d.defaultRoute = true - - _, _, err := dhcpRouting(d, func() (net.IP, error) { return nil, errors.New("pas de route") }) - if err == nil { - t.Error("l'échec de déduction de la gateway doit remonter, pas produire une route muette") - } -} - -// --- route VPC --- - -func TestDhcpRouting_VPCRouteKeptInVxlan(t *testing.T) { - _, route, err := dhcpRouting(baseSubnet(t, ModeVxlan), neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if route == nil || route.String() != "192.168.0.0/16" { - t.Errorf("route VPC attendue, obtenu %v", route) - } -} - -func TestDhcpRouting_VPCRouteKeptInPublicIP(t *testing.T) { - d := baseSubnet(t, ModePublicIP) - d.defaultRoute = true - d.gateway = net.ParseIP("203.0.113.1").To4() - - gw, route, err := dhcpRouting(d, neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if route == nil || route.String() != "192.168.0.0/16" { - t.Fatalf("route VPC attendue sur un subnet public, obtenu %v", route) - } - if gw.String() != "203.0.113.1" { - t.Errorf("next-hop par défaut attendu 203.0.113.1, obtenu %s", gw) - } - // C'est tout l'intérêt du mode : la route VPC garde interface_ip comme next-hop, - // donc le trafic interne ne sort jamais par la gateway publique. -} - -func TestDhcpRouting_NoVPCRouteInBridge(t *testing.T) { - _, route, err := dhcpRouting(baseSubnet(t, ModeBridge), neverDeduced(t)) - if err != nil { - t.Fatalf("dhcpRouting : %v", err) - } - if route != nil { - t.Errorf("le mode bridge n'a pas de route VPC, obtenu %v", route) - } -} - -// --- modes --- - -func TestValidMode(t *testing.T) { - for _, m := range []string{ModeVxlan, ModeBridge, ModePublicIP} { - if !ValidMode(m) { - t.Errorf("%q devrait être un mode valide", m) - } - } - for _, m := range []string{"", "public", "vxlan ", "VXLAN"} { - if ValidMode(m) { - t.Errorf("%q ne devrait pas être un mode valide", m) - } - } -} diff --git a/internal/vm/create.go b/internal/vm/create.go index 7ddc04c..74ed0bb 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -42,13 +42,12 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } if err := metadata.StartMetadata(metadata.NoCloudConfig{ - Name: name, - VpcName: d.vpcName, - BindIP: d.interfaceIP, - BindPort: d.metadataPort, - Password: d.password, - SSHKEY: d.sshkey, - Documents: d.documents, + 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) } diff --git a/internal/vm/data.go b/internal/vm/data.go index b4b9327..3435fb1 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -31,7 +31,6 @@ type vmData struct { uefi bool password string sshkey string - documents map[string]string } func loadVM(db *badger.DB, name string) (vmData, error) { @@ -125,17 +124,5 @@ func loadVM(db *badger.DB, name string) (vmData, error) { d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") - docPrefix := "vm/" + name + "/metadata/" - docEntries, err := kv.ListByPrefix(db, docPrefix) - if err != nil { - return d, fmt.Errorf("list metadata documents: %w", err) - } - if len(docEntries) > 0 { - d.documents = make(map[string]string, len(docEntries)) - for key, content := range docEntries { - d.documents[strings.TrimPrefix(key, docPrefix)] = content - } - } - return d, nil } diff --git a/internal/vm/data_test.go b/internal/vm/data_test.go deleted file mode 100644 index 9c04934..0000000 --- a/internal/vm/data_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package vm - -import ( - "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" -) - -func newVMInDB(t *testing.T) *badger.DB { - t.Helper() - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - - kv.AddInDB(db, "vm/vm-1/subnet", "sn-000001") - kv.AddInDB(db, "subnet/sn-000001/vpc", "vp-admin") - kv.AddInDB(db, "subnet/sn-000001/interface_ip", "10.1.1.1") - kv.AddInDB(db, "subnet/sn-000001/dhcp/10.1.1.2", "00:22:33:00:01:02") - kv.AddInDB(db, "vm/vm-1/ip", "10.1.1.2") - kv.AddInDB(db, "vm/vm-1/metadata_port", "8081") - kv.AddInDB(db, "vm/vm-1/disk/vda", "/data/root.qcow2") - kv.AddInDB(db, "vm/vm-1/memory", "2048") - kv.AddInDB(db, "vm/vm-1/cpus", "2") - return db -} - -func TestLoadVM_NoDocuments(t *testing.T) { - db := newVMInDB(t) - - d, err := loadVM(db, "vm-1") - if err != nil { - t.Fatalf("loadVM : %v", err) - } - if d.documents != nil { - t.Errorf("aucun document en base, la map doit rester nil : %v", d.documents) - } -} - -func TestLoadVM_ReadsDocuments(t *testing.T) { - db := newVMInDB(t) - userData := "#cloud-config\npackages:\n - nginx\n" - kv.AddInDB(db, "vm/vm-1/metadata/user-data", userData) - kv.AddInDB(db, "vm/vm-1/metadata/network-config", "version: 2\n") - - d, err := loadVM(db, "vm-1") - if err != nil { - t.Fatalf("loadVM : %v", err) - } - if len(d.documents) != 2 { - t.Fatalf("2 documents attendus, obtenu %d : %v", len(d.documents), d.documents) - } - if d.documents["user-data"] != userData { - t.Errorf("user-data altéré au passage en base :\nattendu %q\nobtenu %q", userData, d.documents["user-data"]) - } - if d.documents["network-config"] != "version: 2\n" { - t.Errorf("network-config inattendu : %q", d.documents["network-config"]) - } -} - -func TestLoadVM_DocumentKeysAreStrippedOfPrefix(t *testing.T) { - db := newVMInDB(t) - kv.AddInDB(db, "vm/vm-1/metadata/vendor-data", "v") - - d, err := loadVM(db, "vm-1") - if err != nil { - t.Fatalf("loadVM : %v", err) - } - for key := range d.documents { - if key != "vendor-data" { - t.Errorf("clé de document attendue %q, obtenue %q — le préfixe doit être retiré", "vendor-data", key) - } - } -} - -func TestLoadVM_EmptyDocumentIsPreserved(t *testing.T) { - db := newVMInDB(t) - kv.AddInDB(db, "vm/vm-1/metadata/user-data", "") - - d, err := loadVM(db, "vm-1") - if err != nil { - t.Fatalf("loadVM : %v", err) - } - content, ok := d.documents["user-data"] - if !ok { - t.Fatal("un document vide doit survivre au passage en base : c'est une demande de ne rien servir") - } - if content != "" { - t.Errorf("contenu attendu vide, obtenu %q", content) - } -}