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:" 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 ":" 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 }