diff --git a/IntegrationGuide.md b/IntegrationGuide.md index cb6b0c8..9e8496d 100644 --- a/IntegrationGuide.md +++ b/IntegrationGuide.md @@ -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. diff --git a/api/openapi.yaml b/api/openapi.yaml index a5ff648..1be42aa 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 diff --git a/internal/controller/api.go b/internal/controller/api.go index 65d197f..32558e8 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -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) diff --git a/internal/controller/api_controller.go b/internal/controller/api_controller.go new file mode 100644 index 0000000..3ab065a --- /dev/null +++ b/internal/controller/api_controller.go @@ -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, + }) +} diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go index 8e400bd..3c43ab7 100644 --- a/internal/controller/api_vms.go +++ b/internal/controller/api_vms.go @@ -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 diff --git a/pkg/client/client.go b/pkg/client/client.go index 18457d3..c4d8e8e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -266,3 +266,9 @@ func (client *Client) ServiceAccounts() *ServiceAccountsService { client: client, } } + +func (client *Client) Controller() *ControllerService { + return &ControllerService{ + client: client, + } +} diff --git a/pkg/client/controller.go b/pkg/client/controller.go new file mode 100644 index 0000000..60757ac --- /dev/null +++ b/pkg/client/controller.go @@ -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 +} diff --git a/pkg/client/events.go b/pkg/client/events.go index 32ba3c1..1795ef5 100644 --- a/pkg/client/events.go +++ b/pkg/client/events.go @@ -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 } diff --git a/pkg/resource/v1/v1.go b/pkg/resource/v1/v1.go index ba89305..d4af881 100644 --- a/pkg/resource/v1/v1.go +++ b/pkg/resource/v1/v1.go @@ -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"` +}