Compare commits
59 commits
0.1.0rc003
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3b85f5926 |
|||
|
4ab880a32d |
|||
|
c3f26836cd |
|||
|
9111045417 |
|||
|
a0637d827a |
|||
|
89899005d9 |
|||
|
85c8c4e590 |
|||
|
437766d812 |
|||
|
2a5473eb22 |
|||
|
ec613996a0 |
|||
|
325f1acff5 |
|||
|
c602725de9 |
|||
|
732a293857 |
|||
|
b41b4f2518 |
|||
|
1b56a42627 |
|||
|
c0caf1a24c |
|||
|
bb5698fdda |
|||
|
76a840b80a |
|||
|
2504b435a3 |
|||
|
32b78a84f9 |
|||
|
848f965883 |
|||
|
9492de7a2b |
|||
|
91a5d7ac78 |
|||
|
d457c73198 |
|||
|
9a16cf011a |
|||
|
cef465da2e |
|||
|
7065a9f431 |
|||
|
0506be8a87 |
|||
|
87808312f4 |
|||
|
862406f041 |
|||
|
f5707c343c |
|||
|
915581904c |
|||
|
4bef0f9d5f |
|||
|
e545b70d53 |
|||
|
7078691db6 |
|||
|
593bd42e6a |
|||
|
1ea3a986b8 |
|||
|
c17ee79183 |
|||
|
f005d9dde7 |
|||
|
b4092fbe02 |
|||
|
371ec75468 |
|||
|
cc9998028d |
|||
|
2efaf20098 |
|||
|
504a2a723b |
|||
|
a5bf748bdf |
|||
|
da0d043846 |
|||
|
b84e0e9cbe |
|||
|
bf9f871fe1 |
|||
|
877cfc7260 |
|||
|
e939467abf |
|||
|
649ca66bf9 |
|||
|
ba2f8080be |
|||
|
712692d414 |
|||
|
9950e0e24a |
|||
|
b420217f2b |
|||
|
396f2842e5 |
|||
|
1791196c87 |
|||
|
71aaaacf7b |
|||
|
6b104c4784 |
59 changed files with 3524 additions and 447 deletions
|
|
@ -33,9 +33,7 @@ jobs:
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64]
|
goarch: [amd64]
|
||||||
binaries:
|
binaries:
|
||||||
- db
|
|
||||||
- metadata
|
- metadata
|
||||||
- metacli
|
|
||||||
- agent
|
- agent
|
||||||
uses: ./.forgejo/workflows/build.yml
|
uses: ./.forgejo/workflows/build.yml
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
238
api/agent.yaml
238
api/agent.yaml
|
|
@ -44,6 +44,12 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/VPC"
|
$ref: "#/components/schemas/VPC"
|
||||||
|
"400":
|
||||||
|
description: Missing required field or invalid request body
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
"409":
|
"409":
|
||||||
description: VPC already exists
|
description: VPC already exists
|
||||||
content:
|
content:
|
||||||
|
|
@ -93,6 +99,95 @@ paths:
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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 ─────────────────────────────────────────────────────────────────
|
# ── Subnet ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/subnets:
|
/subnets:
|
||||||
|
|
@ -179,12 +274,6 @@ paths:
|
||||||
$ref: "#/components/schemas/Subnet"
|
$ref: "#/components/schemas/Subnet"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"409":
|
|
||||||
description: Subnet not in a deletable state
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Error"
|
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
|
@ -205,13 +294,17 @@ components:
|
||||||
|
|
||||||
VPCCreateRequest:
|
VPCCreateRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [name]
|
required: [name, cidr]
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Unique name for the VPC, must follow the format vp-[id]
|
description: Unique name for the VPC, must follow the format vp-[id]
|
||||||
pattern: '^vp-.+'
|
pattern: '^vp-.+'
|
||||||
example: vp-00001
|
example: vp-00001
|
||||||
|
cidr:
|
||||||
|
type: string
|
||||||
|
description: CIDR block for the entire VPC address space
|
||||||
|
example: "10.0.0.0/16"
|
||||||
|
|
||||||
VPC:
|
VPC:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -223,10 +316,13 @@ components:
|
||||||
type: string
|
type: string
|
||||||
enum: [creating, created, deleting, deleted]
|
enum: [creating, created, deleting, deleted]
|
||||||
example: created
|
example: created
|
||||||
|
cidr:
|
||||||
|
type: string
|
||||||
|
example: "10.0.0.0/16"
|
||||||
|
|
||||||
SubnetCreateRequest:
|
SubnetCreateRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [name, vpc, vxlan_id, gateway_ip, cidr]
|
required: [name, vpc, interface_ip, cidr]
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -236,15 +332,24 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Parent VPC name
|
description: Parent VPC name
|
||||||
example: vpc1
|
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:
|
vxlan_id:
|
||||||
type: integer
|
type: integer
|
||||||
description: VXLAN VNI identifier
|
description: VXLAN VNI identifier. Required when mode is "vxlan", ignored otherwise.
|
||||||
example: 100
|
example: 100
|
||||||
iface_type:
|
iface_type:
|
||||||
type: string
|
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.
|
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
|
example: vms
|
||||||
gateway_ip:
|
interface_ip:
|
||||||
type: string
|
type: string
|
||||||
format: ipv4
|
format: ipv4
|
||||||
description: Gateway IP for the subnet
|
description: Gateway IP for the subnet
|
||||||
|
|
@ -253,6 +358,12 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Subnet CIDR block
|
description: Subnet CIDR block
|
||||||
example: "10.10.10.0/24"
|
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:
|
Subnet:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -267,19 +378,122 @@ components:
|
||||||
vpc:
|
vpc:
|
||||||
type: string
|
type: string
|
||||||
example: vpc1
|
example: vpc1
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [vxlan, bridge]
|
||||||
|
example: vxlan
|
||||||
vxlan_id:
|
vxlan_id:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: VXLAN VNI. Present only when mode is "vxlan".
|
||||||
example: 100
|
example: 100
|
||||||
local_iface:
|
local_iface:
|
||||||
type: string
|
type: string
|
||||||
description: Resolved interface name
|
description: Resolved interface name from agent config
|
||||||
example: br-000000
|
example: br-000000
|
||||||
gateway_ip:
|
interface_ip:
|
||||||
type: string
|
type: string
|
||||||
example: "10.10.10.1"
|
example: "10.10.10.1"
|
||||||
cidr:
|
cidr:
|
||||||
type: string
|
type: string
|
||||||
example: "10.10.10.0/24"
|
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:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ func main() {
|
||||||
d := dispatcher.New(q, db, cfg, log.With(slog.String("component", "dispatcher")))
|
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 agentapi.New(d, db, log.With(slog.String("component", "api"))).Start(apiAddr)
|
||||||
go promserver.Start(promAddr, registry)
|
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 {}
|
select {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,26 +2,29 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
||||||
"git.g3e.fr/syonad/two/internal/metadata"
|
"git.g3e.fr/syonad/two/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
iface = flag.String("interface", "0.0.0.0", "Interface IP à écouter")
|
confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file")
|
||||||
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")
|
vm_name = flag.String("vm", "", "Name of the vm")
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
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{
|
metadata.StartServer(metadata.ServerConfig{
|
||||||
Netns: *netns_name,
|
|
||||||
Iface: *iface,
|
|
||||||
Port: *port,
|
|
||||||
ConfFile: *conf_file,
|
|
||||||
VmName: *vm_name,
|
VmName: *vm_name,
|
||||||
|
RunDir: cfg.Metadata.RunDir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,28 @@ interfaces:
|
||||||
internet: br-000000
|
internet: br-000000
|
||||||
admin: 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
|
# Logging configuration
|
||||||
logger:
|
logger:
|
||||||
# Log level: debug, info, warn, error (default: info)
|
# Log level: debug, info, warn, error (default: info)
|
||||||
|
|
|
||||||
27
internal/api/agent/helpers_test.go
Normal file
27
internal/api/agent/helpers_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -2,30 +2,69 @@ package agentapi
|
||||||
|
|
||||||
type VPCCreateRequest struct {
|
type VPCCreateRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VPC struct {
|
type VPC struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubnetCreateRequest struct {
|
type SubnetCreateRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
VPC string `json:"vpc"`
|
VPC string `json:"vpc"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
VxlanID int `json:"vxlan_id"`
|
VxlanID int `json:"vxlan_id"`
|
||||||
IfaceType string `json:"iface_type"`
|
IfaceType string `json:"iface_type"`
|
||||||
GatewayIP string `json:"gateway_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
CIDR string `json:"cidr"`
|
||||||
|
DefaultRoute bool `json:"default_route"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subnet struct {
|
type Subnet struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
VPC string `json:"vpc"`
|
VPC string `json:"vpc"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
VxlanID int `json:"vxlan_id"`
|
VxlanID int `json:"vxlan_id"`
|
||||||
LocalIface string `json:"local_iface"`
|
LocalIface string `json:"local_iface"`
|
||||||
GatewayIP string `json:"gateway_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ func (s *Server) Start(address string) {
|
||||||
mux.HandleFunc("/vpcs/", s.VpcByNameHandler)
|
mux.HandleFunc("/vpcs/", s.VpcByNameHandler)
|
||||||
mux.HandleFunc("/subnets", s.SubnetsHandler)
|
mux.HandleFunc("/subnets", s.SubnetsHandler)
|
||||||
mux.HandleFunc("/subnets/", s.SubnetByNameHandler)
|
mux.HandleFunc("/subnets/", s.SubnetByNameHandler)
|
||||||
|
mux.HandleFunc("/vms", s.VmsHandler)
|
||||||
|
mux.HandleFunc("/vms/", s.VmByNameHandler)
|
||||||
s.logger.Info("API server listening", "address", address)
|
s.logger.Info("API server listening", "address", address)
|
||||||
if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil {
|
if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil {
|
||||||
s.logger.Error("API server stopped", "error", err)
|
s.logger.Error("API server stopped", "error", err)
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,18 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
|
||||||
sub.State = value
|
sub.State = value
|
||||||
case "vpc":
|
case "vpc":
|
||||||
sub.VPC = value
|
sub.VPC = value
|
||||||
|
case "mode":
|
||||||
|
sub.Mode = value
|
||||||
case "vxlan_id":
|
case "vxlan_id":
|
||||||
sub.VxlanID, _ = strconv.Atoi(value)
|
sub.VxlanID, _ = strconv.Atoi(value)
|
||||||
case "local_iface":
|
case "local_iface":
|
||||||
sub.LocalIface = value
|
sub.LocalIface = value
|
||||||
case "gateway_ip":
|
case "interface_ip":
|
||||||
sub.GatewayIP = value
|
sub.InterfaceIP = value
|
||||||
case "cidr":
|
case "cidr":
|
||||||
sub.CIDR = value
|
sub.CIDR = value
|
||||||
|
case "default_route":
|
||||||
|
sub.DefaultRoute = value == "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
@ -69,7 +73,12 @@ func (s *Server) deleteSubnet(w http.ResponseWriter, _ *http.Request, name strin
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.dispatcher.Dispatch(cmd)
|
s.dispatcher.Dispatch(cmd)
|
||||||
state, _ := kv.GetFromDB(s.db, "subnet/"+name+"/state")
|
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
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
json.NewEncoder(w).Encode(Subnet{Name: name, State: state})
|
json.NewEncoder(w).Encode(Subnet{Name: name, State: state})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
297
internal/api/agent/subnet_test.go
Normal file
297
internal/api/agent/subnet_test.go
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,14 +44,18 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) {
|
||||||
subnets[name].State = value
|
subnets[name].State = value
|
||||||
case "vpc":
|
case "vpc":
|
||||||
subnets[name].VPC = value
|
subnets[name].VPC = value
|
||||||
|
case "mode":
|
||||||
|
subnets[name].Mode = value
|
||||||
case "vxlan_id":
|
case "vxlan_id":
|
||||||
subnets[name].VxlanID, _ = strconv.Atoi(value)
|
subnets[name].VxlanID, _ = strconv.Atoi(value)
|
||||||
case "local_iface":
|
case "local_iface":
|
||||||
subnets[name].LocalIface = value
|
subnets[name].LocalIface = value
|
||||||
case "gateway_ip":
|
case "interface_ip":
|
||||||
subnets[name].GatewayIP = value
|
subnets[name].InterfaceIP = value
|
||||||
case "cidr":
|
case "cidr":
|
||||||
subnets[name].CIDR = value
|
subnets[name].CIDR = value
|
||||||
|
case "default_route":
|
||||||
|
subnets[name].DefaultRoute = value == "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := make([]Subnet, 0, len(subnets))
|
result := make([]Subnet, 0, len(subnets))
|
||||||
|
|
@ -69,26 +73,37 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name == "" || req.VPC == "" || req.IfaceType == "" || req.GatewayIP == "" || req.CIDR == "" {
|
if req.Name == "" || req.VPC == "" || req.InterfaceIP == "" || req.CIDR == "" {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, iface_type, gateway_ip and cidr are required"})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, interface_ip and cidr are required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := dispatcher.CreateSubnetCommand{
|
cmd := dispatcher.CreateSubnetCommand{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
VPC: req.VPC,
|
VPC: req.VPC,
|
||||||
|
Mode: req.Mode,
|
||||||
VxlanID: req.VxlanID,
|
VxlanID: req.VxlanID,
|
||||||
IfaceType: req.IfaceType,
|
IfaceType: req.IfaceType,
|
||||||
GatewayIP: req.GatewayIP,
|
InterfaceIP: req.InterfaceIP,
|
||||||
CIDR: req.CIDR,
|
CIDR: req.CIDR,
|
||||||
|
DefaultRoute: req.DefaultRoute,
|
||||||
}
|
}
|
||||||
if err := s.dispatcher.Prepare(cmd); err != nil {
|
if err := s.dispatcher.Prepare(cmd); err != nil {
|
||||||
|
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil {
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
}
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.dispatcher.Dispatch(cmd)
|
s.dispatcher.Dispatch(cmd)
|
||||||
entries, _ := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/")
|
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
|
||||||
|
}
|
||||||
sub := Subnet{Name: req.Name}
|
sub := Subnet{Name: req.Name}
|
||||||
for key, value := range entries {
|
for key, value := range entries {
|
||||||
parts := strings.Split(key, "/")
|
parts := strings.Split(key, "/")
|
||||||
|
|
@ -100,14 +115,18 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
|
||||||
sub.State = value
|
sub.State = value
|
||||||
case "vpc":
|
case "vpc":
|
||||||
sub.VPC = value
|
sub.VPC = value
|
||||||
|
case "mode":
|
||||||
|
sub.Mode = value
|
||||||
case "vxlan_id":
|
case "vxlan_id":
|
||||||
sub.VxlanID, _ = strconv.Atoi(value)
|
sub.VxlanID, _ = strconv.Atoi(value)
|
||||||
case "local_iface":
|
case "local_iface":
|
||||||
sub.LocalIface = value
|
sub.LocalIface = value
|
||||||
case "gateway_ip":
|
case "interface_ip":
|
||||||
sub.GatewayIP = value
|
sub.InterfaceIP = value
|
||||||
case "cidr":
|
case "cidr":
|
||||||
sub.CIDR = value
|
sub.CIDR = value
|
||||||
|
case "default_route":
|
||||||
|
sub.DefaultRoute = value == "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
|
|
||||||
98
internal/api/agent/vm.go
Normal file
98
internal/api/agent/vm.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
158
internal/api/agent/vm_test.go
Normal file
158
internal/api/agent/vm_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
117
internal/api/agent/vms.go
Normal file
117
internal/api/agent/vms.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -35,19 +35,29 @@ func (s *Server) getVpc(w http.ResponseWriter, _ *http.Request, name string) {
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cidr, _ := kv.GetFromDB(s.db, "vpc/"+name+"/cidr")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(VPC{Name: name, State: state})
|
json.NewEncoder(w).Encode(VPC{Name: name, State: state, CIDR: cidr})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) {
|
func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) {
|
||||||
cmd := dispatcher.DeleteVPCCommand{Name: name}
|
cmd := dispatcher.DeleteVPCCommand{Name: name}
|
||||||
if err := s.dispatcher.Prepare(cmd); err != nil {
|
if err := s.dispatcher.Prepare(cmd); err != nil {
|
||||||
|
if _, dbErr := kv.GetFromDB(s.db, "vpc/"+name+"/state"); dbErr != nil {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
}
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.dispatcher.Dispatch(cmd)
|
s.dispatcher.Dispatch(cmd)
|
||||||
state, _ := kv.GetFromDB(s.db, "vpc/"+name+"/state")
|
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
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
json.NewEncoder(w).Encode(VPC{Name: name, State: state})
|
json.NewEncoder(w).Encode(VPC{Name: name, State: state})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
211
internal/api/agent/vpc_test.go
Normal file
211
internal/api/agent/vpc_test.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package agentapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -38,8 +39,11 @@ func (s *Server) listVpcs(w http.ResponseWriter, _ *http.Request) {
|
||||||
if _, ok := vpcs[name]; !ok {
|
if _, ok := vpcs[name]; !ok {
|
||||||
vpcs[name] = &VPC{Name: name}
|
vpcs[name] = &VPC{Name: name}
|
||||||
}
|
}
|
||||||
if parts[2] == "state" {
|
switch parts[2] {
|
||||||
|
case "state":
|
||||||
vpcs[name].State = value
|
vpcs[name].State = value
|
||||||
|
case "cidr":
|
||||||
|
vpcs[name].CIDR = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := make([]VPC, 0, len(vpcs))
|
result := make([]VPC, 0, len(vpcs))
|
||||||
|
|
@ -62,14 +66,29 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: "name is required"})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: "name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := dispatcher.CreateVPCCommand{Name: req.Name}
|
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}
|
||||||
if err := s.dispatcher.Prepare(cmd); err != nil {
|
if err := s.dispatcher.Prepare(cmd); err != nil {
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.dispatcher.Dispatch(cmd)
|
s.dispatcher.Dispatch(cmd)
|
||||||
state, _ := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state")
|
state, err := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state")
|
||||||
w.WriteHeader(http.StatusAccepted)
|
if err != nil {
|
||||||
json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state})
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state, CIDR: req.CIDR})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,22 @@ type Config struct {
|
||||||
Level string `mapstructure:"level"`
|
Level string `mapstructure:"level"`
|
||||||
Debug bool `mapstructure:"debug"`
|
Debug bool `mapstructure:"debug"`
|
||||||
} `mapstructure:"logger"`
|
} `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"`
|
DefaultInterface string `mapstructure:"default_interface"`
|
||||||
Interfaces map[string]string `mapstructure:"interfaces"`
|
Interfaces map[string]string `mapstructure:"interfaces"`
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +62,16 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
v.SetDefault("worker.buffer_size", 100)
|
v.SetDefault("worker.buffer_size", 100)
|
||||||
v.SetDefault("dispatcher.timeout_seconds", 300)
|
v.SetDefault("dispatcher.timeout_seconds", 300)
|
||||||
v.SetDefault("dispatcher.poll_seconds", 2)
|
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("default_interface", "br-000000")
|
||||||
v.SetDefault("logger.level", "info")
|
v.SetDefault("logger.level", "info")
|
||||||
v.SetDefault("logger.debug", false)
|
v.SetDefault("logger.debug", false)
|
||||||
|
|
|
||||||
19
internal/dhcp/db.go
Normal file
19
internal/dhcp/db.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -51,9 +51,13 @@ func TestIncrementIP_Carry(t *testing.T) {
|
||||||
func newConf(t *testing.T, cidr string) Config {
|
func newConf(t *testing.T, cidr string) Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, network, _ := net.ParseCIDR(cidr)
|
_, network, _ := net.ParseCIDR(cidr)
|
||||||
|
_, vpcNet, _ := net.ParseCIDR("10.0.0.0/16")
|
||||||
|
gw := net.ParseIP("192.168.1.1").To4()
|
||||||
return Config{
|
return Config{
|
||||||
Network: network,
|
Network: network,
|
||||||
Gateway: net.ParseIP("192.168.1.1").To4(),
|
VPCGateway: gw,
|
||||||
|
VPCRoute: vpcNet,
|
||||||
|
DefaultGateway: gw,
|
||||||
Name: "test",
|
Name: "test",
|
||||||
ConfDir: t.TempDir(),
|
ConfDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +65,7 @@ func newConf(t *testing.T, cidr string) Config {
|
||||||
|
|
||||||
func TestGenerateConfig_CreatesFile(t *testing.T) {
|
func TestGenerateConfig_CreatesFile(t *testing.T) {
|
||||||
conf := newConf(t, "192.168.1.0/29") // 6 hôtes
|
conf := newConf(t, "192.168.1.0/29") // 6 hôtes
|
||||||
path, err := GenerateConfig(conf)
|
path, _, err := GenerateConfig(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateConfig a échoué : %v", err)
|
t.Fatalf("GenerateConfig a échoué : %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +77,7 @@ func TestGenerateConfig_CreatesFile(t *testing.T) {
|
||||||
|
|
||||||
func TestGenerateConfig_FilenameMatchesName(t *testing.T) {
|
func TestGenerateConfig_FilenameMatchesName(t *testing.T) {
|
||||||
conf := newConf(t, "192.168.1.0/29")
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
path, err := GenerateConfig(conf)
|
path, _, err := GenerateConfig(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateConfig a échoué : %v", err)
|
t.Fatalf("GenerateConfig a échoué : %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -84,13 +88,45 @@ func TestGenerateConfig_FilenameMatchesName(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateConfig_ContainsGateway(t *testing.T) {
|
func TestGenerateConfig_ContainsDefaultGateway(t *testing.T) {
|
||||||
conf := newConf(t, "192.168.1.0/29")
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
path, _ := GenerateConfig(conf)
|
path, _, _ := GenerateConfig(conf)
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") {
|
if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") {
|
||||||
t.Errorf("gateway absente du fichier généré :\n%s", content)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,11 +134,10 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
|
||||||
_, network, _ := net.ParseCIDR("10.10.0.0/24")
|
_, network, _ := net.ParseCIDR("10.10.0.0/24")
|
||||||
conf := Config{
|
conf := Config{
|
||||||
Network: network,
|
Network: network,
|
||||||
Gateway: net.ParseIP("10.10.0.1").To4(),
|
|
||||||
Name: "vpc1",
|
Name: "vpc1",
|
||||||
ConfDir: t.TempDir(),
|
ConfDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
path, _ := GenerateConfig(conf)
|
path, _, _ := GenerateConfig(conf)
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
if !strings.Contains(string(content), "dhcp-range=10.10.0.0,static,255.255.255.0,12h") {
|
if !strings.Contains(string(content), "dhcp-range=10.10.0.0,static,255.255.255.0,12h") {
|
||||||
|
|
@ -113,7 +148,7 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
|
||||||
func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) {
|
func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) {
|
||||||
// /29 = réseau + broadcast + 6 hôtes → 8 adresses
|
// /29 = réseau + broadcast + 6 hôtes → 8 adresses
|
||||||
conf := newConf(t, "10.0.0.0/29")
|
conf := newConf(t, "10.0.0.0/29")
|
||||||
path, _ := GenerateConfig(conf)
|
path, _, _ := GenerateConfig(conf)
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
lines := strings.Split(string(content), "\n")
|
lines := strings.Split(string(content), "\n")
|
||||||
|
|
@ -131,7 +166,7 @@ func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) {
|
||||||
|
|
||||||
func TestGenerateConfig_MACPrefix(t *testing.T) {
|
func TestGenerateConfig_MACPrefix(t *testing.T) {
|
||||||
conf := newConf(t, "10.0.0.0/30") // 4 adresses
|
conf := newConf(t, "10.0.0.0/30") // 4 adresses
|
||||||
path, _ := GenerateConfig(conf)
|
path, _, _ := GenerateConfig(conf)
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
if !strings.Contains(string(content), "00:22:33:") {
|
if !strings.Contains(string(content), "00:22:33:") {
|
||||||
|
|
@ -144,11 +179,10 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
|
||||||
_, network, _ := net.ParseCIDR("10.0.0.0/30")
|
_, network, _ := net.ParseCIDR("10.0.0.0/30")
|
||||||
conf := Config{
|
conf := Config{
|
||||||
Network: network,
|
Network: network,
|
||||||
Gateway: net.ParseIP("10.0.0.1").To4(),
|
|
||||||
Name: "net",
|
Name: "net",
|
||||||
ConfDir: dir,
|
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)
|
t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,34 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateConfig(c Config) (string, error) {
|
func GenerateConfig(c Config) (string, map[string]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])
|
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
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "no-resolv\n")
|
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-range=%s,static,%s,12h\n", c.Network.IP.String(), mask)
|
||||||
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String())
|
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=6,1.1.1.1,8.8.8.8\n\n")
|
fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n")
|
||||||
|
|
||||||
|
entries := make(map[string]string)
|
||||||
i := 0
|
i := 0
|
||||||
for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) {
|
for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) {
|
||||||
fmt.Fprintf(&sb, "dhcp-host=00:22:33:%02X:%02X:%02X,%s\n",
|
mac := fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF)
|
||||||
(i>>16)&0xFF, (i>>8)&0xFF, i&0xFF, ip)
|
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", mac, ip)
|
||||||
|
entries[ip.String()] = mac
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
outPath := filepath.Join(c.ConfDir, c.Name+".conf")
|
outPath := filepath.Join(c.ConfDir, c.Name+".conf")
|
||||||
if err := os.MkdirAll(c.ConfDir, 0755); err != nil {
|
if err := os.MkdirAll(c.ConfDir, 0755); err != nil {
|
||||||
return "", err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
return outPath, os.WriteFile(outPath, []byte(sb.String()), 0644)
|
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func incrementIP(ip net.IP) {
|
func incrementIP(ip net.IP) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import (
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Network *net.IPNet
|
Network *net.IPNet
|
||||||
Gateway net.IP
|
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
|
Name string
|
||||||
ConfDir string
|
ConfDir string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
internal/dispatcher/agent/dispatcher_test.go
Normal file
63
internal/dispatcher/agent/dispatcher_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
38
internal/dispatcher/agent/helpers_test.go
Normal file
38
internal/dispatcher/agent/helpers_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -14,13 +14,21 @@ import (
|
||||||
type CreateSubnetCommand struct {
|
type CreateSubnetCommand struct {
|
||||||
Name string
|
Name string
|
||||||
VPC string
|
VPC string
|
||||||
|
Mode string
|
||||||
VxlanID int
|
VxlanID int
|
||||||
IfaceType string
|
IfaceType string
|
||||||
GatewayIP string
|
InterfaceIP string
|
||||||
CIDR string
|
CIDR string
|
||||||
|
DefaultRoute bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
|
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 {
|
if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil {
|
||||||
return fmt.Errorf("subnet %q already exists", c.Name)
|
return fmt.Errorf("subnet %q already exists", c.Name)
|
||||||
}
|
}
|
||||||
|
|
@ -37,10 +45,14 @@ 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+"/state", "creating")
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/vpc", c.VPC)
|
kv.AddInDB(db, "subnet/"+c.Name+"/vpc", c.VPC)
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID))
|
kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode)
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface)
|
kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface)
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/gateway_ip", c.GatewayIP)
|
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+"/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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
231
internal/dispatcher/agent/subnet_commands_test.go
Normal file
231
internal/dispatcher/agent/subnet_commands_test.go
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
133
internal/dispatcher/agent/vm_commands.go
Normal file
133
internal/dispatcher/agent/vm_commands.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
130
internal/dispatcher/agent/vm_commands_test.go
Normal file
130
internal/dispatcher/agent/vm_commands_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
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à")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package dispatcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -13,12 +14,19 @@ import (
|
||||||
|
|
||||||
type CreateVPCCommand struct {
|
type CreateVPCCommand struct {
|
||||||
Name string
|
Name string
|
||||||
|
CIDR string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error {
|
func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error {
|
||||||
if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil {
|
if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil {
|
||||||
return fmt.Errorf("vpc %q already exists", c.Name)
|
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")
|
return kv.AddInDB(db, "vpc/"+c.Name+"/state", "creating")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
104
internal/dispatcher/agent/vpc_commands_test.go
Normal file
104
internal/dispatcher/agent/vpc_commands_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/ebtables/ebtables.go
Normal file
60
internal/ebtables/ebtables.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
42
internal/iptables/iptables.go
Normal file
42
internal/iptables/iptables.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,18 @@ package metadata
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
||||||
"git.g3e.fr/syonad/two/pkg/systemd"
|
"git.g3e.fr/syonad/two/pkg/systemd"
|
||||||
"github.com/dgraph-io/badger/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartMetadata(config NoCloudConfig, db *badger.DB, dryrun bool) error {
|
func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error {
|
||||||
service, err := systemd.New()
|
service, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to systemd: %w", err)
|
return fmt.Errorf("failed to connect to systemd: %w", err)
|
||||||
}
|
}
|
||||||
defer service.Close()
|
defer service.Close()
|
||||||
|
|
||||||
LoadNcCloudInDB(config, db)
|
LoadNcCloudInDB(config, cfg.Metadata.RunDir)
|
||||||
if !dryrun {
|
if !dryrun {
|
||||||
if err := service.Start("metadata@" + config.Name + ".service"); err != nil {
|
if err := service.Start("metadata@" + config.Name + ".service"); err != nil {
|
||||||
return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err)
|
return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err)
|
||||||
|
|
@ -23,17 +23,17 @@ func StartMetadata(config NoCloudConfig, db *badger.DB, dryrun bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func StopMetadata(vm_name string, db *badger.DB, dryrun bool) error {
|
func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error {
|
||||||
service, err := systemd.New()
|
service, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to systemd: %w", err)
|
return fmt.Errorf("failed to connect to systemd: %w", err)
|
||||||
}
|
}
|
||||||
defer service.Close()
|
defer service.Close()
|
||||||
|
|
||||||
UnLoadNoCloudInDB(vm_name, db)
|
UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir)
|
||||||
if !dryrun {
|
if !dryrun {
|
||||||
if err := service.Stop("metadata@" + vm_name + ".service"); err != nil {
|
if err := service.Stop("metadata@" + vmName + ".service"); err != nil {
|
||||||
return fmt.Errorf("failed to stop metadata@%s: %w", vm_name, err)
|
return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCfg() NoCloudConfig {
|
func newCfg() NoCloudConfig {
|
||||||
|
|
@ -18,11 +20,9 @@ func newCfg() NoCloudConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestDB(t *testing.T) interface{ Close() error } {
|
func useTestDir(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
return t.TempDir()
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
return db
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RenderConfig ---
|
// --- RenderConfig ---
|
||||||
|
|
@ -106,95 +106,161 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) {
|
||||||
|
|
||||||
// --- LoadNcCloudInDB / UnLoadNoCloudInDB ---
|
// --- LoadNcCloudInDB / UnLoadNoCloudInDB ---
|
||||||
|
|
||||||
func TestLoadNcCloudInDB_StoresAllKeys(t *testing.T) {
|
func readTestFile(t *testing.T, dir, vmName, name string) string {
|
||||||
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
t.Helper()
|
||||||
t.Cleanup(func() { db.Close() })
|
b, err := os.ReadFile(filepath.Join(dir, vmName, name))
|
||||||
|
|
||||||
cfg := newCfg()
|
|
||||||
LoadNcCloudInDB(cfg, db)
|
|
||||||
|
|
||||||
keys := []string{
|
|
||||||
"metadata/vm1/meta-data",
|
|
||||||
"metadata/vm1/user-data",
|
|
||||||
"metadata/vm1/network-config",
|
|
||||||
"metadata/vm1/vendor-data",
|
|
||||||
"metadata/vm1/vpc",
|
|
||||||
"metadata/vm1/bind_ip",
|
|
||||||
"metadata/vm1/bind_port",
|
|
||||||
}
|
|
||||||
for _, key := range keys {
|
|
||||||
val, err := kv.GetFromDB(db, key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("clé %q absente après LoadNcCloudInDB : %v", key, err)
|
t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err)
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if val == "" && key != "metadata/vm1/user-data" {
|
return string(b)
|
||||||
t.Errorf("clé %q vide après LoadNcCloudInDB", key)
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
|
func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
|
||||||
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
dir := useTestDir(t)
|
||||||
t.Cleanup(func() { db.Close() })
|
LoadNcCloudInDB(newCfg(), dir)
|
||||||
|
|
||||||
cfg := newCfg()
|
if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" {
|
||||||
LoadNcCloudInDB(cfg, db)
|
|
||||||
|
|
||||||
vpc, _ := kv.GetFromDB(db, "metadata/vm1/vpc")
|
|
||||||
if vpc != "vpc-test" {
|
|
||||||
t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc)
|
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)
|
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)
|
t.Errorf("bind_port attendu %q, obtenu %q", "80", port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) {
|
func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) {
|
||||||
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
dir := useTestDir(t)
|
||||||
t.Cleanup(func() { db.Close() })
|
LoadNcCloudInDB(newCfg(), dir)
|
||||||
|
UnLoadNoCloudInDB("vm1", dir)
|
||||||
|
|
||||||
cfg := newCfg()
|
if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) {
|
||||||
LoadNcCloudInDB(cfg, db)
|
t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB")
|
||||||
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) {
|
func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) {
|
||||||
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
dir := useTestDir(t)
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
|
|
||||||
cfg1 := newCfg()
|
cfg1 := newCfg()
|
||||||
cfg2 := newCfg()
|
cfg2 := newCfg()
|
||||||
cfg2.Name = "vm2"
|
cfg2.Name = "vm2"
|
||||||
LoadNcCloudInDB(cfg1, db)
|
LoadNcCloudInDB(cfg1, dir)
|
||||||
LoadNcCloudInDB(cfg2, db)
|
LoadNcCloudInDB(cfg2, dir)
|
||||||
|
|
||||||
UnLoadNoCloudInDB("vm1", db)
|
UnLoadNoCloudInDB("vm1", dir)
|
||||||
|
|
||||||
_, err := kv.GetFromDB(db, "metadata/vm2/vpc")
|
if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Errorf("vm2 ne devrait pas être supprimée : %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ package metadata
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
|
||||||
"github.com/dgraph-io/badger/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.tmpl
|
//go:embed templates/*.tmpl
|
||||||
|
|
@ -26,21 +25,25 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadNcCloudInDB(config NoCloudConfig, db *badger.DB) {
|
func LoadNcCloudInDB(config NoCloudConfig, runDir string) {
|
||||||
meta_data, _ := RenderConfig("templates/meta-data.tmpl", config)
|
meta_data, _ := RenderConfig("templates/meta-data.tmpl", config)
|
||||||
user_data, _ := RenderConfig("templates/user-data.tmpl", config)
|
user_data, _ := RenderConfig("templates/user-data.tmpl", config)
|
||||||
network_config, _ := RenderConfig("templates/network-config.tmpl", config)
|
network_config, _ := RenderConfig("templates/network-config.tmpl", config)
|
||||||
vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config)
|
vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config)
|
||||||
|
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/meta-data", meta_data)
|
dir := filepath.Join(runDir, config.Name)
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/user-data", user_data)
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/network-config", network_config)
|
return
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/vendor-data", vendor_data)
|
}
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/vpc", config.VpcName)
|
os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644)
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/bind_ip", config.BindIP)
|
os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644)
|
||||||
kv.AddInDB(db, "metadata/"+config.Name+"/bind_port", config.BindPort)
|
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 UnLoadNoCloudInDB(vm_name string, db *badger.DB) {
|
func UnLoadNoCloudInDB(vmName string, runDir string) {
|
||||||
kv.DeleteInDB(db, "metadata/"+vm_name)
|
os.RemoveAll(filepath.Join(runDir, vmName))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
|
||||||
"git.g3e.fr/syonad/two/internal/netns"
|
"git.g3e.fr/syonad/two/internal/netns"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var data NoCloudData
|
var data NoCloudData
|
||||||
|
|
@ -23,47 +24,23 @@ func getIP(r *http.Request) string {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFromDB(config ServerConfig) NoCloudData {
|
func readFile(dir, name string) string {
|
||||||
var netns_name string
|
b, _ := os.ReadFile(filepath.Join(dir, name))
|
||||||
var port int
|
return strings.TrimRight(string(b), "\n")
|
||||||
var iface string
|
|
||||||
|
|
||||||
conf_db, _ := configuration.LoadConfig(config.ConfFile)
|
|
||||||
|
|
||||||
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 == "" {
|
func getFromFiles(config ServerConfig) NoCloudData {
|
||||||
iface, _ = kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_ip")
|
dir := filepath.Join(config.RunDir, config.VmName)
|
||||||
} else {
|
|
||||||
iface = config.Iface
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Port == 0 {
|
port, _ := strconv.Atoi(readFile(dir, "bind_port"))
|
||||||
sport, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_port")
|
|
||||||
port, _ = strconv.Atoi(sport)
|
|
||||||
} else {
|
|
||||||
port = config.Port
|
|
||||||
}
|
|
||||||
|
|
||||||
return NoCloudData{
|
return NoCloudData{
|
||||||
MetaData: metadata,
|
MetaData: readFile(dir, "meta-data"),
|
||||||
UserData: userdata,
|
UserData: readFile(dir, "user-data"),
|
||||||
NetworkConfig: networkconfig,
|
NetworkConfig: readFile(dir, "network-config"),
|
||||||
VendorData: vendordata,
|
VendorData: readFile(dir, "vendor-data"),
|
||||||
NetNs: netns_name,
|
NetNs: readFile(dir, "vpc"),
|
||||||
Iface: iface,
|
Iface: readFile(dir, "bind_ip"),
|
||||||
Port: port,
|
Port: port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +70,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartServer(config ServerConfig) {
|
func StartServer(config ServerConfig) {
|
||||||
data = getFromDB(config)
|
data = getFromFiles(config)
|
||||||
|
|
||||||
if data.NetNs != "" {
|
if data.NetNs != "" {
|
||||||
if err := netns.Enter(data.NetNs); err != nil {
|
if err := netns.Enter(data.NetNs); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,8 @@ type NoCloudData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Netns string
|
|
||||||
File string
|
|
||||||
Iface string
|
|
||||||
Port int
|
|
||||||
ConfFile string
|
|
||||||
VmName string
|
VmName string
|
||||||
|
RunDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoCloudConfig struct {
|
type NoCloudConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,8 @@
|
||||||
users:
|
users:
|
||||||
- name: syonad
|
- name: syonad
|
||||||
lock_passwd: false
|
lock_passwd: false
|
||||||
gecos: alpine Cloud User
|
|
||||||
groups: [adm, wheel]
|
|
||||||
doas:
|
|
||||||
- permit nopass syonad
|
|
||||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||||
shell: /bin/ash
|
shell: /bin/bash
|
||||||
passwd: "{{ .Password }}"
|
passwd: "{{ .Password }}"
|
||||||
ssh_authorized_keys:
|
ssh_authorized_keys:
|
||||||
- "{{ .SSHKEY }}"
|
- "{{ .SSHKEY }}"
|
||||||
29
internal/netif/gateway_linux.go
Normal file
29
internal/netif/gateway_linux.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//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")
|
||||||
|
}
|
||||||
12
internal/netif/gateway_other.go
Normal file
12
internal/netif/gateway_other.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package netif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetDefaultGateway() (net.IP, error) {
|
||||||
|
return nil, fmt.Errorf("not supported on this platform")
|
||||||
|
}
|
||||||
34
internal/netif/tap_linux.go
Normal file
34
internal/netif/tap_linux.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
//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))
|
||||||
|
})
|
||||||
|
}
|
||||||
13
internal/netif/tap_other.go
Normal file
13
internal/netif/tap_other.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//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")
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"github.com/vishvananda/netlink"
|
"github.com/vishvananda/netlink"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateVxlan(name string, vxlanID int, localIface string) error {
|
func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
|
||||||
link, err := netlink.LinkByName(localIface)
|
link, err := netlink.LinkByName(localIface)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -12,6 +12,7 @@ func CreateVxlan(name string, vxlanID int, localIface string) error {
|
||||||
vxlan := &netlink.Vxlan{
|
vxlan := &netlink.Vxlan{
|
||||||
LinkAttrs: netlink.LinkAttrs{
|
LinkAttrs: netlink.LinkAttrs{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
MTU: mtu,
|
||||||
},
|
},
|
||||||
VxlanId: vxlanID,
|
VxlanId: vxlanID,
|
||||||
Port: 4789,
|
Port: 4789,
|
||||||
|
|
|
||||||
20
internal/qemu/config.go
Normal file
20
internal/qemu/config.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
106
internal/qemu/start_linux.go
Normal file
106
internal/qemu/start_linux.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//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
|
||||||
|
}
|
||||||
11
internal/qemu/start_other.go
Normal file
11
internal/qemu/start_other.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package qemu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Start(_ Config) error {
|
||||||
|
return errors.New("vm: not supported on this platform")
|
||||||
|
}
|
||||||
50
internal/qmp/send.go
Normal file
50
internal/qmp/send.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,9 @@ package subnet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/internal/dhcp"
|
"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/netif"
|
||||||
"git.g3e.fr/syonad/two/internal/netns"
|
"git.g3e.fr/syonad/two/internal/netns"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
|
@ -25,94 +22,53 @@ func CreateSubnet(db *badger.DB, subnetName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lecture des paramètres depuis la DB
|
d, err := loadSubnet(db, subnetName)
|
||||||
vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get vpc: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id")
|
if err := createSubnet(db, subnetName, d); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return fmt.Errorf("get vxlan_id: %w", err)
|
|
||||||
}
|
|
||||||
vxlanID, err := strconv.Atoi(vxlanIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse vxlan_id: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localIface, err := kv.GetFromDB(db, "subnet/"+subnetName+"/local_iface")
|
return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get local_iface: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip")
|
func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
if err != nil {
|
vethE := "v-" + d.subnetID + "-e"
|
||||||
return fmt.Errorf("get gateway_ip: %w", err)
|
vethI := "v-" + d.subnetID + "-i"
|
||||||
}
|
|
||||||
gatewayIP := net.ParseIP(gatewayIPStr)
|
|
||||||
if gatewayIP == nil {
|
|
||||||
return fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
cidr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/cidr")
|
if err := netif.CreateVethToNetns(vethE, vethI, "/var/run/netns/"+d.vpc, 1500); err != nil {
|
||||||
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)
|
return fmt.Errorf("create veth: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bridge dans le root netns
|
switch d.mode {
|
||||||
if err := netif.CreateBridge(bridge, 1500); err != nil {
|
case "vxlan":
|
||||||
return fmt.Errorf("create bridge: %w", err)
|
if err := setupVxlanHost(d, vethE); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
case "bridge":
|
||||||
// bridge dans le netns VPC
|
if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil {
|
||||||
if err := netns.Call(vpcName, func() error {
|
|
||||||
return netif.CreateBridge(bridge, 1500)
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("create bridge in netns: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
return fmt.Errorf("add veth-e to bridge: %w", err)
|
||||||
}
|
}
|
||||||
if err := netns.Call(vpcName, func() error {
|
if err := netif.LinkSetUp(vethE); err != nil {
|
||||||
return netif.BridgeSetMaster("v-"+subnetID+"-i", bridge)
|
return fmt.Errorf("set up %s: %w", vethE, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown subnet mode %q", d.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("add veth-i to bridge in netns: %w", err)
|
return fmt.Errorf("setup 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
|
if err := netns.Call(d.vpc, func() error {
|
||||||
for _, iface := range []string{"v-" + subnetID + "-e", vxlanIface, bridge} {
|
for _, iface := range []string{vethI, d.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 {
|
if err := netif.LinkSetUp(iface); err != nil {
|
||||||
return fmt.Errorf("set up %s: %w", iface, err)
|
return fmt.Errorf("set up %s: %w", iface, err)
|
||||||
}
|
}
|
||||||
|
|
@ -122,51 +78,88 @@ func CreateSubnet(db *badger.DB, subnetName string) error {
|
||||||
return fmt.Errorf("set up interfaces in netns: %w", err)
|
return fmt.Errorf("set up interfaces in netns: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP gateway (/32) sur le bridge interne
|
switch d.mode {
|
||||||
if err := netns.Call(vpcName, func() error {
|
case "vxlan":
|
||||||
return netif.AddrAdd(bridge, gatewayIP)
|
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 {
|
}); err != nil {
|
||||||
return fmt.Errorf("add addr to bridge in netns: %w", err)
|
return fmt.Errorf("configure netns: %w", err)
|
||||||
}
|
}
|
||||||
|
case "bridge":
|
||||||
// route subnet (scope link) dans le netns VPC
|
if err := netns.Call(d.vpc, func() error {
|
||||||
if err := netns.Call(vpcName, func() error {
|
if err := netif.AddrAdd(d.bridge, d.interfaceIP); err != nil {
|
||||||
return netif.RouteAdd(bridge, subnet)
|
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 {
|
}); err != nil {
|
||||||
return fmt.Errorf("add route in netns: %w", err)
|
return fmt.Errorf("configure netns: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ebtables : drop ARP Request vers la gateway sur ce bridge
|
return startDHCP(db, subnetName, d)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ebtables : drop trafic DHCP sur ce bridge
|
func setupVxlanHost(d subnetData, vethE string) error {
|
||||||
if err := exec.Command("ebtables", "-A", "FORWARD",
|
vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID)
|
||||||
"--out-interface", bridge,
|
|
||||||
"-p", "IPv4",
|
if err := netif.CreateBridge(d.bridge, 1500); err != nil {
|
||||||
"--ip-protocol", "udp",
|
return fmt.Errorf("create bridge: %w", err)
|
||||||
"--ip-source-port", "67:68",
|
}
|
||||||
"--ip-destination-port", "67:68",
|
if err := netif.CreateVxlan(vxlanIface, d.vxlanID, d.localIface, 1500); err != nil {
|
||||||
"-j", "DROP").Run(); err != nil {
|
return fmt.Errorf("create vxlan: %w", err)
|
||||||
return fmt.Errorf("ebtables dhcp rule: %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
|
||||||
}
|
}
|
||||||
|
|
||||||
// génération de la config dnsmasq et démarrage du service
|
func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
conf := dhcp.Config{
|
conf := dhcp.Config{
|
||||||
Network: subnet,
|
Network: d.cidr,
|
||||||
Gateway: gatewayIP,
|
Name: d.vpc + "_" + d.bridge,
|
||||||
Name: vpcName + "_" + bridge,
|
|
||||||
ConfDir: "/etc/dnsmasq.d",
|
ConfDir: "/etc/dnsmasq.d",
|
||||||
}
|
}
|
||||||
if _, err := dhcp.GenerateConfig(conf); err != nil {
|
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 {
|
||||||
return fmt.Errorf("generate dhcp config: %w", err)
|
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()
|
svc, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,6 +170,5 @@ func CreateSubnet(db *badger.DB, subnetName string) error {
|
||||||
if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil {
|
if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil {
|
||||||
return fmt.Errorf("start dnsmasq: %w", err)
|
return fmt.Errorf("start dnsmasq: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
internal/subnet/data.go
Normal file
99
internal/subnet/data.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,8 @@ package subnet
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"git.g3e.fr/syonad/two/internal/ebtables"
|
||||||
"git.g3e.fr/syonad/two/internal/netif"
|
"git.g3e.fr/syonad/two/internal/netif"
|
||||||
"git.g3e.fr/syonad/two/internal/netns"
|
"git.g3e.fr/syonad/two/internal/netns"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
|
@ -23,73 +22,99 @@ func DeleteSubnet(db *badger.DB, subnetName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc")
|
d, err := loadSubnet(db, subnetName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get vpc: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id")
|
if err := stopDHCP(db, subnetName, d); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return fmt.Errorf("get vxlan_id: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subnetID := strings.SplitN(subnetName, "-", 2)[1]
|
switch d.mode {
|
||||||
bridge := "br-" + subnetID
|
case "vxlan":
|
||||||
vxlanIface := "vxlan-" + vxlanIDStr
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// arrêt du service dnsmasq
|
return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopDHCP(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
svc, err := systemd.New()
|
svc, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to systemd: %w", err)
|
return fmt.Errorf("connect to systemd: %w", err)
|
||||||
}
|
}
|
||||||
defer svc.Close()
|
defer svc.Close()
|
||||||
|
|
||||||
svcName := "dnsmasq@" + vpcName + "_" + bridge + ".service"
|
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 {
|
if err := svc.Stop(svcName); err != nil {
|
||||||
return fmt.Errorf("stop dnsmasq: %w", err)
|
return fmt.Errorf("stop dnsmasq: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// suppression de la config dnsmasq
|
if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) {
|
||||||
if err := os.Remove("/etc/dnsmasq.d/" + vpcName + "_" + bridge + ".conf"); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("remove dnsmasq config: %w", err)
|
return fmt.Errorf("remove dnsmasq config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// suppression des règles ebtables
|
if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil {
|
||||||
exec.Command("ebtables", "-D", "FORWARD",
|
return fmt.Errorf("delete dhcp entries: %w", err)
|
||||||
"--out-interface", bridge,
|
}
|
||||||
"-p", "arp",
|
return nil
|
||||||
"--arp-op", "Request",
|
}
|
||||||
"-j", "DROP").Run()
|
|
||||||
|
func deleteSubnetVxlan(d subnetData) error {
|
||||||
exec.Command("ebtables", "-D", "FORWARD",
|
vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID)
|
||||||
"--out-interface", bridge,
|
vethI := "v-" + d.subnetID + "-i"
|
||||||
"-p", "IPv4",
|
|
||||||
"--ip-protocol", "udp",
|
if err := netns.Call(d.vpc, func() error {
|
||||||
"--ip-source-port", "67:68",
|
if err := ebtables.DeleteARPToGateway(vethI, d.interfaceIP.String()); err != nil {
|
||||||
"--ip-destination-port", "67:68",
|
return fmt.Errorf("delete ebtables arp rule: %w", err)
|
||||||
"-j", "DROP").Run()
|
}
|
||||||
|
if err := ebtables.DeleteDHCP(vethI, d.interfaceIP.String()); err != nil {
|
||||||
// suppression du bridge dans le netns VPC
|
return fmt.Errorf("delete ebtables dhcp rule: %w", err)
|
||||||
if err := netns.Call(vpcName, func() error {
|
}
|
||||||
return netif.DeleteLink(bridge)
|
return netif.DeleteLink(d.bridge)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("delete bridge in netns: %w", err)
|
return fmt.Errorf("delete netns resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// suppression du vxlan
|
|
||||||
if err := netif.DeleteLink(vxlanIface); err != nil {
|
if err := netif.DeleteLink(vxlanIface); err != nil {
|
||||||
return fmt.Errorf("delete vxlan: %w", err)
|
return fmt.Errorf("delete vxlan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// suppression du veth pair (supprime les deux côtés)
|
if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil {
|
||||||
if err := netif.DeleteLink("v-" + subnetID + "-e"); err != nil {
|
|
||||||
return fmt.Errorf("delete veth: %w", err)
|
return fmt.Errorf("delete veth: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// suppression du bridge dans le root netns
|
if err := netif.DeleteLink(d.bridge); err != nil {
|
||||||
if err := netif.DeleteLink(bridge); err != nil {
|
|
||||||
return fmt.Errorf("delete bridge: %w", err)
|
return fmt.Errorf("delete bridge: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted")
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
internal/vm/create.go
Normal file
110
internal/vm/create.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
128
internal/vm/data.go
Normal file
128
internal/vm/data.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
84
internal/vm/delete.go
Normal file
84
internal/vm/delete.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pkg/db/kv/admin_server.go
Normal file
52
pkg/db/kv/admin_server.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package kv
|
package kv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/dgraph-io/badger/v4"
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,7 +33,7 @@ func DeleteInDB(db *badger.DB, key string) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleteKey(db, key)
|
return deleteKey(db, key)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package kv
|
package kv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/dgraph-io/badger/v4"
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,6 +17,7 @@ func InitDB(conf Config, readonly bool) *badger.DB {
|
||||||
opts.NumLevelZeroTablesStall = 2
|
opts.NumLevelZeroTablesStall = 2
|
||||||
db, err := badger.Open(opts)
|
db, err := badger.Open(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("kv.InitDB (readonly=%v, path=%s): %v", readonly, conf.Path, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return db
|
return db
|
||||||
|
|
|
||||||
|
|
@ -151,3 +151,69 @@ func TestDeleteInDB_MissingKey(t *testing.T) {
|
||||||
t.Logf("DeleteInDB clé inexistante retourne : %v (non bloquant)", err)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
103
pkg/worker/queue_test.go
Normal file
103
pkg/worker/queue_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue