package nixcache
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var (
ErrAlreadyExists = errors.New("already exists")
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
type Error struct {
Status int
Kind string
Message string
}
func (e *Error) Error() string {
if e.Kind == "" {
return fmt.Sprintf("attic: status %d: %s", e.Status, e.Message)
}
return fmt.Sprintf("attic: %s (status %d): %s", e.Kind, e.Status, e.Message)
}
func (e *Error) Is(target error) bool {
switch target {
case ErrAlreadyExists:
return e.Kind == "CacheAlreadyExists"
case ErrNotFound:
return e.Kind == "NoSuchObject" || e.Kind == "NoSuchCache"
case ErrUnauthorized:
return e.Status == http.StatusUnauthorized
case ErrForbidden:
return e.Status == http.StatusForbidden
}
return false
}
// NarHash is "sha256:<nix32>" or hex
type NarInfo struct {
Cache string `json:"cache"`
StorePathHash string `json:"store_path_hash"`
StorePath string `json:"store_path"`
References []string `json:"references"`
Deriver string `json:"deriver,omitempty"`
Sigs []string `json:"sigs"`
CA string `json:"ca,omitempty"`
NarHash string `json:"nar_hash"`
NarSize int64 `json:"nar_size"`
}
const MaxHashesPerRequest = 10000
const (
defaultTimeout = 30 * time.Second
maxResponseBytes = 1 << 20
)
type Client struct {
BaseURL string
HTTP *http.Client
// UploadPath ignores this and only stops when its context does
Timeout time.Duration
}
type hashesRequest struct {
Cache string `json:"cache"`
StorePathHash []string `json:"store_path_hashes"`
}
func (c *Client) CreateCache(ctx context.Context, token, name string) error {
body := map[string]any{
"keypair": "Generate",
"is_public": false,
"store_dir": "/nix/store",
"priority": 41,
"upstream_cache_key_names": []string{"cache.nixos.org-1"},
}
err := c.json(ctx, token, http.MethodPost, c.cacheConfig(name), body, nil)
if errors.Is(err, ErrAlreadyExists) {
return nil
}
return err
}
// SetRetention turns attic's own gc off for the cache when period is zero
func (c *Client) SetRetention(ctx context.Context, token, name string, period time.Duration) error {
body := map[string]any{"retention_period": map[string]int64{"Period": int64(period / time.Second)}}
return c.json(ctx, token, http.MethodPatch, c.cacheConfig(name), body, nil)
}
// PublicKey returns the key in nix's "<name>:<base64>" form
func (c *Client) PublicKey(ctx context.Context, token, name string) (string, error) {
var config struct {
PublicKey string `json:"public_key"`
}
if err := c.json(ctx, token, http.MethodGet, c.cacheConfig(name), nil, &config); err != nil {
return "", err
}
if config.PublicKey == "" {
return "", fmt.Errorf("attic returned no public key for cache %s", name)
}
return config.PublicKey, nil
}
// a big narinfo in a header gets 431 from proxies, and attic also reads one from the front of the body
func (c *Client) UploadPath(ctx context.Context, token string, info NarInfo, nar io.Reader) error {
info.References = nonNil(info.References)
info.Sigs = nonNil(info.Sigs)
preamble, err := json.Marshal(info)
if err != nil {
return err
}
req, err := c.request(ctx, token, http.MethodPut, "/_api/v1/upload-path", io.MultiReader(bytes.NewReader(preamble), nar))
if err != nil {
return err
}
req.Header.Set("X-Attic-Nar-Info-Preamble-Size", strconv.Itoa(len(preamble)))
req.ContentLength = int64(len(preamble)) + info.NarSize
return c.do(req, nil)
}
func (c *Client) MissingPaths(ctx context.Context, token, cache string, storePathHashes []string) ([]string, error) {
var res struct {
Missing []string `json:"missing_paths"`
}
err := c.postHashes(ctx, token, "/_api/v1/get-missing-paths", cache, storePathHashes, &res)
return res.Missing, err
}
func (c *Client) DeletePaths(ctx context.Context, token, cache string, storePathHashes []string) ([]string, error) {
var res struct {
Deleted []string `json:"deleted"`
}
err := c.postHashes(ctx, token, "/_api/v1/delete-paths", cache, storePathHashes, &res)
return res.Deleted, err
}
func (c *Client) postHashes(ctx context.Context, token, path, cache string, hashes []string, out any) error {
if len(hashes) > MaxHashesPerRequest {
return fmt.Errorf("%d hashes is over the %d attic takes in one request", len(hashes), MaxHashesPerRequest)
}
return c.json(ctx, token, http.MethodPost, path, hashesRequest{cache, nonNil(hashes)}, out)
}
func (c *Client) cacheConfig(name string) string {
return "/_api/v1/cache-config/" + url.PathEscape(name)
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func (c *Client) request(ctx context.Context, token, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(c.BaseURL, "/")+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
return req, nil
}
func (c *Client) json(ctx context.Context, token, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
timeout := cmp.Or(c.Timeout, defaultTimeout)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := c.request(ctx, token, method, path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
httpClient := c.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return readError(resp)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return err
}
if len(raw) > maxResponseBytes {
return fmt.Errorf("attic response to %s %s is over %d bytes", req.Method, req.URL.Path, maxResponseBytes)
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decoding attic response to %s %s: %w", req.Method, req.URL.Path, err)
}
return nil
}
func readError(resp *http.Response) error {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
e := &Error{Status: resp.StatusCode, Message: strings.TrimSpace(string(raw))}
var body struct {
Error string `json:"error"`
Message string `json:"message"`
}
if json.Unmarshal(raw, &body) == nil && body.Error != "" {
e.Kind, e.Message = body.Error, body.Message
}
return e
}