OVH provides a HTTP(s) API to automatize various tasks. There are multiple official wrappers (Python, PHP, Java, etc.);Β I’ll use here the Go one (static typing, cross-compilation with a single static binary)
All the code presented below can be found in this github repository,
containing a small CLI tool, ovh-do
,
that can be used to access the API from the CLI and
sh(1) scripts.
Our end goal here is to automatically re-install the latest Debian image on an existing VPS, with a pre-installed ssh(1) key.
General idea
API access
We’ll leave authentication aside for now (this will be covered
in the next section. Almost
always, the access points will take and return JSON blobs.
Additionally, some of them will receive parameters from the
URL/route (e.g. <keyName>
is a parameter in
GET /me/sshKey/<keyName>
).
Note: To reduce confusion, will call (API)
access points a pair of a HTTP method and a route, e.g. GET /me
and POST /me
are two different access points.
The Go wrapper being rather thin, there’s a little
bit of extra-work to do because of the static typing, because it
doesn’t provide struct
to type those JSON blobs, so this need
to be done manually.
The procedure to use an access point is as follow:
- Identify the access points you need on the web console;
- When applicable, test them from the browser, to get an idea on what kind of JSON blob is sent/returned (there’s a formal type definition otherwise);
- Create a struct to wrap the input and/or output, when applicable:
for instance:
POST
requests have both;- but
GET
orDELETE
only have an output; - and that output often is void for
DELETE
;
- You can keep that struct partial; it seems reasonable to make the fields public (first capital letter), which requires to use json tags to perform the renaming;
- Finally, don’t forget to systematically look for errors along the way.
Example: Here’s a small example on how to access
GET /me/api/application/<applicationId>
, and how to retrieve
the HTTP error code in case of issues:
// https://api.ovh.com/console/#/me/api/application/%7BapplicationId%7D~GET
// TODO: ALPHA API
type GetMeApiApplicationId struct {
ApplicationId int `json:"applicationId"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
ApplicationKey string `json:"applicationKey"`
}
func test(c *ovh.Client, id string) error {
var x GetMeApiApplicationId
if err := c.Get("/me/api/application/"+strconv.Itoa(id), &x); err != nil {
serr, ok := err.(*ovh.APIError)
// application IDs referring to web console will 404;
// just silently ignore those
if !ok || serr.Code != http.StatusNotFound {
return err
}
} else {
fmt.Println(x)
}
}
Foreach
There’s a recurring pattern occurring when accessing lists
of objects: there is first an access point returning object IDs,
and then an access point returning the data associated with an
individual ID. Most of the time, those IDs are string
s,
but there are exceptions.
Besides, the route naming is (so far) systematic:
/foo/bar
: list IDs;/foo/bar/<baz>
: access meta-data of object with id<baz>
.
It can be convenient to wrap this pattern with a small forEach()
iterator, using Go generics:
// XXX generic experimentation; perhaps they are better approaches
// let's see where this goes.
//
// This is a bit clumsy so far, but works.
type Item interface {
GetMeApiApplicationId | GetVPSName | GetMeSSHKeyName | GetVPSNameImagesAvailableId
}
type ItemId interface{ string | int }
func id[T any](x T) T { return x }
func forEachItem[T Item, U ItemId](c *ovh.Client, r string,
f func(T) (bool, error), g func(U) string) error {
var xs []U
var y T
if err := c.Get(r, &xs); err != nil {
return err
}
for _, x := range xs {
if err := c.Get(r+"/"+g(x), &y); err != nil {
return err
}
stop, err := f(y)
if err != nil {
return err
}
if stop {
break
}
}
return nil
}
Example: The following lists on stdout all the available
applications, using both
GET /me/api/application
(alpha API) and
GET /me/api/application/<applicationId>
(alpha API)
// https://api.ovh.com/console/#/me/api/application~GET
// TODO: ALPHA API
type GetMeApiApplication []int
// https://api.ovh.com/console/#/me/api/application/%7BapplicationId%7D~GET
// Retrieve meta-data associated to an application ID
// TODO: ALPHA API
type GetMeApiApplicationId struct {
ApplicationId int `json:"applicationId"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
ApplicationKey string `json:"applicationKey"`
}
func lsApps(c *ovh.Client) error {
return forEachItem(c,
"/me/api/application",
func(y GetMeApiApplicationId) (bool, error) {
fmt.Printf("%s %d %s %s\n", y.Name, y.ApplicationId, y.Status, y.Description)
return false, nil
}, strconv.Itoa)
}
Authentication
Main process
Allowing an user to use an application that access the API is a two steps process, generating three “tokens”:
- Registering the application; this will generate:
- An application key;
- An application secret;
- Asking the user the authenticate on an URL; this will generate:
- A consumer key.
The consumer key can both expire and be restricted in terms
of access (e.g. only allowed to send GET
requests on /me
).
Note: Likely because you need an application to use the API, there is apparently no way to create an app directly from the API.
You can either generate all those three at once via either:
api.ovh.com/createToken/
;api.ovh.com/createToken/index.cgi?GET=/*&PUT=/*&POST=/*&DELETE=/*
, which will pre-set the request to use full access.
Alternatively, you can create the application key/secret first via
api.ovh.com/createApp
, and then use the API to generate the
consumer key: the wrapper’s README.md contains a
rather straightforward section on how to do so.
Consumer key update
While the wrapper can use a few different methods to
access the 3 authentication tokens, ovh-do
relies
on the presence of a $HOME/.ovh.conf
, which should contain all
those; if the consumer_key
is missing or expired/invalid, the
app will automatically request for a new one, with full access:
[default]
endpoint=ovh-eu
[ovh-eu]
application_key=aaaaaaaaaaaaaaaa
application_secret=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
consumer_key=cccccccccccccccccccccccccccccccc
The consumer_key
is retrieved by the method described in
the wrapper’s README.md; ovh-do
then pools until the key has been validated:
// https://api.ovh.com/console/#/me/~GET
// TODO: incomplete
type GetMe struct {
Email string `json:"email"`
Country string `json:"country"`
FirstName string `json:"firstname"`
}
// ...
func isValidated(c *ovh.Client) (bool, error) {
var y GetMe
if err := c.Get("/me", &y); err != nil {
serr, ok := err.(*ovh.APIError)
if !ok || serr.Code != http.StatusForbidden {
return false, err
}
return false, nil
}
return true, nil
}
func poolForValidated(c *ovh.Client) error {
a := time.Now().Add(poolTimeout)
for {
time.Sleep(5 * time.Second)
if time.Now().After(a) {
break
}
ok, err := isValidated(c)
if ok {
return nil
}
if err != nil {
return err
}
}
return fmt.Errorf("Waiting for credential validation timeout")
}
func requestNewKey(c *ovh.Client) (string, error) {
ck := c.NewCkRequest()
ck.AddRecursiveRules(ovh.ReadWrite, "/")
s, err := ck.Do()
if err != nil {
return "", err
}
// ...
if err = poolForValidated(c); err != nil {
return "", err
}
return c.ConsumerKey, nil
}
Note: the GET /me
access point is
used arbitrarily; any (available) authenticated access would do.
Finally, ovh-do
will edit $HOME/.ovh.conf
to use the new key. This is the reason why it depends on a
$HOME/.ovh.conf
instead of supporting the same options as
the Go wrapper.
Flushing expired credentials
If you choose to prefer to use short-lived credentials, you may find useful to have a way to regularly clean the list of expired credentials:
GET /me/api/credential
(alpha API);GET /me/api/credential/<credentialId>
(alpha API);DELETE /me/api/credential/<credentialId>
(alpha API).
// https://api.ovh.com/console/#/me/api/credential~GET
// TODO: ALPHA API
type GetMeApiCredential []int
// https://api.ovh.com/console/#/me/api/credential/%7BcredentialId%7D~GET
// TODO: ALPHA API
type GetMeApiCredentialIdRule struct {
Method string `json:"method"`
Path string `json:"path"`
}
type GetMeApiCredentialId struct {
// XXX type uncertain
AllowedIPs []string `json:"allowedIPs"`
// NOTE: 115/168 seem to correspond to the web console
ApplicationId int `json:"applicationId"`
Creation time.Time `json:"creation"`
CredentialId int `json:"credentialId"`
Expiration time.Time `json:"expiration"`
LastUse time.Time `json:"lastUse"`
OvhSupport bool `json:"ovhSupport"`
Status string `json:"status"`
Rules []GetMeApiCredentialIdRule `json:"rules"`
}
// https://api.ovh.com/console/#/me/api/credential/%7BcredentialId%7D~DELETE
// TODO: ALPHA API
type DeleteMeApiCredentialId struct {}
// ...
func flushExpiredCredentials(c *ovh.Client) error {
var xs GetMeApiCredential
var d GetMeApiCredentialId
var e DeleteMeApiCredentialId
if err := c.Get("/me/api/credential", &xs); err != nil {
return err
}
for _, x := range xs {
if err := c.Get("/me/api/credential/"+strconv.Itoa(x), &d); err != nil {
return err
}
if d.Status == "expired" {
if err := c.Delete("/me/api/credential/"+strconv.Itoa(x), &e); err != nil {
return err
}
}
}
return nil
}
SSH keys
The VPS reinstallation will be performed in the
next section
via POST /vps/<vps-id>/rebuild
(beta API).
This access has an optional sshKey
parameter, which can be used
automatically register a SSH key in the default user’s
$HOME/.ssh/authorized_keys
; on a fresh Debian install,
this user is called debian
.
That parameter doesn’t contain the key itself as a string, but refers
to one of the keys registered via POST /me/sshKey
.
Exercice: Try to implement a way to list all the available
keys using the forEachItem()
iterator presented
earlier. SSH keys related access points are:
GET /me/sshKey
;POST /me/sshKey
;DELETE /me/sshKey/<keyName>
;GET /me/sshKey/<keyName>
;PUT /me/sshKey/<keyName>
.
Feel free to peek into the code for a solution.
Note: ovh-do
will automatically
look for SSH keys in $PATH/.ssh/
so that ovh-do add-key
should
by default almost always do the Right Thingβ’.
VPSs
Listing VPSs and available images
As for the SSH keys, this is covered by our forEachItem()
iterator.
The access points of interests are:
GET /vps
;GET /vps/<serviceName>
;GET /vps/<serviceName>/ips
;GET /vps/<serviceName>/datacenter
;GET /vps/<serviceName>/images/available
;GET /vps/<serviceName>/images/available/<id>
.
Note: ovh-do
contain a few bits
to help automatically find the latest image matching a given name/regexp,
and some more bits to automatically target the latest Debian.
Rebuilding a VPS
ovh-do
will perform re-installation of a
VPS using stock images in three steps:
- Launch the rebuild;
- Pool the related task until completion (or timeout);
- Reset
known_hosts
entries associated to the VPS’s IPs.
Related routes:
Here’s a sample trace of re-installing the latest debian on the given VPS:
% ovh-do rebuild-debian vps-00ac1791.vps.ovh.net
2022/08/25 00:55:16 Installing Debian 11 (62129106-e97f-4ad2-b356-e9854f86a990) to vps-00ac1791.vps.ovh.net
44%
44%
44%
66%
2022/08/25 01:11:06 Warning: ssh-keyscan '2001:4:701:::5555' failed:
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
$ ssh -p 22 debian@10.10.4.1 echo hello, world
hello, world
Comments
By email, at mathieu.bivert chez: