Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/toad/internal/client/client.go

Raw
package 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))
}