repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/toad/internal/client/client.go
Rawpackage client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"bugabinga.net/toad/internal/api"
"bugabinga.net/toad/internal/store"
)
type Client struct {
Endpoint string
Token string
HTTP *http.Client
}
type ServiceState struct {
Service string `json:"service"`
Generation uint64 `json:"generation"`
Current *store.Revision `json:"current"`
Rollback *store.Revision `json:"rollback"`
ActiveOperation string `json:"activeOperation"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c Client) Service(ctx context.Context, service string) (ServiceState, error) {
var state ServiceState
err := c.call(ctx, http.MethodGet, "/v1/services/"+service, nil, &state)
return state, err
}
func (c Client) Deploy(ctx context.Context, service string, request api.DeployRequest) (store.Operation, error) {
var operation store.Operation
err := c.call(ctx, http.MethodPost, "/v1/services/"+service+"/deployments", request, &operation)
return operation, err
}
func (c Client) Operation(ctx context.Context, service, id string) (store.Operation, error) {
var operation store.Operation
err := c.call(ctx, http.MethodGet, "/v1/services/"+service+"/operations/"+id, nil, &operation)
return operation, err
}
func (c Client) Rollback(ctx context.Context, service string) (string, error) {
var response struct {
Operation string `json:"operation"`
}
err := c.call(ctx, http.MethodPost, "/v1/services/"+service+"/rollback", nil, &response)
return response.Operation, err
}
type Token struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Value string `json:"token,omitempty"`
}
// IssueToken returns the only copy of the token value; it cannot be retrieved later.
func (c Client) IssueToken(ctx context.Context, service, label string) (Token, error) {
var issued Token
payload := struct {
Label string `json:"label"`
}{Label: label}
err := c.call(ctx, http.MethodPost, "/v1/services/"+service+"/tokens", payload, &issued)
return issued, err
}
func (c Client) ListTokens(ctx context.Context, service string) ([]Token, error) {
var listed []Token
err := c.call(ctx, http.MethodGet, "/v1/services/"+service+"/tokens", nil, &listed)
return listed, err
}
func (c Client) RevokeToken(ctx context.Context, service, id string) error {
return c.call(ctx, http.MethodDelete, "/v1/services/"+service+"/tokens/"+id, nil, nil)
}
func (c Client) Log(ctx context.Context, service, id string) (string, error) {
body, err := c.request(ctx, http.MethodGet, "/v1/services/"+service+"/operations/"+id+"/log", nil)
return string(body), err
}
// Await polls one operation until it reaches a terminal phase.
func (c Client) Await(ctx context.Context, service, id string, poll time.Duration) (store.Operation, error) {
for {
operation, err := c.Operation(ctx, service, id)
if err != nil {
return store.Operation{}, err
}
if operation.Phase.Terminal() {
return operation, nil
}
timer := time.NewTimer(poll)
select {
case <-ctx.Done():
timer.Stop()
return operation, ctx.Err()
case <-timer.C:
}
}
}
func (c Client) call(ctx context.Context, method, path string, payload, result any) error {
body, err := c.request(ctx, method, path, payload)
if err != nil {
return err
}
if result == nil || len(bytes.TrimSpace(body)) == 0 {
return nil
}
return json.Unmarshal(body, result)
}
func (c Client) request(ctx context.Context, method, path string, payload any) ([]byte, error) {
var reader io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
reader = bytes.NewReader(encoded)
}
httpClient, base := c.transport()
request, err := http.NewRequestWithContext(ctx, method, base+path, reader)
if err != nil {
return nil, err
}
if c.Token != "" {
request.Header.Set("Authorization", "Bearer "+c.Token)
}
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
if err != nil {
return nil, err
}
if response.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: %s: %s", method, path, response.Status, describe(body))
}
return body, nil
}
// transport resolves unix:// endpoints to a socket dialer; the host part is then irrelevant.
func (c Client) transport() (*http.Client, string) {
if socket, ok := strings.CutPrefix(c.Endpoint, "unix://"); ok {
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
},
},
}
}
return httpClient, "http://toad"
}
if c.HTTP != nil {
return c.HTTP, strings.TrimRight(c.Endpoint, "/")
}
return &http.Client{Timeout: 30 * time.Second}, strings.TrimRight(c.Endpoint, "/")
}
func describe(body []byte) string {
var reported struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &reported); err == nil && reported.Error != "" {
return reported.Error
}
return strings.TrimSpace(string(body))
}