api-ovh
= api-ovh :toc: left :toc-title: Table of Contents :toclevels: 3 :source-highlighter: rouge :icons: font
Pure-Crystal client (stdlib only) for the https://api.ovh.com/console/[OVHcloud v1 API]. Typed modules for the day-to-day operator scope (SSH keys, dedicated servers
- install + rescue, reverse DNS, DNS zones, SMS) on top of a signed generic transport (
raw_get/raw_post/raw_put/raw_delete) that reaches any v1 endpoint.
[NOTE]
api-ovh is a sister project in the Aloli Crystal ecosystem alongside https://github.com/aloli-crystal/beryl[beryl] (agentless FreeBSD configuration) and https://github.com/aloli-crystal/silex[silex] (pure-Crystal FAT12 / cloud-init NoCloud writer). Zero external dependencies: only HTTP::Client, OpenSSL, JSON from the Crystal stdlib.
== Why api-ovh?
OVH's official clients exist in Python, Node, Go, PHP and Java, but not Crystal. Existing Crystal wrappers are abandoned or pull in third-party HTTP shards.
api-ovh computes the X-Ovh-Signature locally (SHA-1), uses stdlib only, and exposes an idiomatic Crystal API that is easy to test by injecting a fake HTTP transport.
== Features
- Pure Crystal: no runtime dependencies (stdlib
HTTP::Client,OpenSSL,JSON). - OVH signature: local
$1$ + SHA1(secret+ck+method+url+body+timestamp). - Clock offset:
/auth/timequeried once and cached for the process lifetime. - Multi-endpoint routing:
:eu(default),:ca,:us, Kimsufi, SoYouStart. - Typed modules:
ssh_keys,dedicated_servers(list / info / reinstall / tasks / boot+rescue /wait_for_task),ips(reverse DNS),domains(DNS zones),sms,auth(consumer key request, current credential, days until expiration). - Raw escape hatch:
client.raw_get,raw_post,raw_put,raw_deletereach any uncovered endpoint (/me/api/credential,/me/identity,/secret,/ssl,/cloud,/services, …) with the same signature and error handling. - Injectable transport: abstract
HttpTransport, trivial stub for specs. - Error taxonomy:
AuthenticationError,NotFound,RateLimited,ApiError(exposeserrorCode+http_status),TaskTimeout(with the last observedTask).
== Scope
[cols="1,3"] |=== | Layer | Coverage
| Typed modules | SSH keys (/me/sshKey), dedicated servers (/dedicated/server — list, info, reinstall, tasks, wait_for_task, boot, rescue, reboot, boot_from_disk, displayName), reverse DNS (/ip/{ip}/reverse), DNS zones (/domain/zone/*/record), SMS (/sms — services + send), auth helpers (/auth/credential, /auth/time, consumer-key request).
| Raw access (raw_*) | Every other v1 endpoint reachable via a signed call returning JSON::Any?. Examples: /me/api/credential (rotate consumer keys), /me/identity/{user,group} (IAM), /me/accessRestriction (MFA / IP allow-list), /secret (KMS), /ssl (certs), /services (asset inventory), /cloud/*, /order/cart, /dbaas/logs.
| Out of scope | OAuth 2.0 app flow (the API-Keys + consumer-key scheme remains OVH's official path on v1). |===
== Installation
In your shard.yml:
[source,yaml]
dependencies: api-ovh: github: aloli-crystal/api-ovh version: ~> 0.7
Then shards install.
== Quick start
[source,crystal]
require "api-ovh"
client = OvhApi::Client.new( application_key: ENV["OVH_APPLICATION_KEY"], application_secret: ENV["OVH_APPLICATION_SECRET"], consumer_key: ENV["OVH_CONSUMER_KEY"], endpoint: :eu, )
SSH keys
client.ssh_keys.list # => ["laptop", "desktop"] client.ssh_keys.create(name: "laptop", key: "ssh-ed25519 AAAA...")
Dedicated servers
servers = client.dedicated_servers.list # => ["ns1.ip-1-2-3.eu"] info = client.dedicated_servers.info(servers.first)
Reinstall Debian 12
task = client.dedicated_servers.reinstall( service_name: "ns1.ip-1-2-3.eu", template: "debian12_64", hostname: "web01.aloli.fr", ssh_key_name: "laptop", )
Poll until done (raises TaskTimeout if budget exceeded)
final = client.dedicated_servers.wait_for_task( service_name: "ns1.ip-1-2-3.eu", task_id: task.id, interval: 30.seconds, timeout: 1.hour, ) raise "install failed: #{final.comment}" if final.failed?
Reverse DNS
client.ips.set_reverse(ip: "192.0.2.10", reverse: "web01.aloli.fr.")
Full runnable example in link:examples/provision_debian.cr[examples/provision_debian.cr].
== OVH rescue orchestration
Since v0.2.1, api-ovh exposes the netboot / reboot endpoints plus a high-level helper prepare_rescue that chains three steps to swap a dedicated server into rescue mode with an SSH key injected:
. Find the UEFI-compatible rescue bootId via GET /dedicated/server/\*/boot then GET .../boot/\{id\}. . PUT /dedicated/server/\* with {"bootId": <rescueId>, "rescueSshKey": "<ssh-key-name>"} (one call — rescueSshKey is a field on the server object, not a separate option). . POST /dedicated/server/\*/reboot → Task.
[source,crystal]
The key must already exist in /me/sshKey.
client.ssh_keys.list.includes?("laptop") # => true
task = client.dedicated_servers.prepare_rescue( service_name: "ns1.ip-1-2-3.eu", ssh_key_name: "laptop", ) puts "Rescue task #{task.id} (#{task.status})"
client.dedicated_servers.wait_for_task( service_name: "ns1.ip-1-2-3.eu", task_id: task.id, interval: 15.seconds, )
The underlying calls are exposed individually as well (boots, boot, set_boot (with optional rescue_ssh_key), reboot) if you need a different orchestrator (e.g. switching to ipxeCustomerScript).
== Raw API access (escape hatch)
Anything outside the typed modules is reachable via the four signed helpers below. Caller navigates the resulting JSON::Any? directly. Same signing, same error taxonomy, same multi-endpoint routing.
[source,crystal]
A.5.17 (ISO 27001) — list active consumer keys for rotation
ids = client.raw_get("/me/api/credential", query: {"status" => "validated"}).not_nil!.as_a.map(&.as_i)
A.5.17 — revoke a compromised consumer key
client.raw_delete("/me/api/credential/#{ids.first}")
A.8.16 — full asset inventory
services = client.raw_get("/services").not_nil!.as_a
A.8.24 — listing SSL certificates
certs = client.raw_get("/ssl").not_nil!.as_a
Custom POST with a JSON body
client.raw_post("/secret/v1/secrets", body: {"name" => "db-password", "value" => "s3cr3t"})
Prefer a typed module (client.ssh_keys, client.dedicated_servers, …) when one exists: better ergonomics and stable typing across OVH schema changes. Use raw_* for the long tail.
== Getting OVH credentials
. Create an application at https://eu.api.ovh.com/createApp/[eu.api.ovh.com/createApp] → you get application_key and application_secret. . Generate a consumer key with the required scopes at https://eu.api.ovh.com/createToken/[eu.api.ovh.com/createToken]: + [source]
GET /me/sshKey POST /me/sshKey GET /dedicated/server GET /dedicated/server/* GET /dedicated/installationTemplate POST /dedicated/server//reinstall GET /dedicated/server//task GET /dedicated/server//task/ GET /ip//reverse POST /ip//reverse GET /dedicated/server//boot GET /dedicated/server//boot/* PUT /dedicated/server/*
POST /dedicated/server/*/reboot
. Validate the URL returned by OVH (SMS or OVH password manager). The consumer key becomes active.
[TIP]
Step 2 can be automated end-to-end with client.auth.request_consumer_key, which submits the access rules and returns the validation URL alongside the new consumer key. See the Endpoints::Auth module for details.
== Running against the real API
Specs use a FakeTransport (stdlib-only) with no network calls. For an integration run against the real OVH API:
[source,sh]
export OVH_APPLICATION_KEY=... export OVH_APPLICATION_SECRET=... export OVH_CONSUMER_KEY=... crystal run examples/provision_debian.cr
Without OVH_CONFIRM=yes, the example stops before launching the reinstall: it just lists keys, servers and templates.
== Status
v0.7 — typed modules stable for the operator scope (provisioning, rescue, reverse DNS, DNS zones, SMS, auth) ; everything else reachable via raw_*. Signing, clock sync and error handling are battle-tested on real OVH infrastructure.
- link:ARCHITECTURE.adoc[ARCHITECTURE.adoc] — signing, routing, error taxonomy.
- link:CHANGELOG.adoc[CHANGELOG.adoc] — changelog.
== License
MIT. See link:LICENSE[LICENSE].
api-ovh
- 0
- 0
- 0
- 1
- 1
- 12 days ago
- April 19, 2026
MIT License
Thu, 07 May 2026 16:40:03 GMT