Infrastructure

Hetzner Cloud

Make, power and remove Hetzner Cloud servers, volumes and firewalls, wait on each action and the ones it starts next, read pages and rate-limit headers, and try again when a resource is locked or the limit is reached.

Version 1.0.0 · 20 functions · 2 crates

#cloud#servers#firewall#api

Functions

What use hetzner.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use hetzner_rate, hetzner_request from hetzner (Quillo 0.6).

hetzner_rate() → any

What the last answer said of the rate limit: { limit, remaining, reset }.

hetzner_request(method: str, path: str, body: any, options: any) → anyor an error

Any call: method, path like /datacenters, body or null. Gives the JSON answer.

hetzner_all(path: str, key: str, options: any) → listor an error

Every item of a list under key, by meta.pagination.next_page.

hetzner_action_wait(id: int, options: any) → anyor an error

Waits for an action: a success gives it, an error is an error value with the action's own code and message. Options: wait_ms (5 min), every_ms (1 s).

hetzner_servers(options: any) → listor an error

All servers. Options: name, label_selector (like env=prod), status.

hetzner_server(id: int, options: any) → anyor an error

One server.

hetzner_server_create(spec: any, options: any) → anyor an error

Makes a server: spec is { name, server_type, image, location, ssh_keys, labels, user_data, ... }. Gives { server, root_password, actions }: with the option wait, after its action and those it starts next, and with the server read again. Password is null when ssh_keys were given.

hetzner_server_action(id: int, action: str, args: any, options: any) → anyor an error

Runs a server action: poweron, poweroff, shutdown, reboot, reset, rebuild, change_type..., args being its fields or null. With the option wait, waits for it. Gives the action.

hetzner_server_delete(id: int, options: any) → boolor an error

Removes a server. With the option wait, waits until it has gone.

hetzner_volumes(options: any) → listor an error

All volumes. Options: name, label_selector, status.

hetzner_volume_create(spec: any, options: any) → anyor an error

Makes a volume: { name, size (GB, 10 at least), location or server, format, automount, labels }. With the option wait, waits for it. Gives the volume.

hetzner_volume_attach(id: int, server: int, options: any) → anyor an error

Attaches a volume to a server. Option automount; with the option wait, waits. Gives the action.

hetzner_volume_detach(id: int, options: any) → anyor an error

Detaches a volume.

hetzner_volume_resize(id: int, size: int, options: any) → anyor an error

Grows a volume to size GB; it cannot shrink.

hetzner_volume_delete(id: int, options: any) → boolor an error

Removes a detached volume.

hetzner_firewalls(options: any) → listor an error

All firewalls. Options: name, label_selector.

hetzner_firewall_create(spec: any, options: any) → anyor an error

Makes a firewall: { name, rules: [{ direction, protocol, port, source_ips or destination_ips, description }], apply_to, labels }. Rules are checked here first. With the option wait, waits. Gives the firewall.

hetzner_firewall_set_rules(id: int, rules: any, options: any) → intor an error

Replaces a firewall's rules, all at once. With the option wait, waits.

hetzner_firewall_apply(id: int, targets: any, options: any) → intor an error

Applies a firewall to targets: server ids, or { type: "label_selector", label_selector: { selector } } maps.

hetzner_firewall_delete(id: int, options: any) → boolor an error

Removes a firewall no longer applied.

Get it

$ quillo install hetzner               # hetzner.qwer into this folder$ quillo install hetzner --example     # and its example, hetzner.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/hetzner.qwer$ curl -O https://quillolang.org/plugins/files/hetzner.qwe

Then, in your program, all of it or only what you call:

main.qwe
use hetzner.qweruse hetzner_rate, hetzner_request from hetzner      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"serde_json = { version = "1", features = ["preserve_order"] }

Example

hetzner.qwe
// Servers on Hetzner Cloud: one made and waited for through every action it starts, powered off,// a volume made, attached and grown, a firewall with its rules applied, lists read past a page, and// the rate-limit headers.////   HETZNER_API_TOKEN=... quillo run hetzner.qwe      then open http://localhost:8080/servers, /volumes, /firewall and /failures//// These routes spend money on a real project. HETZNER_API_URL reaches a test server.use "hetzner.qwer"let WAIT = { wait: true, every_ms: 50 }route GET /servers {    let made = hetzner_server_create({ name: "web-1", server_type: "cx22", image: "ubuntu-24.04", location: "fsn1", labels: { env: "prod" } }, WAIT)?    let off = hetzner_server_action(made.server.id, "poweroff", null, WAIT)?    let all = hetzner_servers({ per_page: 2 })?    let prod = hetzner_servers({ label_selector: "env=prod" })?    {        status: made.server.status,        ip: made.server.public_net.ipv4.ip,        password: made.root_password != null,        waited: made.actions,        off: off.status,        total: len(all),        prod: map(prod, (s) => s.name),        rate: hetzner_rate().limit,        removed: hetzner_server_delete(made.server.id, WAIT)?,    }}route GET /volumes {    let v = hetzner_volume_create({ name: "data", size: 20, location: "fsn1", format: "ext4", automount: false }, WAIT)?    let attached = hetzner_volume_attach(v.id, 1, { ...WAIT, automount: true })?    let grown = hetzner_volume_resize(v.id, 40, WAIT)?    let busy = hetzner_volume_delete(v.id, {})    hetzner_volume_detach(v.id, WAIT)?    { attach: attached.command, resize: grown.status, busy: busy.error, size: filter(hetzner_volumes({})?, (x) => x.id == v.id)[0].size, deleted: hetzner_volume_delete(v.id, {})?, small: hetzner_volume_create({ name: "x", size: 5, location: "fsn1" }, {}).error }}route GET /firewall {    let rules = [{ direction: "in", protocol: "tcp", port: "443", source_ips: ["0.0.0.0/0", "::/0"], description: "https" }]    let fw = hetzner_firewall_create({ name: "web", rules }, WAIT)?    let set = hetzner_firewall_set_rules(fw.id, [...rules, { direction: "in", protocol: "icmp", source_ips: ["0.0.0.0/0"] }], WAIT)?    let applied = hetzner_firewall_apply(fw.id, [1, { type: "label_selector", label_selector: { selector: "env=prod" } }], WAIT)?    {        rules: len(hetzner_firewalls({ name: "web" })?[0].rules),        set: set,        applied: applied,        no_port: hetzner_firewall_create({ name: "x", rules: [{ direction: "in", protocol: "tcp", source_ips: ["0.0.0.0/0"] }] }, {}).error,        no_ips: hetzner_firewall_set_rules(fw.id, [{ direction: "out", protocol: "icmp" }], {}).error,    }}route GET /failures = {    missing: hetzner_server(987654, {}).error,    invalid: hetzner_server_create({ name: "bad name!", server_type: "cx22", image: "x" }, {}).error,    failed_action: hetzner_server_action(1, "rebuild", { image: "gone" }, WAIT).error,    locked_then_fine: hetzner_server_action(2, "reboot", null, WAIT)?.status,    limited_then_fine: len(hetzner_request("GET", "/datacenters", null, {})?.datacenters),}
$ quillo run hetzner.qwe$ curl 'localhost:8080/servers'$ curl 'localhost:8080/volumes'$ curl 'localhost:8080/firewall'$ curl 'localhost:8080/failures'

It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.

Source

hetzner.qwer
//! Hetzner Cloud's API: servers made, powered and removed, volumes made, attached and resized, firewalls and their rules, every action waited on including those a call starts next, pages followed, and rate-limit headers read and waited out.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Value};use std::sync::Mutex;use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};static RATE: Mutex<Value> = Mutex::new(Value::Null);fn enc(s: &str) -> String {    s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}/// One call. 429 waits for RateLimit-Reset (at most option max_wait_s, 10), `locked` (a resource busy with another action) is tried again, and 5xx on a GET. Errors are `Hetzner 404 not_found: ...`, with field names from `details`.fn call(method: &str, path: &str, body: Option<&Value>, o: &Value) -> Result<Value, String> {    let token = std::env::var("HETZNER_API_TOKEN").ok().filter(|v| !v.is_empty()).ok_or("no token: set HETZNER_API_TOKEN")?;    let base = std::env::var("HETZNER_API_URL").ok().filter(|v| !v.is_empty()).unwrap_or("https://api.hetzner.cloud/v1".into());    let url = format!("{}{}", base.trim_end_matches('/'), path);    let agent = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(10)).timeout_read(Duration::from_secs(60)).build();    let most = o.get("max_wait_s").and_then(|v| v.as_u64()).unwrap_or(10);    let mut tries = 0u64;    loop {        tries += 1;        let r = agent.request(method, &url).set("Authorization", &format!("Bearer {}", token));        let res = match body {            Some(b) => r.set("Content-Type", "application/json").send_string(&b.to_string()),            None => r.call(),        };        let resp = match res {            Ok(x) => x,            Err(ureq::Error::Status(_, x)) => x,            Err(e) => return Err(format!("cannot reach Hetzner: {}", e)),        };        let status = resp.status();        let num = |n: &str| resp.header(n).and_then(|h| h.trim().parse::<i64>().ok());        let (limit, left, reset) = (num("ratelimit-limit"), num("ratelimit-remaining"), num("ratelimit-reset"));        if limit.is_some() {            if let Ok(mut g) = RATE.lock() {                *g = json!({ "limit": limit, "remaining": left, "reset": reset });            }        }        let text = resp.into_string().map_err(|e| e.to_string())?;        if status < 300 {            if text.trim().is_empty() {                return Ok(Value::Null);            }            return serde_json::from_str(&text).map_err(|e| format!("Hetzner: not JSON: {}", e));        }        let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);        let e = &v["error"];        let code = e["code"].as_str().unwrap_or("error");        if tries < 6 {            let wait = if status == 429 {                let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);                Some(reset.map(|r| (r - now).max(1) as u64).unwrap_or(1))            } else if code == "locked" || (status >= 500 && method == "GET") {                Some(tries)            } else {                None            };            if let Some(w) = wait.filter(|w| *w <= most) {                std::thread::sleep(Duration::from_secs(w));                continue;            }        }        let fields: Vec<String> = e.pointer("/details/fields").and_then(|f| f.as_array()).into_iter().flatten().map(|f| format!("{}: {}", f["name"].as_str().unwrap_or("?"), f["messages"].as_array().map(|m| m.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default())).collect();        let more = if fields.is_empty() { String::new() } else { format!(" ({})", fields.join("; ")) };        return Err(format!("Hetzner {} {}: {}{}", status, code, e["message"].as_str().map(str::to_string).unwrap_or_else(|| text.chars().take(160).collect()), more));    }}/// What the last answer said of the rate limit: { limit, remaining, reset }.pub fn hetzner_rate() -> serde_json::Value {    RATE.lock().map(|g| g.clone()).unwrap_or(Value::Null)}/// Any call: `method`, `path` like /datacenters, `body` or null. Gives the JSON answer.pub fn hetzner_request(method: &str, path: &str, body: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    call(&method.to_uppercase(), path, if body.is_null() { None } else { Some(&body) }, &options)}/// Every item of a list under `key`, by `meta.pagination.next_page`.pub fn hetzner_all(path: &str, key: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    let per = options.get("per_page").and_then(|v| v.as_u64()).unwrap_or(50);    let sep = if path.contains('?') { "&" } else { "?" };    let mut out = vec![];    let mut page = 1;    loop {        let v = call("GET", &format!("{}{}page={}&per_page={}", path, sep, page, per), None, &options)?;        out.extend(v[key].as_array().cloned().unwrap_or_default());        match v.pointer("/meta/pagination/next_page").and_then(|n| n.as_u64()) {            Some(n) => page = n,            None => return Ok(out),        }    }}/// Waits for an action: a `success` gives it, an `error` is an error value with the action's own code and message. Options: wait_ms (5 min), every_ms (1 s).pub fn hetzner_action_wait(id: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    let limit = Duration::from_millis(options.get("wait_ms").and_then(|v| v.as_u64()).unwrap_or(300_000));    let every = Duration::from_millis(options.get("every_ms").and_then(|v| v.as_u64()).unwrap_or(1_000));    let start = Instant::now();    loop {        let a = call("GET", &format!("/actions/{}", id), None, &options)?["action"].clone();        match a["status"].as_str() {            Some("success") => return Ok(a),            Some("error") => return Err(format!("action {} ({}) failed: {}: {}", id, a["command"].as_str().unwrap_or("?"), a["error"]["code"].as_str().unwrap_or("?"), a["error"]["message"].as_str().unwrap_or("?"))),            _ => {}        }        if start.elapsed() > limit {            return Err(format!("action {} is still at {}% after {} ms", id, a["progress"], limit.as_millis()));        }        std::thread::sleep(every);    }}fn wait_all(r: &Value, o: &Value) -> Result<i64, String> {    let mut n = 0;    let singles = r.get("action").into_iter();    let lists = ["actions", "next_actions"].into_iter().flat_map(|k| r.get(k).and_then(|l| l.as_array()).into_iter().flatten());    for a in singles.chain(lists) {        if a.is_object() {            hetzner_action_wait(a["id"].as_i64().unwrap_or(0), o.clone())?;            n += 1;        }    }    Ok(n)}fn wants_wait(o: &Value) -> bool {    o.get("wait") == Some(&Value::Bool(true))}fn filters(o: &Value, keys: &[&str]) -> String {    let q: Vec<String> = keys.iter().filter_map(|k| o.get(*k).and_then(|v| v.as_str()).map(|v| format!("{}={}", k, enc(v)))).collect();    if q.is_empty() { String::new() } else { format!("?{}", q.join("&")) }}/// All servers. Options: name, label_selector (like env=prod), status.pub fn hetzner_servers(options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    hetzner_all(&format!("/servers{}", filters(&options, &["name", "label_selector", "status"])), "servers", options)}/// One server.pub fn hetzner_server(id: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    Ok(call("GET", &format!("/servers/{}", id), None, &options)?["server"].clone())}/// Makes a server: `spec` is { name, server_type, image, location, ssh_keys, labels, user_data, ... }. Gives { server, root_password, actions }: with the option wait, after its action and those it starts next, and with the server read again. Password is null when ssh_keys were given.pub fn hetzner_server_create(spec: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    for k in ["name", "server_type", "image"] {        if spec.get(k).is_none() {            return Err(format!("the server needs `{}`", k));        }    }    let r = call("POST", "/servers", Some(&spec), &options)?;    if wants_wait(&options) {        let n = wait_all(&r, &options)?;        let s = hetzner_server(r["server"]["id"].as_i64().unwrap_or(0), options)?;        return Ok(json!({ "server": s, "root_password": r["root_password"], "actions": n }));    }    Ok(json!({ "server": r["server"], "root_password": r["root_password"], "actions": 0 }))}/// Runs a server action: poweron, poweroff, shutdown, reboot, reset, rebuild, change_type..., `args` being its fields or null. With the option wait, waits for it. Gives the action.pub fn hetzner_server_action(id: i64, action: &str, args: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    let body = if args.is_object() { args } else { json!({}) };    let r = call("POST", &format!("/servers/{}/actions/{}", id, action), Some(&body), &options)?;    if wants_wait(&options) {        return hetzner_action_wait(r["action"]["id"].as_i64().unwrap_or(0), options);    }    Ok(r["action"].clone())}/// Removes a server. With the option wait, waits until it has gone.pub fn hetzner_server_delete(id: i64, options: serde_json::Value) -> Result<bool, String> {    let r = call("DELETE", &format!("/servers/{}", id), None, &options)?;    if wants_wait(&options) {        wait_all(&r, &options)?;    }    Ok(true)}/// All volumes. Options: name, label_selector, status.pub fn hetzner_volumes(options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    hetzner_all(&format!("/volumes{}", filters(&options, &["name", "label_selector", "status"])), "volumes", options)}/// Makes a volume: { name, size (GB, 10 at least), location or server, format, automount, labels }. With the option wait, waits for it. Gives the volume.pub fn hetzner_volume_create(spec: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    if spec.get("size").and_then(|s| s.as_i64()).is_none_or(|s| s < 10) {        return Err("a Hetzner volume is 10 GB or more".into());    }    if spec.get("location").is_none() && spec.get("server").is_none() {        return Err("a volume needs `location` or `server`".into());    }    let r = call("POST", "/volumes", Some(&spec), &options)?;    if wants_wait(&options) {        wait_all(&r, &options)?;    }    Ok(r["volume"].clone())}/// Attaches a volume to a server. Option automount; with the option wait, waits. Gives the action.pub fn hetzner_volume_attach(id: i64, server: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    let mut b = json!({ "server": server });    if let Some(a) = options.get("automount") {        b["automount"] = a.clone();    }    let r = call("POST", &format!("/volumes/{}/actions/attach", id), Some(&b), &options)?;    if wants_wait(&options) {        return hetzner_action_wait(r["action"]["id"].as_i64().unwrap_or(0), options);    }    Ok(r["action"].clone())}/// Detaches a volume.pub fn hetzner_volume_detach(id: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    let r = call("POST", &format!("/volumes/{}/actions/detach", id), Some(&json!({})), &options)?;    if wants_wait(&options) {        return hetzner_action_wait(r["action"]["id"].as_i64().unwrap_or(0), options);    }    Ok(r["action"].clone())}/// Grows a volume to `size` GB; it cannot shrink.pub fn hetzner_volume_resize(id: i64, size: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    let r = call("POST", &format!("/volumes/{}/actions/resize", id), Some(&json!({ "size": size })), &options)?;    if wants_wait(&options) {        return hetzner_action_wait(r["action"]["id"].as_i64().unwrap_or(0), options);    }    Ok(r["action"].clone())}/// Removes a detached volume.pub fn hetzner_volume_delete(id: i64, options: serde_json::Value) -> Result<bool, String> {    call("DELETE", &format!("/volumes/{}", id), None, &options)?;    Ok(true)}/// All firewalls. Options: name, label_selector.pub fn hetzner_firewalls(options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    hetzner_all(&format!("/firewalls{}", filters(&options, &["name", "label_selector"])), "firewalls", options)}fn check_rules(rules: &Value) -> Result<(), String> {    for r in rules.as_array().ok_or("rules is a list")? {        let dir = r["direction"].as_str().unwrap_or("");        let proto = r["protocol"].as_str().unwrap_or("");        if dir != "in" && dir != "out" {            return Err(format!("a rule's direction is in or out, not `{}`", dir));        }        if !["tcp", "udp", "icmp", "esp", "gre"].contains(&proto) {            return Err(format!("`{}` is not a protocol Hetzner firewalls know", proto));        }        if matches!(proto, "tcp" | "udp") && r.get("port").is_none() {            return Err(format!("a {} rule needs `port`", proto));        }        let ips = if dir == "in" { "source_ips" } else { "destination_ips" };        if r[ips].as_array().is_none_or(|l| l.is_empty()) {            return Err(format!("an `{}` rule needs `{}`", dir, ips));        }    }    Ok(())}/// Makes a firewall: { name, rules: [{ direction, protocol, port, source_ips or destination_ips, description }], apply_to, labels }. Rules are checked here first. With the option wait, waits. Gives the firewall.pub fn hetzner_firewall_create(spec: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    if let Some(r) = spec.get("rules") {        check_rules(r)?;    }    let r = call("POST", "/firewalls", Some(&spec), &options)?;    if wants_wait(&options) {        wait_all(&r, &options)?;    }    Ok(r["firewall"].clone())}/// Replaces a firewall's rules, all at once. With the option wait, waits.pub fn hetzner_firewall_set_rules(id: i64, rules: serde_json::Value, options: serde_json::Value) -> Result<i64, String> {    check_rules(&rules)?;    let r = call("POST", &format!("/firewalls/{}/actions/set_rules", id), Some(&json!({ "rules": rules })), &options)?;    if wants_wait(&options) {        return wait_all(&r, &options);    }    Ok(r["actions"].as_array().map_or(0, |a| a.len() as i64))}/// Applies a firewall to `targets`: server ids, or { type: "label_selector", label_selector: { selector } } maps.pub fn hetzner_firewall_apply(id: i64, targets: serde_json::Value, options: serde_json::Value) -> Result<i64, String> {    let list: Vec<Value> = targets.as_array().ok_or("targets is a list")?.iter().map(|t| if t.is_i64() { json!({ "type": "server", "server": { "id": t } }) } else { t.clone() }).collect();    let r = call("POST", &format!("/firewalls/{}/actions/apply_to_resources", id), Some(&json!({ "apply_to": list })), &options)?;    if wants_wait(&options) {        return wait_all(&r, &options);    }    Ok(r["actions"].as_array().map_or(0, |a| a.len() as i64))}/// Removes a firewall no longer applied.pub fn hetzner_firewall_delete(id: i64, options: serde_json::Value) -> Result<bool, String> {    call("DELETE", &format!("/firewalls/{}", id), None, &options)?;    Ok(true)}
DigitalOcean digitalocean.qwer · 1.0.0Make, list and destroy droplets, wait on their actions, keep domains and DNS records and Spaces keys, with every list paged by its links and rate limits waited out by their headers.cloudserversdnsapi3 views · 9 downloadsAmazon DynamoDB aws_dynamodb.qwer · 1.0.0Get, put, update, delete and query DynamoDB with plain maps turned into attribute values and back, pages followed, batch writes with UnprocessedItems sent again, throttling retried and each answer's CRC32 checked, signed with Signature V4.awsdatabaseapisigning2 views · 7 downloadsAWS Lambda aws_lambda.qwer · 1.0.0Invoke Lambda functions and wait, or queue them to run later, with function errors read from their header and the log tail's REPORT line turned into numbers, throttling waited out as Retry-After says, and functions listed and waited on, signed with Signature V4.awsserverlessapisigning3 views · 8 downloadsGrafana grafana.qwer · 1.0.0Save, find and remove Grafana dashboards and folders, mark deploys with annotations, run data-source queries with their frames read as rows, keep alert rules, and make service accounts and tokens, with version conflicts and a query's own failure turned into clear errors.monitoringdashboardsalertsapi3 views · 7 downloadsGrafana Loki loki.qwer · 1.0.0Push log lines to Loki with labels and nanosecond times kept as text, run LogQL range and instant queries with streams flattened into one sorted list and metric results as series, and read long ranges past the limit without losing or doubling a line.loggingmonitoringapitime series4 views · 7 downloads
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

On Hetzner Cloud: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.