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