Controller info endpoint and API integration examples (#75)

* Controller API: introduce controller's information endpoint

* Prevent generation of empty events after channel closure

* Allow events to be buffered in the events channel

* Controller API: introduce controller's information endpoint[1]

* IntegrationGuide.md: a couple of Python and Golang examples

* Rephrase a sentence

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
This commit is contained in:
Nikolay Edigaryev 2023-04-11 11:28:46 +04:00 committed by GitHub
parent 84633d0e45
commit 77656517fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 272 additions and 8 deletions

View File

@ -6,6 +6,179 @@ You can run `orchard dev` locally and navigate to http://127.0.0.1:6120/v1/ for
![](docs/orchard-api-documentation-browser.png)
## Using the API
Below you'll find examples of using Orchard API via vanilla Python's request library and Golang package that Orchard CLI build on top of.
### Authentication
When running in non-development mode, Orchard API expects a [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) to be provided for each API call.
Below you'll find two snippets that retrieve controller's information and output its version:
**Python**
```python
import requests
from requests.auth import HTTPBasicAuth
def main():
# Authentication
basic_auth = HTTPBasicAuth("service account name", "service account token")
response = requests.get("http://127.0.0.1:6120/v1/info", auth=basic_auth)
print(response.json()["version"])
if __name__ == '__main__':
main()
```
**Golang**
```go
package main
import (
"context"
"fmt"
"github.com/cirruslabs/orchard/pkg/client"
"log"
)
func main() {
client, err := client.New()
if err != nil {
log.Fatalf("failed to initialize Orchard API client: %v", err)
}
controllerInfo, err := client.Controller().Info(context.Background())
if err != nil {
log.Fatalf("failed to retrieve controller's information: %v", err)
}
fmt.Println(controllerInfo.Version)
}
```
Note that we don't provide any credentials for Golang's version of the snippet: this is because Orchard's Golang API client (`github.com/cirruslabs/orchard/pkg/client`) has the ability to read the current's user Orchard context automatically.
### Creating a VM
A more intricate example would be spinning off a VM with a startup script that outputs date, reading its logs and removing it from the controller:
**Python**
```python
import time
import uuid
import requests
from requests.auth import HTTPBasicAuth
def main():
vm_name = str(uuid.uuid4())
basic_auth = HTTPBasicAuth("service account name", "service account token")
# Create VM
response = requests.post("http://127.0.0.1:6120/v1/vms", auth=basic_auth, json={
"name": vm_name,
"image": "ghcr.io/cirruslabs/macos-ventura-base:latest",
"cpu": 4,
"memory": 4096,
"startup_script": {
"script_content": "date",
}
})
response.raise_for_status()
# Retrieve VM's logs
while True:
response = requests.get(f"http://127.0.0.1:6120/v1/vms/{vm_name}/events", auth=basic_auth)
response.raise_for_status()
result = response.json()
if isinstance(result, list) and len(result) != 0:
print(result[0]["payload"])
break
time.sleep(1)
# Delete VM
response = requests.delete(f"http://127.0.0.1:6120/v1/vms/{vm_name}", auth=basic_auth)
response.raise_for_status()
if __name__ == '__main__':
main()
```
**Golang**
```go
package main
import (
"context"
"fmt"
"github.com/cirruslabs/orchard/pkg/client"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/google/uuid"
"log"
"time"
)
func main() {
vmName := uuid.New().String()
client, err := client.New()
if err != nil {
log.Fatalf("failed to initialize Orchard API client: %v", err)
}
// Create VM
err = client.VMs().Create(context.Background(), &v1.VM{
Meta: v1.Meta{
Name: vmName,
},
Image: "ghcr.io/cirruslabs/macos-ventura-base:latest",
CPU: 4,
Memory: 4096,
StartupScript: &v1.VMScript{
ScriptContent: "date",
},
})
if err != nil {
log.Fatalf("failed to create VM: %v")
}
// Retrieve VM's logs
for {
vmLogs, err := client.VMs().Logs(context.Background(), vmName)
if err != nil {
log.Fatalf("failed to retrieve VM logs")
}
if len(vmLogs) != 0 {
fmt.Println(vmLogs[0])
break
}
time.Sleep(time.Second)
}
// Delete VM
if err := client.VMs().Delete(context.Background(), vmName); err != nil {
log.Fatalf("failed to delete VM: %v", err)
}
}
```
## Resource management
Some resources, such as `Worker` and `VM`, have a `resource` field which is a dictionary that maps between resource names and their amounts (amount requested or amount provided, depending on the resource) and is useful for scheduling.

View File

@ -4,6 +4,18 @@ info:
description: Orchard orchestration API
version: 0.1.0
paths:
/controller/info:
get:
summary: "Retrieve controller's information"
tags:
- controller
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#components/schemas/ControllerInfo'
/service-accounts:
post:
summary: "Create a Service Account"
@ -321,3 +333,13 @@ components:
type: array
items:
type: string
ControllerInfo:
title: Controller's Information
type: object
properties:
version:
type: string
description: Version number
commit:
type: string
description: Commit hash

View File

@ -30,7 +30,8 @@ func (controller *Controller) initAPI() *gin.Engine {
// Auth
v1.Use(controller.authenticateMiddleware)
// A way to for the clients to check that the API is working
// OpenAPI docs/spec (if enabled) and a way to for the clients
// to check that the API is working
v1.GET("/", func(c *gin.Context) {
if controller.enableSwaggerDocs {
middleware.SwaggerUI(middleware.SwaggerUIOpts{
@ -41,13 +42,17 @@ func (controller *Controller) initAPI() *gin.Engine {
c.Status(http.StatusOK)
}
})
if controller.enableSwaggerDocs {
v1.GET("/openapi.yaml", func(c *gin.Context) {
c.Data(200, "text/yaml", api.Spec)
})
}
// Controller information
v1.GET("/controller/info", func(c *gin.Context) {
controller.controllerInfo(c).Respond(c)
})
// Service accounts
v1.POST("/service-accounts", func(c *gin.Context) {
controller.createServiceAccount(c).Respond(c)

View File

@ -0,0 +1,22 @@
package controller
import (
"github.com/cirruslabs/orchard/internal/responder"
"github.com/cirruslabs/orchard/internal/version"
v1pkg "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/gin-gonic/gin"
"net/http"
)
func (controller *Controller) controllerInfo(ctx *gin.Context) responder.Responder {
// Only require the service account to be valid,
// no roles are needed to query this endpoint
if responder := controller.authorize(ctx); responder != nil {
return responder
}
return responder.JSON(http.StatusOK, &v1pkg.ControllerInfo{
Version: version.Version,
Commit: version.Commit,
})
}

View File

@ -161,6 +161,7 @@ func (controller *Controller) deleteVM(ctx *gin.Context) responder.Responder {
return responder.Code(http.StatusOK)
})
}
func (controller *Controller) appendVMEvents(ctx *gin.Context) responder.Responder {
if responder := controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite); responder != nil {
return responder

View File

@ -266,3 +266,9 @@ func (client *Client) ServiceAccounts() *ServiceAccountsService {
client: client,
}
}
func (client *Client) Controller() *ControllerService {
return &ControllerService{
client: client,
}
}

23
pkg/client/controller.go Normal file
View File

@ -0,0 +1,23 @@
package client
import (
"context"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"net/http"
)
type ControllerService struct {
client *Client
}
func (service *ControllerService) Info(ctx context.Context) (v1.ControllerInfo, error) {
var controllerInfo v1.ControllerInfo
err := service.client.request(ctx, http.MethodGet, "controller/info", nil, &controllerInfo,
nil)
if err != nil {
return controllerInfo, err
}
return controllerInfo, nil
}

View File

@ -20,7 +20,7 @@ func NewEventStreamer(client *Client, endpoint string) *EventStreamer {
streamer := &EventStreamer{
client: client,
endpoint: endpoint,
eventsChannel: make(chan v1.Event),
eventsChannel: make(chan v1.Event, 64),
}
go streamer.stream()
return streamer
@ -46,16 +46,23 @@ func (streamer *EventStreamer) stream() {
}
func (streamer *EventStreamer) readAvailableEvents() ([]v1.Event, bool) {
// blocking wait for at least one event
result := []v1.Event{<-streamer.eventsChannel}
var result []v1.Event
// blocking wait for at least one event
nextEvent, ok := <-streamer.eventsChannel
if !ok {
return result, true
}
result = append(result, nextEvent)
// non-blocking wait for more events, if any
for {
select {
case nextEvent, more := <-streamer.eventsChannel:
result = append(result, nextEvent)
if !more {
case nextEvent, ok := <-streamer.eventsChannel:
if !ok {
return result, true
}
result = append(result, nextEvent)
default:
return result, false
}

View File

@ -90,3 +90,8 @@ const (
// (either via API or from within a VM via `sudo shutdown -now`).
VMStatusStopped VMStatus = "stopped"
)
type ControllerInfo struct {
Version string `json:"version"`
Commit string `json:"commit"`
}