Experimental Proxmox / Docker compose tool
compose proxmox docker
0

Configure Feed

Select the types of activity you want to include in your feed.

nodes and vm config

author
ollie
date (Jul 21, 2026, 10:39 PM +0200) commit 3f73ed3f parent 1aa4ee50 change-id lvkssxtq
+231 -8
+1 -1
README.md
··· 29 29 ```sh 30 30 pvesh create /cluster/mapping/dir --id {vmid}-compose --map node={node},path=/root/compose/{vmid}/ 31 31 32 - pvesh create /nodes/pve/qemu/109/config --virtiofs{fs-nr} dirid={vmid}-compose 32 + pvesh create /nodes/pve/qemu/{vmid}/config --virtiofs{fs-nr} dirid={vmid}-compose 33 33 ``` 34 34 35 35
+34 -1
dev/proxpose_dev.gleam
··· 14 14 // directory_mappings(host, credentials) 15 15 16 16 // directory_mapping_by_id(host, credentials, "mar-music-lib") 17 - directory_mapping_by_id(host, credentials, "awdadwa") 17 + // directory_mapping_by_id(host, credentials, "awdadwa") 18 + 19 + // vm_config(host, credentials, "primergy", 108) 20 + 21 + nodes(host, credentials) 18 22 |> echo 23 + } 24 + 25 + fn nodes(host: String, credentials: proxmox.Credentials) { 26 + let req = proxmox.nodes(host:, credentials:) 27 + 28 + use resp <- result.try( 29 + send(req) 30 + |> result.map_error(string.inspect), 31 + ) 32 + 33 + proxmox.nodes_response(resp) 34 + |> result.map_error(string.inspect) 35 + } 36 + 37 + fn vm_config( 38 + host: String, 39 + credentials: proxmox.Credentials, 40 + node: String, 41 + vmid: Int, 42 + ) { 43 + let req = proxmox.vm_config_by_id(host:, credentials:, node:, vmid:) 44 + 45 + use resp <- result.try( 46 + send(req) 47 + |> result.map_error(string.inspect), 48 + ) 49 + 50 + proxmox.vm_config_by_id_response(resp) 51 + |> result.map_error(string.inspect) 19 52 } 20 53 21 54 fn send(req) {
+196 -6
src/proxpose/proxmox.gleam
··· 3 3 import gleam/http 4 4 import gleam/http/request 5 5 import gleam/http/response 6 + import gleam/int 6 7 import gleam/json 7 8 import gleam/list 8 9 import gleam/option 9 10 import gleam/result 10 11 import gleam/string 11 12 12 - const api_base = "/api2/json/" 13 + const api_base: String = "/api2/json/" 13 14 14 15 // AUTH ------------------------------------------------------------------------- 15 16 ··· 48 49 GotUnexpectedResponse(Int, String) 49 50 Unauthorized 50 51 FailedToDecode(json.DecodeError) 51 - NotFound 52 + NotFound(String) 52 53 UnexpectedData(String) 53 54 ProxmoxInternalError(String) 55 + MissingPermissions(String) 54 56 } 55 57 56 58 // REQUESTS --------------------------------------------------------------------- ··· 62 64 case resp.status { 63 65 200 -> callback(resp.body) 64 66 401 -> Error(Unauthorized) 67 + 403 -> { 68 + case decode_message(resp.body) { 69 + "Permission check failed " <> message -> { 70 + let message = string.trim(message) 71 + Error(MissingPermissions(message)) 72 + } 73 + message -> Error(MissingPermissions(message)) 74 + } 75 + } 65 76 // proxmox will return response code 500 instead of 404 66 77 500 -> 67 78 // so we try to parse the data, which will be `null` on 404 68 79 case json.parse(resp.body, decode_data(decode.optional(decode.string))) { 69 - Ok(option.None) -> Error(NotFound) 80 + Ok(option.None) -> { 81 + Error(NotFound(decode_message(resp.body))) 82 + } 70 83 Ok(option.Some(data)) -> Error(UnexpectedData(data)) 71 84 Error(_) -> Error(ProxmoxInternalError(resp.body)) 72 85 } ··· 74 87 } 75 88 } 76 89 90 + fn decode_message(body: String) -> String { 91 + json.parse(body, decode.at(["message"], decode.string)) 92 + |> result.unwrap("") 93 + } 94 + 77 95 fn decode_data(decoder: decode.Decoder(a)) -> decode.Decoder(a) { 78 96 decode.at(["data"], decoder) 79 97 } ··· 87 105 /// use path <- decode.then(map_item_decoder(map, "path")) 88 106 /// ``` 89 107 /// 90 - fn map_item_decoder(map: dict.Dict(String, String), key: String) { 108 + fn map_item_decoder( 109 + map: dict.Dict(String, String), 110 + key: String, 111 + ) -> decode.Decoder(String) { 91 112 case dict.get(map, key) { 92 113 Error(_) -> decode.failure("", "Value for key: " <> key) 93 114 Ok(value) -> decode.success(value) ··· 103 124 /// use path <- decode.then(map_item_decoder(map, "path")) 104 125 /// ``` 105 126 /// 106 - fn map_decoder() { 127 + fn map_decoder() -> decode.Decoder(dict.Dict(String, String)) { 107 128 use content <- decode.then(decode.list(decode.string)) 108 129 109 130 case content { ··· 174 195 pub fn directory_mapping_by_id_response( 175 196 response: response.Response(String), 176 197 id: String, 177 - ) { 198 + ) -> Result(DirectoryMapping, ApiError) { 178 199 handle_response(response, fn(body) { 179 200 json.parse(body, decode_data(directory_mapping_decoder(option.Some(id)))) 180 201 |> result.map_error(FailedToDecode) ··· 242 263 } 243 264 } 244 265 } 266 + 267 + // VM CONFIG -------------------------------------------------------------------- 268 + // /node/{node}/qemu/{vmid}/config ---------------------------------------------- 269 + 270 + pub type VmConfig { 271 + Qemu( 272 + digest: String, 273 + tags: List(String), 274 + cpu_type: String, 275 + cpu_sockets: Int, 276 + cpu_cores: Int, 277 + memory_mib: Int, 278 + virtiofs: List(VirtioFs), 279 + ) 280 + } 281 + 282 + fn vm_config_decoder() -> decode.Decoder(VmConfig) { 283 + use digest <- decode.field("digest", decode.string) 284 + use tags <- decode.field("tags", decode_tags()) 285 + use cpu_type <- decode.field("cpu", decode.string) 286 + use cpu_sockets <- decode.field("sockets", decode.int) 287 + use cpu_cores <- decode.field("cores", decode.int) 288 + use memory_mib <- decode.field("memory", decode_more_int()) 289 + 290 + // decode all fields into a dict 291 + use fields <- decode.then(decode.dict(decode.string, decode_more_string())) 292 + 293 + let virtiofs = 294 + dict.to_list(fields) 295 + |> list.filter_map(fn(field) { 296 + case field { 297 + // grab virtiofs 298 + #("virtiofs" <> rest, id) -> { 299 + case int.parse(rest) { 300 + Ok(nr) -> VirtioFs(nr:, fs_id: id) |> Ok 301 + Error(_) -> Error(Nil) 302 + } 303 + } 304 + _ -> Error(Nil) 305 + } 306 + }) 307 + 308 + decode.success(Qemu( 309 + digest:, 310 + tags:, 311 + cpu_type:, 312 + cpu_sockets:, 313 + cpu_cores:, 314 + memory_mib:, 315 + virtiofs:, 316 + )) 317 + } 318 + 319 + fn decode_more_string() -> decode.Decoder(String) { 320 + decode.one_of(decode.string, [ 321 + decode.int |> decode.map(int.to_string), 322 + ]) 323 + } 324 + 325 + fn decode_more_int() -> decode.Decoder(Int) { 326 + decode.one_of(decode.int, [ 327 + decode.string 328 + |> decode.then(fn(num) { 329 + case int.parse(num) { 330 + Ok(num) -> decode.success(num) 331 + Error(_) -> decode.failure(0, "Int") 332 + } 333 + }), 334 + ]) 335 + } 336 + 337 + fn decode_tags() -> decode.Decoder(List(String)) { 338 + use tags <- decode.then(decode.string) 339 + decode.success(string.split(tags, ";")) 340 + } 341 + 342 + pub type VirtioFs { 343 + VirtioFs(nr: Int, fs_id: String) 344 + } 345 + 346 + /// /nodes/{node}/qemu/{vmid}/config 347 + /// 348 + /// Required permissions 349 + /// `VM.Audit` for `/vms/{vmid}` 350 + /// 351 + pub fn vm_config_by_id( 352 + host host: String, 353 + credentials credentials: Credentials, 354 + node node: String, 355 + vmid vmid: Int, 356 + ) -> request.Request(String) { 357 + base_request(host, credentials) 358 + |> request.set_method(http.Get) 359 + |> request.set_path( 360 + api_base <> "nodes/" <> node <> "/qemu/" <> int.to_string(vmid) <> "/config", 361 + ) 362 + } 363 + 364 + pub fn vm_config_by_id_response( 365 + response: response.Response(String), 366 + ) -> Result(VmConfig, ApiError) { 367 + handle_response(response, fn(body) { 368 + json.parse(body, decode_data(vm_config_decoder())) 369 + |> result.map_error(FailedToDecode) 370 + }) 371 + } 372 + 373 + // NODES ------------------------------------------------------------------------ 374 + // /cluster/config/nodes -------------------------------------------------------- 375 + 376 + /// { 377 + /// "data": [ 378 + /// { 379 + /// "nodeid": "1", 380 + /// "ring0_addr": "192.168.188.50", 381 + /// "name": "primergy", 382 + /// "quorum_votes": "1", 383 + /// "node": "primergy" 384 + /// }, 385 + /// { 386 + /// "name": "pve", 387 + /// "nodeid": "2", 388 + /// "ring0_addr": "192.168.188.40", 389 + /// "node": "pve", 390 + /// "quorum_votes": "1" 391 + /// } 392 + /// ] 393 + /// } 394 + pub type Node { 395 + Node( 396 + id: Int, 397 + name: String, 398 + node: String, 399 + quorum_votes: Int, 400 + ring0_addr: String, 401 + ) 402 + } 403 + 404 + fn node_decoder() -> decode.Decoder(Node) { 405 + use id <- decode.field("nodeid", decode_more_int()) 406 + use name <- decode.field("name", decode.string) 407 + use node <- decode.field("node", decode.string) 408 + use quorum_votes <- decode.field("quorum_votes", decode_more_int()) 409 + use ring0_addr <- decode.field("ring0_addr", decode.string) 410 + decode.success(Node(id:, name:, node:, quorum_votes:, ring0_addr:)) 411 + } 412 + 413 + /// /cluster/config/nodes 414 + /// 415 + /// Required permissions 416 + /// `Sys.Audit` 417 + /// 418 + pub fn nodes( 419 + host host: String, 420 + credentials credentials: Credentials, 421 + ) -> request.Request(String) { 422 + base_request(host, credentials) 423 + |> request.set_method(http.Get) 424 + |> request.set_path(api_base <> "/cluster/config/nodes/") 425 + } 426 + 427 + pub fn nodes_response( 428 + response: response.Response(String), 429 + ) -> Result(List(Node), ApiError) { 430 + handle_response(response, fn(body) { 431 + json.parse(body, decode_data(decode.list(node_decoder()))) 432 + |> result.map_error(FailedToDecode) 433 + }) 434 + }