forked from molecule-ai/molecule-core
Suppress errcheck warnings for calls where the return value is safely
ignored:
- resp.Body.Close() (artifacts/client.go): deferred cleanup — failure
to close a response body is non-critical; the defer itself is what
matters for connection reuse.
- rows.Close() (bundle/exporter.go): deferred cleanup in a loop where
rows.Err() already handles query errors.
- filepath.Walk (bundle/exporter.go): top-level walk call; errors in
sub-directory traversal are handled by the inner callback (which
returns nil for err != nil).
- broadcaster.RecordAndBroadcast (bundle/importer.go): fire-and-forget
event broadcast; errors are logged internally by the broadcaster.
- db.DB.ExecContext (bundle/importer.go): best-effort runtime column
update; non-critical auxiliary data that the provisioner re-extracts
if needed.
Fixes: #1143
283 lines
9.8 KiB
Go
283 lines
9.8 KiB
Go
// Package artifacts provides a minimal Go client for the Cloudflare Artifacts
|
|
// REST API (private beta Apr 2026, public beta May 2026).
|
|
//
|
|
// API reference: https://developers.cloudflare.com/artifacts/api/rest-api/
|
|
// Blog post: https://blog.cloudflare.com/artifacts-git-for-agents-beta/
|
|
//
|
|
// Base URL: https://artifacts.cloudflare.net/v1/api/namespaces/{namespace}
|
|
// Auth: Authorization: Bearer <CLOUDFLARE_API_TOKEN>
|
|
//
|
|
// This client covers the subset of operations needed for the Molecule AI
|
|
// workspace-snapshot demo:
|
|
// - CreateRepo — provision a bare Git repo for a workspace
|
|
// - GetRepo — fetch repo metadata (remote URL, created_at, …)
|
|
// - ForkRepo — create an isolated copy (e.g. workspace branching)
|
|
// - ImportRepo — bootstrap from an external GitHub/GitLab URL
|
|
// - DeleteRepo — clean-up
|
|
// - CreateToken — mint a short-lived Git credential for clone/push
|
|
// - RevokeToken — invalidate an issued token
|
|
package artifacts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://artifacts.cloudflare.net/v1/api"
|
|
defaultTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Client is a thin HTTP wrapper around the Cloudflare Artifacts REST API.
|
|
// Instantiate with New(); override BaseURL in tests via NewWithBaseURL().
|
|
type Client struct {
|
|
baseURL string // e.g. https://artifacts.cloudflare.net/v1/api/namespaces/my-ns
|
|
apiToken string // Cloudflare API token — never logged
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New returns a Client scoped to the given namespace.
|
|
// apiToken is a Cloudflare API token with Artifacts write permissions.
|
|
// namespace identifies the CF Artifacts namespace (maps to CLOUDFLARE_ARTIFACTS_NAMESPACE).
|
|
func New(apiToken, namespace string) *Client {
|
|
return NewWithBaseURL(apiToken, namespace, defaultBaseURL)
|
|
}
|
|
|
|
// NewWithBaseURL is the same as New but lets callers override the base URL —
|
|
// primarily used in unit tests to point at an httptest.Server.
|
|
func NewWithBaseURL(apiToken, namespace, baseURL string) *Client {
|
|
ns := url.PathEscape(namespace)
|
|
return &Client{
|
|
baseURL: fmt.Sprintf("%s/namespaces/%s", baseURL, ns),
|
|
apiToken: apiToken,
|
|
httpClient: &http.Client{
|
|
Timeout: defaultTimeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---- Domain types --------------------------------------------------------
|
|
|
|
// Repo represents a single Cloudflare Artifacts repository.
|
|
type Repo struct {
|
|
// Name is the user-supplied identifier within the namespace.
|
|
Name string `json:"name"`
|
|
// ID is the opaque CF-assigned identifier.
|
|
ID string `json:"id,omitempty"`
|
|
// RemoteURL is the authenticated Git remote in the form
|
|
// https://x:<TOKEN>@<hash>.artifacts.cloudflare.net/git/repo-<id>.git
|
|
RemoteURL string `json:"remote_url,omitempty"`
|
|
// ReadOnly marks repos that accept only fetch/clone operations.
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
// Description is an optional human-readable label.
|
|
Description string `json:"description,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// ForkResult is the response body from POST /repos/:name/fork.
|
|
type ForkResult struct {
|
|
Repo Repo `json:"repo"`
|
|
ObjectCount int `json:"object_count,omitempty"`
|
|
}
|
|
|
|
// RepoToken is a short-lived credential for Git operations against a single repo.
|
|
// The plaintext Token value is returned only once — callers must store it.
|
|
type RepoToken struct {
|
|
ID string `json:"id"`
|
|
Token string `json:"token"`
|
|
Scope string `json:"scope"` // "read" | "write"
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// ---- Request payloads ----------------------------------------------------
|
|
|
|
// CreateRepoRequest is the body for POST /repos.
|
|
type CreateRepoRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
DefaultBranch string `json:"default_branch,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
}
|
|
|
|
// ForkRepoRequest is the body for POST /repos/:name/fork.
|
|
type ForkRepoRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
DefaultBranchOnly bool `json:"default_branch_only,omitempty"`
|
|
}
|
|
|
|
// ImportRepoRequest is the body for POST /repos/:name/import.
|
|
type ImportRepoRequest struct {
|
|
// URL is the HTTPS URL of the source Git repository.
|
|
URL string `json:"url"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Depth int `json:"depth,omitempty"`
|
|
ReadOnly bool `json:"read_only,omitempty"`
|
|
}
|
|
|
|
// CreateTokenRequest is the body for POST /tokens.
|
|
type CreateTokenRequest struct {
|
|
// Repo is the name of the repository to scope the token to.
|
|
Repo string `json:"repo"`
|
|
Scope string `json:"scope,omitempty"` // "read" | "write"; default "write"
|
|
// TTL is the lifetime in seconds. Default 86400 (24h).
|
|
TTL int `json:"ttl,omitempty"`
|
|
}
|
|
|
|
// ---- API error -----------------------------------------------------------
|
|
|
|
// APIError represents a non-2xx response from the Cloudflare v4 envelope.
|
|
type APIError struct {
|
|
StatusCode int
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("cloudflare artifacts: HTTP %d — code %d: %s", e.StatusCode, e.Code, e.Message)
|
|
}
|
|
|
|
// ---- HTTP helpers --------------------------------------------------------
|
|
|
|
// do executes an HTTP request, checks the Cloudflare v4 envelope, and
|
|
// JSON-decodes the "result" field into out (pass nil to discard).
|
|
func (c *Client) do(ctx context.Context, method, path string, body, out interface{}) error {
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: marshal request: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: request %s %s: %w", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Decode the Cloudflare v4 envelope. Cap at 1 MiB to prevent a
|
|
// malicious or runaway upstream response from exhausting memory.
|
|
var envelope struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Success bool `json:"success"`
|
|
Errors []struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil {
|
|
// Non-JSON body (network error page, etc.)
|
|
return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("non-JSON body (status %d)", resp.StatusCode)}
|
|
}
|
|
|
|
if !envelope.Success || resp.StatusCode >= 300 {
|
|
apiErr := &APIError{StatusCode: resp.StatusCode}
|
|
if len(envelope.Errors) > 0 {
|
|
apiErr.Code = envelope.Errors[0].Code
|
|
apiErr.Message = envelope.Errors[0].Message
|
|
} else {
|
|
apiErr.Message = "unknown error"
|
|
}
|
|
return apiErr
|
|
}
|
|
|
|
if out != nil && len(envelope.Result) > 0 {
|
|
if err := json.Unmarshal(envelope.Result, out); err != nil {
|
|
return fmt.Errorf("artifacts: decode result: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Repo operations -----------------------------------------------------
|
|
|
|
// CreateRepo provisions a new bare Git repo in the namespace.
|
|
// Corresponds to POST /repos.
|
|
func (c *Client) CreateRepo(ctx context.Context, req CreateRepoRequest) (*Repo, error) {
|
|
var repo Repo
|
|
if err := c.do(ctx, http.MethodPost, "/repos", req, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// GetRepo fetches metadata for an existing repo.
|
|
// Corresponds to GET /repos/:name.
|
|
func (c *Client) GetRepo(ctx context.Context, name string) (*Repo, error) {
|
|
var repo Repo
|
|
path := "/repos/" + url.PathEscape(name)
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// ForkRepo creates an isolated copy of an existing repo.
|
|
// Corresponds to POST /repos/:name/fork.
|
|
func (c *Client) ForkRepo(ctx context.Context, sourceName string, req ForkRepoRequest) (*ForkResult, error) {
|
|
var result ForkResult
|
|
path := "/repos/" + url.PathEscape(sourceName) + "/fork"
|
|
if err := c.do(ctx, http.MethodPost, path, req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ImportRepo bootstraps a repo from an external HTTPS Git URL.
|
|
// Corresponds to POST /repos/:name/import.
|
|
func (c *Client) ImportRepo(ctx context.Context, name string, req ImportRepoRequest) (*Repo, error) {
|
|
var repo Repo
|
|
path := "/repos/" + url.PathEscape(name) + "/import"
|
|
if err := c.do(ctx, http.MethodPost, path, req, &repo); err != nil {
|
|
return nil, err
|
|
}
|
|
return &repo, nil
|
|
}
|
|
|
|
// DeleteRepo deletes a repo (returns 202 Accepted).
|
|
// Corresponds to DELETE /repos/:name.
|
|
func (c *Client) DeleteRepo(ctx context.Context, name string) error {
|
|
path := "/repos/" + url.PathEscape(name)
|
|
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
|
}
|
|
|
|
// ---- Token operations ----------------------------------------------------
|
|
|
|
// CreateToken mints a short-lived Git credential scoped to a single repo.
|
|
// The plaintext token is in the returned RepoToken.Token field — it will not
|
|
// be available again after this call returns.
|
|
// Corresponds to POST /tokens.
|
|
func (c *Client) CreateToken(ctx context.Context, req CreateTokenRequest) (*RepoToken, error) {
|
|
var token RepoToken
|
|
if err := c.do(ctx, http.MethodPost, "/tokens", req, &token); err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// RevokeToken invalidates an issued token by its ID.
|
|
// Corresponds to DELETE /tokens/:id.
|
|
func (c *Client) RevokeToken(ctx context.Context, tokenID string) error {
|
|
path := "/tokens/" + url.PathEscape(tokenID)
|
|
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
|
}
|