Navigating Koyeb's Golang SDK
- 7 minutes read - 1307 wordsAckal deploys gRPC Health Checking clients in locations around the World in order to health check services that are representative of customer need.
Koyeb offers multiple locations and I spent time today writing a client for Ackal to integrate with Koyeb using the Golang client for the Koyeb API.
The SDK is generated from Koyeb’s OpenAPI (nee Swagger) endpoint using openapi-generator-cli
. This is a smart, programmatic solution to ensuring that the SDK always matches the API definition but I found the result is idiosyncratic and therefore a little gnarly.
One aspect of programming with the SDK that is challenging is that the Koyeb API service implements extensive validation but this validation does not appear to be present in the OpenAPI definition and therefore not surfaced through the SDK nor its documentation.
For example, here are some of the validation errors that I discovered in my attempt to create a Koyeb Service:
- source type is required (
definition.source
) - min and max need to be equal (
definition.scalings.0.max
) - cannot be blank (
definition.scalings.0.min
) - at least one scaling configuration is required (
definition.scalings
) - must be a valid value (
definition.ports.0.protocol
) - only http and http2 ports can be exposed publicly (
definition.routes.0.port
) - must be no greater than 10 (
definition.health_checks.0.restart_limit
) - rpc error: code = NotFound desc = not_found (
definition.instance_types.0.scopes.was
) - rpc error: code = NotFound desc = not_found (
definition.instance_types.0.scopes.WAS
) - rpc error: code = NotFound desc = not_found (
definition.regions.0
)
In all cases, the error message is helpful, defines the field that’s in error and an explanation that helped me diagnose my issue but the error only occurred in the API response (i.e. after making the call).
pkgsite provides an easy way to browse the Koyeb SDK’s documentation and, although the Go-generated documentation is (as always) excellent, Koyeb provides additional Documentation for API Endpoints.
So, for example, here’s a code sample for CreateService
that is richer than the method documentation for CreateService
.
I have a local Koyeb configuration in ~/.koyeb.yaml
that includes an API token value. For ease using both Koyeb’s CLI and this SDK, I access the token
through an environment variable:
export TOKEN=$(more ~/.koyeb.yaml | yq .token)
And then authenticate the SDK with:
cfg := koyeb.NewConfiguration()
client := koyeb.NewAPIClient(cfg)
token := os.Getenv("TOKEN")
ctx := context.WithValue(
context.TODO(),
koyeb.ContextAccessToken,
token,
)
In order to create a Koyeb Service, we first must create an App.
NOTE Creating a Service will create a Deployment
There are multiple ways to create Koyeb resources using the SDK.
My preference is top-down by creating the struct literals but the SDK presents a complication because it uses *string
rather than string
types:
name := "foo"
apiCreateApp := koyeb.CreateApp{
Name: &name,
}
apiCreateAppReply, resp, err := client.AppsApi.CreateApp(ctx).App(apiCreateApp).Execute()
The SDK generator converts JSON string fields to *string
in Go to provide nil
(omitted), ""
(empty) and other but this complicates writing code. A common alternative to create *string
is:
name := new(string)
*name = "foo"
Another way to create Koyeb resource is to use generated methods but this requires a bottom-up approach which I find more confusing to read:
apiCreateApp := koyeb.NewCreateApp()
apiCreateApp.SetName(name)
apiCreateAppReply, resp, err := c.client.AppsApi.CreateApp(ctx).App(*apiCreateApp).Execute()
The Execute
methods return triples: *Reply
, resp
(*http.Response
), error
.
In this case the *Reply
type is *CreateAppReply
which includes App
which is *App
which includes the App’s ID.
resp
represents the raw *http.Response
which seems (!?) redundant but may be useful in debugging.
The error
(if any) is of type *GenericOpenAPIError
and this can be type asserted into a more useful ErrorWithFields
. This includes Status
, Code
, Message
and Fields
. The latter containing a list of fields (in the request) that contained errors detailing the field name and an explanation of the problem. This was very useful when debugging (see below):
if e, ok := err.(*koyeb.GenericOpenAPIError); ok {
if e, ok := e.Model().(koyeb.ErrorWithFields); ok {
slog.Info("Error", "e", e)
}
}
We’ll need the App’s ID to create the associated Service. Once again, there are different ways to do this:
appID := apiCreateAppReply.App.Id
appID := apiCreateAppReply.GetApp().Id
And, in a similar way to creating the App, we can now define and create the Service. I won’t labor the following code but provide it in its entirety with comments:
// String constants are defined for e.g. Web Service
ddType := koyeb.DEPLOYMENTDEFINITIONTYPE_WEB
// A public general-purpose container image
// https://github.com/kubernetes-up-and-running/kuard
image := "gcr.io/kuar-demo/kuard-amd64:blue"
// The container provides an HTTP endpoint on port 8080
protocol := "http"
path := "/"
port := int64(8080)
// Example environment key:value
key := "key"
value := "value"
// The Koyeb Region
// See "Regions" below
region := "was"
// Scopes
// Shared by DeploymentScaling, DeploymentInstanceType
// Must be of the form "region:{region}"
scope := fmt.Sprintf("region:%s", region)
// Scaling
min := int64(1)
max := int64(1)
// The Koyeb Instance Type
// See "Instances" below
iType := "free"
// Healthcheck
gracePeriod := int64(60)
interval := int64(60)
restartLimit := int64(10) // Must be no greater than 10
timeout := int64(30)
hcPath := "/healthy"
hcMethod := "GET"
hchKey := "key"
hchValue := "value"
apiCreateService := koyeb.CreateService{
AppId: appID,
Definition: &koyeb.DeploymentDefinition{
Name: &name,
Type: &ddType,
Routes: []koyeb.DeploymentRoute{
{
Port: &port,
Path: &path,
},
},
Ports: []koyeb.DeploymentPort{
{
Port: &port,
Protocol: &protocol,
},
},
Env: []koyeb.DeploymentEnv{
{
Key: &key,
Value: &value,
},
},
Regions: []string{
region,
},
Scalings: []koyeb.DeploymentScaling{
{
Scopes: []string{
scope,
},
Min: &min,
Max: &max,
},
},
InstanceTypes: []koyeb.DeploymentInstanceType{
{
Scopes: []string{
scope,
},
Type: &iType,
},
},
HealthChecks: []koyeb.DeploymentHealthCheck{
{
GracePeriod: &gracePeriod,
Interval: &interval,
RestartLimit: &restartLimit,
Timeout: &timeout,
Http: &koyeb.HTTPHealthCheck{
Port: &port,
Path: &hcPath,
Method: &hcMethod,
Headers: []koyeb.HTTPHeader{
{
Key: &hchKey,
Value: &hchValue,
},
},
},
},
},
Docker: &koyeb.DockerSource{
Image: &image,
Command: nil,
Args: []string{},
Entrypoint: nil,
},
},
}
apiCreateServiceReply, _, err := client.ServicesApi.CreateService(ctx).Service(apiCreateService).Execute()
if e, ok := err.(*koyeb.GenericOpenAPIError); ok {
if e, ok := e.Model().(koyeb.ErrorWithFields); ok {
slog.Info("Unable to create Service", "e", e)
}
}
serviceID := apiCreateServiceReply.Service.Id
slog.Info("Success", "Service.ID", serviceID)
Here’s an example of an ErrorWithFields
message that will result from the above if e.g.
restartLimit := int64(20)
I’m using slog
’s JSONHandler to format the log output as JSON:
{
"time": "2024-01-05T00:00:00.000000000-08:00",
"level": "INFO",
"msg": "Unable to create Service",
"e": {
"code": "invalid_argument",
"fields": [{
"description": "must be no greater than 10",
"field": "definition.health_checks.0.restart_limit"
}],
"message": "Validation error",
"status": 400
}
}
The field
helps path down through the structure to see which field specifically is in error and the message
explains why.
A challenge with Koyeb’s Web-based UI console is that it doesn’t surface e.g. IDs of Apps, Services, Deployments etc. As you saw above, when referencing Koyeb resources, you do so using these IDs. In order to delete the App and the Service created in the code, we can of course use the UI to do this or we could write code using the SDK but, it’s also possible to use the API directly from a tool like curl
:
TOKEN=$(more ~/.koyeb.yaml | yq .token)
NAME="kuard"
# List Apps and filter using YQ
APP=$(\
curl \
--silent \
--request GET \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/apps \
| jq -r ".apps[]|select(.name==\"${NAME}\").id") \
&& echo ${APP}
# List Services and filter using YQ
SERVICE=$(\
curl \
--silent \
--request GET \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/services \
| jq -r ".services[]|select(.name==\"${NAME}\").id") \
&& echo ${SERVICE}
# Delete the Service
curl \
--silent \
--request DELETE \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/services/${SERVICE}
# Delete the App
curl \
--silent \
--request DELETE \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/apps/${APP}
NOTE If you’re using the Web console, a trick for grabbing IDs is to open the Developers Tools’ Network tab and observe the API calls that the Web UI is making as you navigate Apps, Services etc..
You can also use the Koyeb API to list…
Regions
TOKEN=$(more ~/.koyeb.yaml | yq .token)
curl \
--silent \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/catalog/regions \
| jq -r .regions[].id
Instances
TOKEN=$(more ~/.koyeb.yaml | yq .token)
curl \
--silent \
--header "Authorization: Bearer ${TOKEN}" \
https://app.koyeb.com/v1/catalog/instances \
| jq -r .instances[].id