package worker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/auth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
type DeployContext struct {
RepoDid string `json:"-"`
OwnerDid string `json:"ownerDid"`
Domain string `json:"domain"`
Name string `json:"name"`
Rkey string `json:"rkey"`
Knot string `json:"knot"`
Branch string `json:"branch"`
Dir string `json:"dir"`
IsIndex bool `json:"isIndex"`
}
type Client struct {
BaseURL string
Issuer string
Audience string
Priv *atcrypto.PrivateKeyP256
HTTP *http.Client
}
func New(baseURL, issuer, audience string, priv *atcrypto.PrivateKeyP256) *Client {
return &Client{
BaseURL: baseURL,
Issuer: issuer,
Audience: audience,
Priv: priv,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) mint(lxm string) (string, error) {
nsid, err := syntax.ParseNSID(lxm)
if err != nil {
return "", fmt.Errorf("invalid lexicon method %q: %w", lxm, err)
}
return auth.SignServiceAuth(syntax.DID(c.Issuer), c.Audience, 5*time.Minute, &nsid, c.Priv)
}
type Error struct {
StatusCode int
NSID string
Name string
Message string
Body string
}
func (e *Error) Error() string {
if e.Name != "" {
return fmt.Sprintf("worker %s: %s: %s", e.NSID, e.Name, e.Message)
}
return fmt.Sprintf("worker %s: HTTP %d: %s", e.NSID, e.StatusCode, strings.TrimSpace(e.Body))
}
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return err
}
}
full, err := url.Parse(strings.TrimRight(c.BaseURL, "/") + path)
if err != nil {
return err
}
nsid := full.Path[strings.LastIndex(full.Path, "/")+1:]
token, err := c.mint(nsid)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, full.String(), &buf)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
xrpcErr := struct {
Error string `json:"error"`
Message string `json:"message"`
}{}
_ = json.Unmarshal(raw, &xrpcErr)
return &Error{
StatusCode: resp.StatusCode,
NSID: nsid,
Name: xrpcErr.Error,
Message: xrpcErr.Message,
Body: string(raw),
}
}
if out != nil {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return fmt.Errorf("decoding worker response for %s: %w", nsid, err)
}
}
return nil
}
func (c *Client) GetDeployContext(ctx context.Context, repoDid string) (*DeployContext, error) {
path := "/xrpc/org.tangled.temp.repo.getDeployContext?repoDid=" + url.QueryEscape(repoDid)
var raw struct {
OwnerDid string `json:"ownerDid"`
Domain string `json:"domain"`
Name string `json:"name"`
Rkey string `json:"rkey"`
Knot string `json:"knot"`
Branch string `json:"branch"`
Dir string `json:"dir"`
IsIndex bool `json:"isIndex"`
}
if err := c.do(ctx, http.MethodGet, path, nil, &raw); err != nil {
return nil, err
}
empty := raw.Rkey == "" && raw.OwnerDid == "" && raw.Domain == "" &&
raw.Name == "" && raw.Knot == "" && raw.Branch == "" && raw.Dir == ""
if empty {
return nil, nil
}
if raw.Rkey == "" || raw.OwnerDid == "" || raw.Domain == "" ||
raw.Name == "" || raw.Knot == "" || raw.Branch == "" || raw.Dir == "" {
return nil, fmt.Errorf("incomplete deploy context from worker: %+v", raw)
}
return &DeployContext{
RepoDid: repoDid,
OwnerDid: raw.OwnerDid,
Domain: raw.Domain,
Name: raw.Name,
Rkey: raw.Rkey,
Knot: raw.Knot,
Branch: raw.Branch,
Dir: raw.Dir,
IsIndex: raw.IsIndex,
}, nil
}
func (c *Client) RecordDeploy(ctx context.Context, d *DeployContext, commitSha, status, trigger, errMsg string) error {
body := map[string]string{
"repoDid": d.RepoDid,
"branch": d.Branch,
"dir": d.Dir,
"commitSha": commitSha,
"status": status,
"trigger": trigger,
"error": errMsg,
}
return c.do(ctx, http.MethodPost, "/xrpc/org.tangled.temp.repo.recordDeploy", body, nil)
}