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:
parent
84633d0e45
commit
77656517fd
|
|
@ -6,6 +6,179 @@ You can run `orchard dev` locally and navigate to http://127.0.0.1:6120/v1/ for
|
|||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -266,3 +266,9 @@ func (client *Client) ServiceAccounts() *ServiceAccountsService {
|
|||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) Controller() *ControllerService {
|
||||
return &ControllerService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue