init
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- identity
|
||||
|
||||
// WhoAmI describes the credential the client is using.
|
||||
func (c *Client) WhoAmI(ctx context.Context) (api.WhoAmI, error) {
|
||||
var out api.WhoAmI
|
||||
err := c.do(ctx, http.MethodGet, api.PathWhoAmI(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SystemInfo returns server-wide counters. Admin scope only.
|
||||
func (c *Client) SystemInfo(ctx context.Context) (api.SystemInfo, error) {
|
||||
var out api.SystemInfo
|
||||
err := c.do(ctx, http.MethodGet, api.PathSystemInfo(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- projects
|
||||
|
||||
// CreateProject creates a project. Admin scope only.
|
||||
func (c *Client) CreateProject(ctx context.Context, req api.CreateProjectRequest) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodPost, api.PathProjects(), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListOptions pages a listing endpoint. A zero Limit takes the server default.
|
||||
type ListOptions struct {
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func (o ListOptions) query() string {
|
||||
q := url.Values{}
|
||||
if o.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(o.Limit))
|
||||
}
|
||||
if o.Cursor != "" {
|
||||
q.Set("cursor", o.Cursor)
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// ListProjects returns one page of projects. Admin scope only.
|
||||
func (c *Client) ListProjects(ctx context.Context, opts ListOptions) (api.ProjectList, error) {
|
||||
var out api.ProjectList
|
||||
err := c.do(ctx, http.MethodGet, api.PathProjects()+opts.query(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListAllProjects follows the cursor to the end.
|
||||
//
|
||||
// The CLI pages on the user's behalf because "pages project list" that silently
|
||||
// showed the first hundred of three hundred projects would be a lie; a caller
|
||||
// that wants one page asks for ListProjects.
|
||||
func (c *Client) ListAllProjects(ctx context.Context) ([]api.Project, error) {
|
||||
var all []api.Project
|
||||
opts := ListOptions{Limit: 500}
|
||||
for {
|
||||
page, err := c.ListProjects(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page.Projects...)
|
||||
if page.NextCursor == "" || len(page.Projects) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
opts.Cursor = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
// GetProject reads one project. A project-scoped key may read only its own.
|
||||
func (c *Client) GetProject(ctx context.Context, name string) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodGet, api.PathProject(name), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PatchProject applies a partial update. Admin scope only.
|
||||
func (c *Client) PatchProject(ctx context.Context, name string, patch api.ProjectPatch) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodPatch, api.PathProject(name), patch, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteProject removes a project and everything under it. Admin scope only.
|
||||
func (c *Client) DeleteProject(ctx context.Context, name string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathProject(name), nil, nil)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- keys
|
||||
|
||||
// CreateAdminKey mints an admin key. Admin scope only.
|
||||
//
|
||||
// The returned token is the only copy that will ever exist; the caller shows it
|
||||
// once and must not log it.
|
||||
func (c *Client) CreateAdminKey(ctx context.Context, req api.CreateKeyRequest) (api.CreateKeyResponse, error) {
|
||||
var out api.CreateKeyResponse
|
||||
err := c.do(ctx, http.MethodPost, api.PathKeys(), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateProjectKey mints a key scoped to one project. Admin scope only.
|
||||
func (c *Client) CreateProjectKey(ctx context.Context, project string, req api.CreateKeyRequest) (api.CreateKeyResponse, error) {
|
||||
var out api.CreateKeyResponse
|
||||
err := c.do(ctx, http.MethodPost, api.PathProjectKeys(project), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListKeys returns every key on the server. Admin scope only.
|
||||
func (c *Client) ListKeys(ctx context.Context) (api.KeyList, error) {
|
||||
var out api.KeyList
|
||||
err := c.do(ctx, http.MethodGet, api.PathKeys(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListProjectKeys returns the keys of one project.
|
||||
func (c *Client) ListProjectKeys(ctx context.Context, project string) (api.KeyList, error) {
|
||||
var out api.KeyList
|
||||
err := c.do(ctx, http.MethodGet, api.PathProjectKeys(project), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeKey revokes a key by id. It takes effect immediately, and is
|
||||
// idempotent: revoking an already-revoked key succeeds.
|
||||
func (c *Client) RevokeKey(ctx context.Context, keyID string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathKey(keyID), nil, nil)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deployments
|
||||
|
||||
// DeploymentListOptions selects and pages a project's deployments. A zero State
|
||||
// lists every state.
|
||||
type DeploymentListOptions struct {
|
||||
ListOptions
|
||||
State string
|
||||
}
|
||||
|
||||
func (o DeploymentListOptions) query() string {
|
||||
q := url.Values{}
|
||||
if o.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(o.Limit))
|
||||
}
|
||||
if o.Cursor != "" {
|
||||
q.Set("cursor", o.Cursor)
|
||||
}
|
||||
if o.State != "" {
|
||||
q.Set("state", o.State)
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// ListDeployments returns one page of a project's deployments, newest first.
|
||||
func (c *Client) ListDeployments(ctx context.Context, project string, opts DeploymentListOptions) (api.DeploymentList, error) {
|
||||
var out api.DeploymentList
|
||||
err := c.do(ctx, http.MethodGet, api.PathDeployments(project)+opts.query(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetDeployment reads one deployment. withFiles asks for its manifest too,
|
||||
// which for a large site is a great deal more response than the rest of it.
|
||||
func (c *Client) GetDeployment(ctx context.Context, project, id string, withFiles bool) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
path := api.PathDeployment(project, id)
|
||||
if withFiles {
|
||||
path += "?files=true"
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, path, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteDeployment removes a deployment and its files. The active one cannot be
|
||||
// deleted; the server answers deployment_active until something else is
|
||||
// activated.
|
||||
func (c *Client) DeleteDeployment(ctx context.Context, project, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathDeployment(project, id), nil, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- upkeep
|
||||
|
||||
// Collect runs a garbage collection pass. Admin scope only.
|
||||
func (c *Client) Collect(ctx context.Context, dryRun bool) (api.GCStats, error) {
|
||||
var out api.GCStats
|
||||
err := c.do(ctx, http.MethodPost, api.PathGC(), api.GCRequest{DryRun: dryRun}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Fsck checks the blob reference counts against the manifests, optionally
|
||||
// correcting them. Admin scope only.
|
||||
func (c *Client) Fsck(ctx context.Context, repair bool) (api.FsckReport, error) {
|
||||
var out api.FsckReport
|
||||
err := c.do(ctx, http.MethodPost, api.PathFsck(), api.FsckRequest{Repair: repair}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package client is the HTTP client for the pages management API.
|
||||
//
|
||||
// It depends on the standard library and github.com/iceBear67/simplepages/api
|
||||
// only — see cmd/pages/deps_test.go, which fails the build if a server-side
|
||||
// package ever reaches the CLI through here.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
)
|
||||
|
||||
// DefaultTimeout bounds a single management request.
|
||||
const DefaultTimeout = 30 * time.Second
|
||||
|
||||
// maxErrorBody caps how much of a non-2xx body is read before giving up on
|
||||
// finding an error envelope in it. A misrouted request can land on something
|
||||
// that answers with a megabyte of HTML.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
// Config configures a Client.
|
||||
type Config struct {
|
||||
// BaseURL is the management API root, e.g. "https://pages.example.com".
|
||||
// Any trailing slash is trimmed.
|
||||
BaseURL string
|
||||
// Token is the bearer credential. It is sent in the Authorization header
|
||||
// and must never appear in a URL, a log line or an error message.
|
||||
Token string
|
||||
// Timeout bounds each request. Zero means DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// HTTP overrides the underlying client. Its Timeout field is ignored:
|
||||
// deadlines come from the request context so the deploy path can give a
|
||||
// large blob upload longer than a management call.
|
||||
HTTP *http.Client
|
||||
// UserAgent overrides the default "pages/<version>".
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Client talks to the management API.
|
||||
type Client struct {
|
||||
base string
|
||||
token string
|
||||
timeout time.Duration
|
||||
http *http.Client
|
||||
agent string
|
||||
}
|
||||
|
||||
// New validates cfg and returns a client.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
raw := strings.TrimSpace(cfg.BaseURL)
|
||||
if raw == "" {
|
||||
return nil, errors.New("no server URL: pass --server or set PAGES_SERVER")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid server URL %q: %w", raw, err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
case "":
|
||||
return nil, fmt.Errorf("invalid server URL %q: missing scheme, try https://%s", raw, raw)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid server URL %q: scheme must be http or https", raw)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q: missing host", raw)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return nil, errors.New("no token: pass --token-file or set PAGES_TOKEN")
|
||||
}
|
||||
|
||||
hc := cfg.HTTP
|
||||
if hc == nil {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
// The deploy path uploads blobs concurrently to one host.
|
||||
tr.MaxIdleConnsPerHost = 32
|
||||
hc = &http.Client{Transport: tr}
|
||||
}
|
||||
// A redirect is a misconfigured --server, and following it silently would
|
||||
// send the bearer token somewhere the operator did not name. Report it and
|
||||
// let them fix the URL. (Go strips Authorization across hosts anyway, which
|
||||
// would turn the redirect into a confusing 401 instead.)
|
||||
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return fmt.Errorf("server redirected to %s — use that as --server", req.URL.Redacted())
|
||||
}
|
||||
|
||||
agent := cfg.UserAgent
|
||||
if agent == "" {
|
||||
agent = "pages/" + version.Version
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
return &Client{
|
||||
base: strings.TrimRight(u.String(), "/"),
|
||||
token: cfg.Token,
|
||||
timeout: timeout,
|
||||
http: hc,
|
||||
agent: agent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BaseURL returns the server root the client was configured with.
|
||||
func (c *Client) BaseURL() string { return c.base }
|
||||
|
||||
// do performs a request with a JSON body and decodes a JSON response.
|
||||
//
|
||||
// body may be nil for a bodyless request; out may be nil to discard the
|
||||
// response (204, or a response the caller does not need).
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.agent)
|
||||
if rdr != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return requestError(method, path, err)
|
||||
}
|
||||
defer func() {
|
||||
// Drain a little so the connection can be reused; a huge unread body is
|
||||
// not worth keeping the connection for.
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return responseError(resp)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode %s %s response: %w", method, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestError describes a transport-level failure. The URL is included; the
|
||||
// token is not, and cannot be — it never leaves the Authorization header.
|
||||
func requestError(method, path string, err error) error {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("%s %s: timed out; raise --timeout if the server is slow", method, path)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
|
||||
// responseError turns a non-2xx response into an *api.Error, so callers can
|
||||
// switch on api.CodeOf and the CLI can print "project_exists: ...".
|
||||
func responseError(resp *http.Response) error {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err == nil && env.Error.Code != "" {
|
||||
return &env.Error
|
||||
}
|
||||
|
||||
// Not an envelope: a proxy, a wrong --server, or a bug. Say what arrived
|
||||
// instead of pretending to a code we did not receive.
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200] + "…"
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return &api.Error{Code: codeForStatus(resp.StatusCode), Message: msg}
|
||||
}
|
||||
|
||||
func codeForStatus(status int) api.Code {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return api.CodeBadRequest
|
||||
case http.StatusUnauthorized:
|
||||
return api.CodeUnauthorized
|
||||
case http.StatusForbidden:
|
||||
return api.CodeForbidden
|
||||
case http.StatusNotFound:
|
||||
return api.CodeNotFound
|
||||
case http.StatusMethodNotAllowed:
|
||||
return api.CodeMethodNotAllowed
|
||||
case http.StatusConflict:
|
||||
return api.CodeConflict
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
return api.CodePayloadTooLarge
|
||||
case http.StatusTooManyRequests:
|
||||
return api.CodeRateLimited
|
||||
case http.StatusServiceUnavailable:
|
||||
return api.CodeUnavailable
|
||||
default:
|
||||
return api.CodeInternal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
const testToken = "pgs_abcdefghijklmnop_ThisIsTheSecretHalfAndMustNotLeakAnywhere"
|
||||
|
||||
func TestNewValidatesConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{"no server", Config{Token: testToken}, "no server URL"},
|
||||
{"no token", Config{BaseURL: "https://p.example.com"}, "no token"},
|
||||
{"missing scheme", Config{BaseURL: "p.example.com", Token: testToken}, "missing scheme"},
|
||||
{"wrong scheme", Config{BaseURL: "ftp://p.example.com", Token: testToken}, "scheme must be http or https"},
|
||||
{"missing host", Config{BaseURL: "https://", Token: testToken}, "missing host"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
_, err := New(tc.cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
|
||||
}
|
||||
// A rejected URL is echoed back so the operator can see the typo, but
|
||||
// the token must never turn up in a message they might paste anywhere.
|
||||
if err != nil && strings.Contains(err.Error(), testToken) {
|
||||
t.Error("error message contains the token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTrimsTrailingSlash(t *testing.T) {
|
||||
c, err := New(Config{BaseURL: "https://p.example.com/", Token: testToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := c.BaseURL(); got != "https://p.example.com" {
|
||||
t.Errorf("BaseURL = %q, want no trailing slash", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCarriesTokenInHeaderOnly is the wire half of the rule that a
|
||||
// token never reaches a proxy access log: it belongs in Authorization, and
|
||||
// nowhere near the URL.
|
||||
func TestRequestCarriesTokenInHeaderOnly(t *testing.T) {
|
||||
var gotAuth, gotURL, gotAccept, gotAgent string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotURL = r.Header.Get("Authorization"), r.URL.String()
|
||||
gotAccept, gotAgent = r.Header.Get("Accept"), r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"key_id":"abcdefghijklmnop","scope":"admin"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
who, err := c.WhoAmI(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if who.KeyID != "abcdefghijklmnop" || who.Scope != "admin" {
|
||||
t.Errorf("decoded %+v", who)
|
||||
}
|
||||
if gotAuth != "Bearer "+testToken {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if strings.Contains(gotURL, "pgs_") {
|
||||
t.Errorf("token reached the URL: %q", gotURL)
|
||||
}
|
||||
if gotAccept != "application/json" {
|
||||
t.Errorf("Accept = %q", gotAccept)
|
||||
}
|
||||
if !strings.HasPrefix(gotAgent, "pages/") {
|
||||
t.Errorf("User-Agent = %q", gotAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectIsRefused: following a redirect would send the bearer token to a
|
||||
// host the operator never named.
|
||||
func TestRedirectIsRefused(t *testing.T) {
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("request followed the redirect and reached %s", r.Host)
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redirected") {
|
||||
t.Errorf("err = %v, want it to explain the redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponses(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
status int
|
||||
body string
|
||||
contentType string
|
||||
wantCode api.Code
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
desc: "server envelope is used verbatim",
|
||||
status: http.StatusConflict,
|
||||
body: `{"error":{"code":"project_exists","message":"project demo already exists"}}`,
|
||||
wantCode: "project_exists",
|
||||
wantMsg: "project demo already exists",
|
||||
},
|
||||
{
|
||||
desc: "a bare proxy error still gets a code from the status",
|
||||
status: http.StatusForbidden,
|
||||
body: "<html>403 Forbidden</html>",
|
||||
wantCode: api.CodeForbidden,
|
||||
wantMsg: "<html>403 Forbidden</html>",
|
||||
},
|
||||
{
|
||||
desc: "an empty body falls back to the status text",
|
||||
status: http.StatusBadGateway,
|
||||
body: "",
|
||||
wantCode: api.CodeInternal,
|
||||
wantMsg: "Bad Gateway",
|
||||
},
|
||||
{
|
||||
desc: "an envelope without a code is not an envelope",
|
||||
status: http.StatusNotFound,
|
||||
body: `{"error":{"message":"nope"}}`,
|
||||
wantCode: api.CodeNotFound,
|
||||
wantMsg: `{"error":{"message":"nope"}}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("err = %v (%T), want *api.Error", err, err)
|
||||
}
|
||||
if apiErr.Code != tc.wantCode {
|
||||
t.Errorf("code = %q, want %q", apiErr.Code, tc.wantCode)
|
||||
}
|
||||
if apiErr.Message != tc.wantMsg {
|
||||
t.Errorf("message = %q, want %q", apiErr.Message, tc.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHugeErrorBodyIsTruncated: a wrong --server can point at something that
|
||||
// answers every request with a megabyte of HTML.
|
||||
func TestHugeErrorBodyIsTruncated(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(strings.Repeat("x", 1<<20)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if len(err.Error()) > 400 {
|
||||
t.Errorf("error message is %d bytes; it should be truncated", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContentNeedsNoBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
if err := c.DeleteProject(context.Background(), "demo"); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeoutSaysWhichFlagToRaise — the timeout is the one failure a user can
|
||||
// fix from the message alone, so the message has to name the flag.
|
||||
func TestTimeoutSaysWhichFlagToRaise(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
}))
|
||||
defer func() { close(release); srv.Close() }()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken, Timeout: 50 * time.Millisecond})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--timeout") {
|
||||
t.Errorf("err = %v, want it to mention --timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentQueriesTravelInTheURL. State, limit and cursor are the three
|
||||
// knobs of a deployment listing and all three ride the query string; one that
|
||||
// went missing would leave a listing that quietly answers a different question.
|
||||
func TestDeploymentQueriesTravelInTheURL(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.URL.RequestURI()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"deployments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
ctx := context.Background()
|
||||
list := api.PathDeployments("demo")
|
||||
one := api.PathDeployment("demo", "dpl_1")
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
call func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"no options, no query",
|
||||
func() error { _, err := c.ListDeployments(ctx, "demo", DeploymentListOptions{}); return err },
|
||||
list,
|
||||
},
|
||||
{
|
||||
"every option set",
|
||||
func() error {
|
||||
opts := DeploymentListOptions{State: "ready"}
|
||||
opts.Limit, opts.Cursor = 50, "dpl_9"
|
||||
_, err := c.ListDeployments(ctx, "demo", opts)
|
||||
return err
|
||||
},
|
||||
list + "?cursor=dpl_9&limit=50&state=ready",
|
||||
},
|
||||
{
|
||||
"a manifest is not fetched by default",
|
||||
func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", false); return err },
|
||||
one,
|
||||
},
|
||||
{
|
||||
"asking for the manifest",
|
||||
func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", true); return err },
|
||||
one + "?files=true",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
if err := tc.call(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("requested %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpkeepPostsItsFlag: a --dry-run that silently ran for real, or a fsck
|
||||
// that repaired without being asked, are the two ways these calls can be
|
||||
// dangerous, and both live in the request body.
|
||||
func TestUpkeepPostsItsFlag(t *testing.T) {
|
||||
var gotMethod, gotPath, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotMethod, gotPath, gotBody = r.Method, r.URL.Path, string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.Collect(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != api.PathGC() {
|
||||
t.Errorf("collect sent %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"dry_run":true`) {
|
||||
t.Errorf("collect body = %q, want dry_run set", gotBody)
|
||||
}
|
||||
|
||||
if _, err := c.Fsck(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != api.PathFsck() {
|
||||
t.Errorf("fsck sent %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"repair":true`) {
|
||||
t.Errorf("fsck body = %q, want repair set", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAllProjectsFollowsTheCursor: paging is invisible to the CLI, so the
|
||||
// only place it can break is here.
|
||||
func TestListAllProjectsFollowsTheCursor(t *testing.T) {
|
||||
var seen []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
seen = append(seen, cursor)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch cursor {
|
||||
case "":
|
||||
w.Write([]byte(`{"projects":[{"name":"a"},{"name":"b"}],"next_cursor":"b"}`))
|
||||
case "b":
|
||||
w.Write([]byte(`{"projects":[{"name":"c"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected cursor %q", cursor)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
got, err := c.ListAllProjects(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range got {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "a,b,c" {
|
||||
t.Errorf("projects = %v, want a,b,c", names)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != "" || seen[1] != "b" {
|
||||
t.Errorf("cursors requested = %q", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ single calls
|
||||
|
||||
// CreateDeployment opens a deployment. Nothing is served until it is finalized
|
||||
// and activated.
|
||||
func (c *Client) CreateDeployment(ctx context.Context, project string, meta map[string]string) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
req := api.CreateDeploymentRequest{Meta: meta}
|
||||
err := c.do(ctx, http.MethodPost, api.PathDeployments(project), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SetManifest declares the deployment's complete file list and returns the
|
||||
// digests the server does not have yet.
|
||||
//
|
||||
// A large manifest is both slow to send and slow to insert, so it gets a
|
||||
// deadline scaled to its size rather than the management timeout.
|
||||
func (c *Client) SetManifest(ctx context.Context, project, id string, files []api.FileEntry) (api.ManifestResponse, error) {
|
||||
var out api.ManifestResponse
|
||||
req := api.ManifestRequest{Files: files}
|
||||
// Roughly a millisecond per file on top of the base timeout: a 50,000-file
|
||||
// manifest gets a minute of headroom, a small one gets no extra.
|
||||
extra := time.Duration(len(files)) * time.Millisecond
|
||||
err := c.doWithTimeout(ctx, c.timeout+extra, http.MethodPost,
|
||||
api.PathManifest(project, id), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Finalize verifies every blob arrived and assembles the deployment.
|
||||
//
|
||||
// It returns an *api.Error with code blobs_missing when uploads are outstanding;
|
||||
// the digests are in details["missing"].
|
||||
func (c *Client) Finalize(ctx context.Context, project, id string, fileCount int) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
// Assembly hardlinks or copies every file, so this scales with the file
|
||||
// count in the same way the manifest insert does.
|
||||
extra := time.Duration(fileCount) * time.Millisecond
|
||||
err := c.doWithTimeout(ctx, c.timeout+extra, http.MethodPost,
|
||||
api.PathFinalize(project, id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Activate switches the project to this deployment. Passing an older id is how
|
||||
// a rollback is performed.
|
||||
func (c *Client) Activate(ctx context.Context, project, id string) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
err := c.do(ctx, http.MethodPost, api.PathActivate(project, id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PutBlob uploads one blob's contents.
|
||||
//
|
||||
// It bypasses do, which is JSON-only: the body is raw bytes, the length is
|
||||
// declared up front so the server can refuse an oversized file before reading
|
||||
// it, and the deadline has to accommodate a file rather than an API call.
|
||||
//
|
||||
// A digest the server already holds is a fast 200 — that is what makes a
|
||||
// re-run of a failed deploy cheap.
|
||||
func (c *Client) PutBlob(ctx context.Context, digest string, size int64, body io.Reader) (api.BlobResponse, error) {
|
||||
var out api.BlobResponse
|
||||
path := api.PathBlob(digest)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, uploadTimeout(c.timeout, size))
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.base+path, body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.ContentLength = size
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.agent)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return out, requestError(http.MethodPut, path, err)
|
||||
}
|
||||
defer func() {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return out, wrapRetryable(resp, responseError(resp))
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return out, fmt.Errorf("decode PUT %s response: %w", path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadTimeout gives a transfer the base timeout plus enough time to move its
|
||||
// bytes over a slow link. The floor is deliberately pessimistic — a CI runner
|
||||
// on a hotel connection should finish, not time out halfway and start over.
|
||||
func uploadTimeout(base time.Duration, size int64) time.Duration {
|
||||
const bytesPerSecond = 128 << 10
|
||||
return base + time.Duration(size/bytesPerSecond)*time.Second
|
||||
}
|
||||
|
||||
// doWithTimeout is do with an explicit deadline instead of the client's.
|
||||
//
|
||||
// do reads c.timeout, and one Client is shared by every upload goroutine, so
|
||||
// the field cannot be swapped in place. The struct is a string, a duration and
|
||||
// two pointers; copying it is cheaper than the synchronisation would be.
|
||||
func (c *Client) doWithTimeout(ctx context.Context, timeout time.Duration, method, path string, body, out any) error {
|
||||
tmp := *c
|
||||
tmp.timeout = timeout
|
||||
return tmp.do(ctx, method, path, body, out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ retries
|
||||
|
||||
// throttled marks an error the deploy loop should retry, and carries the
|
||||
// server's Retry-After when it sent one.
|
||||
//
|
||||
// The delay rides on a wrapper rather than on api.Error because api.Error is
|
||||
// the wire type: a field that never appears in JSON does not belong in it.
|
||||
// api.CodeOf unwraps, so callers still see the underlying code.
|
||||
type throttled struct {
|
||||
err error
|
||||
after time.Duration
|
||||
}
|
||||
|
||||
func (t *throttled) Error() string { return t.err.Error() }
|
||||
func (t *throttled) Unwrap() error { return t.err }
|
||||
|
||||
// wrapRetryable tags the responses that are worth trying again: rate limiting,
|
||||
// and anything the server reports as a transient failure of its own.
|
||||
func wrapRetryable(resp *http.Response, err error) error {
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests,
|
||||
resp.StatusCode >= 500:
|
||||
return &throttled{err: err, after: retryAfter(resp)}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// retryAfter reads the header in its delay-seconds form. The HTTP-date form is
|
||||
// ignored on purpose: honouring it means trusting the server's clock against
|
||||
// ours, and the backoff below is a perfectly good fallback.
|
||||
func retryAfter(resp *http.Response) time.Duration {
|
||||
v := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
secs, err := strconv.Atoi(v)
|
||||
if err != nil || secs < 0 {
|
||||
return 0
|
||||
}
|
||||
const maxWait = 60 * time.Second
|
||||
d := time.Duration(secs) * time.Second
|
||||
return min(d, maxWait)
|
||||
}
|
||||
|
||||
// retryable reports whether err is worth another attempt, and how long to wait
|
||||
// before it if the server asked for a specific delay.
|
||||
//
|
||||
// Transport errors are retried because a dropped connection mid-upload is the
|
||||
// single most common failure on a CI runner. A 4xx other than 429 is not: the
|
||||
// request is wrong and repeating it will not fix it.
|
||||
func retryable(err error) (time.Duration, bool) {
|
||||
if err == nil {
|
||||
return 0, false
|
||||
}
|
||||
var t *throttled
|
||||
if errors.As(err, &t) {
|
||||
return t.after, true
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
// A deadline that came from the caller's context means give up; one from
|
||||
// our own per-upload timeout is indistinguishable here, so treat both as
|
||||
// fatal rather than risk a retry storm against a wedged server.
|
||||
return 0, false
|
||||
}
|
||||
var apiErr *api.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
// An envelope arrived, so the server is reachable and answered on
|
||||
// purpose. Only rate limiting is worth repeating.
|
||||
return 0, apiErr.Code == api.CodeRateLimited
|
||||
}
|
||||
// Anything else is a transport failure.
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// backoff is exponential with jitter, capped. The jitter matters when a CI
|
||||
// fleet retries in lockstep after a server restart.
|
||||
func backoff(attempt int, seed uint64) time.Duration {
|
||||
const (
|
||||
base = 250 * time.Millisecond
|
||||
longest = 15 * time.Second
|
||||
)
|
||||
d := min(base<<min(attempt, 6), longest)
|
||||
// Full jitter over [d/2, d), derived from the digest so the goroutines
|
||||
// spread out without needing a shared random source.
|
||||
half := d / 2
|
||||
return half + time.Duration(seed%uint64(half))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deploy flow
|
||||
|
||||
// DeployOptions drives Deploy.
|
||||
type DeployOptions struct {
|
||||
Project string
|
||||
Source *Source
|
||||
Meta map[string]string
|
||||
// Activate switches the project over once the upload is complete.
|
||||
Activate bool
|
||||
// Concurrency bounds simultaneous blob uploads. Zero means 8.
|
||||
Concurrency int
|
||||
// Retries is the number of additional attempts per blob. Zero means 4.
|
||||
Retries int
|
||||
// Progress receives human-readable step messages. Calls are serialised, so
|
||||
// an implementation needs no locking of its own. It may be nil.
|
||||
Progress func(string)
|
||||
}
|
||||
|
||||
// DeployResult reports what a deploy did.
|
||||
type DeployResult struct {
|
||||
Deployment api.Deployment `json:"deployment"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
// Uploaded counts blobs this run actually sent; Deduplicated counts the ones
|
||||
// the server already had. Their sum is the number of distinct digests.
|
||||
Uploaded int `json:"uploaded"`
|
||||
UploadedBytes int64 `json:"uploaded_bytes"`
|
||||
Deduplicated int `json:"deduplicated"`
|
||||
Activated bool `json:"activated"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Deploy runs the full sequence: create, negotiate the manifest, upload what is
|
||||
// missing, finalize, and optionally activate.
|
||||
//
|
||||
// Interrupting it is safe and cheap to recover from. Blobs that made it are
|
||||
// already in the content-addressed store, so re-running the same deploy
|
||||
// negotiates a much smaller missing set — resumption falls out of the protocol
|
||||
// rather than needing one of its own.
|
||||
func (c *Client) Deploy(ctx context.Context, opts DeployOptions) (*DeployResult, error) {
|
||||
if opts.Source == nil || len(opts.Source.Files) == 0 {
|
||||
return nil, errors.New("nothing to deploy")
|
||||
}
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = 8
|
||||
}
|
||||
if opts.Retries <= 0 {
|
||||
opts.Retries = 4
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
report := func(format string, args ...any) {
|
||||
if opts.Progress == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
opts.Progress(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
src := opts.Source
|
||||
res := &DeployResult{FileCount: len(src.Files), TotalBytes: src.TotalBytes}
|
||||
|
||||
dep, err := c.CreateDeployment(ctx, opts.Project, opts.Meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deployment = dep
|
||||
report("created deployment %s", dep.ID)
|
||||
|
||||
man, err := c.SetManifest(ctx, opts.Project, dep.ID, src.Manifest())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deduplicated = man.Have
|
||||
report("%d files, %s; %s, %s to upload",
|
||||
man.FileCount, humanBytes(man.TotalBytes),
|
||||
plural(len(man.Missing), "new blob"), humanBytes(man.MissingBytes))
|
||||
|
||||
if len(man.Missing) > 0 {
|
||||
up, bytes, err := c.upload(ctx, src, man.Missing, opts, report)
|
||||
res.Uploaded, res.UploadedBytes = up, bytes
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fin, err := c.Finalize(ctx, opts.Project, dep.ID, len(src.Files))
|
||||
if err != nil {
|
||||
// The server may have lost a blob between our upload and the finalize —
|
||||
// a GC race, or a restart mid-write. It tells us exactly which, so send
|
||||
// those again and finalize once more rather than failing the build.
|
||||
missing, ok := missingFrom(err)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
report("server is missing %s after upload; resending", plural(len(missing), "blob"))
|
||||
up, bytes, uerr := c.upload(ctx, src, missing, opts, report)
|
||||
res.Uploaded += up
|
||||
res.UploadedBytes += bytes
|
||||
if uerr != nil {
|
||||
return nil, uerr
|
||||
}
|
||||
fin, err = c.Finalize(ctx, opts.Project, dep.ID, len(src.Files))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res.Deployment = fin
|
||||
res.URL = fin.URL
|
||||
report("finalized %s (%d files, %s)", fin.ID, fin.FileCount, humanBytes(fin.TotalBytes))
|
||||
|
||||
if opts.Activate {
|
||||
act, err := c.Activate(ctx, opts.Project, dep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deployment = act
|
||||
res.Activated = true
|
||||
if act.URL != "" {
|
||||
res.URL = act.URL
|
||||
}
|
||||
report("activated %s", act.ID)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// upload sends the named digests, at most Concurrency at a time.
|
||||
func (c *Client) upload(ctx context.Context, src *Source, digests []string,
|
||||
opts DeployOptions, report func(string, ...any)) (int, int64, error) {
|
||||
|
||||
// A digest may back several paths; any one of them has the bytes.
|
||||
byDigest := make(map[string]LocalFile, len(src.Files))
|
||||
for _, f := range src.Files {
|
||||
if _, ok := byDigest[f.Digest]; !ok {
|
||||
byDigest[f.Digest] = f
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
count int
|
||||
sent int64
|
||||
)
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(opts.Concurrency)
|
||||
for _, digest := range digests {
|
||||
f, ok := byDigest[digest]
|
||||
if !ok {
|
||||
// The server asked for something we never offered. Failing here beats
|
||||
// finalizing into a deployment that can never become ready.
|
||||
return count, sent, fmt.Errorf("server reported digest %s as missing, "+
|
||||
"but it is not in the manifest", digest)
|
||||
}
|
||||
g.Go(func() error {
|
||||
if err := c.putRetrying(ctx, src, f, opts.Retries, report); err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
count++
|
||||
sent += f.Size
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err := g.Wait()
|
||||
if err == nil {
|
||||
report("uploaded %s (%s)", plural(count, "blob"), humanBytes(sent))
|
||||
}
|
||||
return count, sent, err
|
||||
}
|
||||
|
||||
// putRetrying uploads one blob, retrying transient failures.
|
||||
//
|
||||
// The body is reopened for every attempt: an io.Reader that has already been
|
||||
// partly consumed cannot be replayed, and a retry that sent the tail of a file
|
||||
// would be rejected as a digest mismatch — correctly, but confusingly.
|
||||
func (c *Client) putRetrying(ctx context.Context, src *Source, f LocalFile,
|
||||
retries int, report func(string, ...any)) error {
|
||||
|
||||
var last error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
wait, _ := retryable(last)
|
||||
if wait == 0 {
|
||||
wait = backoff(attempt-1, seedOf(f.Digest))
|
||||
}
|
||||
report("retrying %s in %s (%v)", shortDigest(f.Digest), wait.Round(time.Millisecond), last)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
body, err := src.Open(f.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
_, err = c.PutBlob(ctx, f.Digest, f.Size, body)
|
||||
body.Close()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
last = err
|
||||
if _, ok := retryable(err); !ok {
|
||||
return fmt.Errorf("upload %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("upload %s: giving up after %d attempts: %w", f.Path, retries+1, last)
|
||||
}
|
||||
|
||||
// missingFrom extracts the digest list from a blobs_missing error.
|
||||
func missingFrom(err error) ([]string, bool) {
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != api.CodeBlobsMissing {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := apiErr.Details["missing"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// seedOf derives a per-blob jitter seed from its digest, so retries of
|
||||
// different blobs spread out without a shared random source.
|
||||
func seedOf(digest string) uint64 {
|
||||
var h uint64 = 1469598103934665603 // FNV-1a offset basis
|
||||
for i := 0; i < len(digest); i++ {
|
||||
h ^= uint64(digest[i])
|
||||
h *= 1099511628211
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func shortDigest(d string) string {
|
||||
if len(d) > 12 {
|
||||
return d[:12]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return strconv.Itoa(n) + " " + noun + "s"
|
||||
}
|
||||
|
||||
// humanBytes renders a size the way a person reads it. It lives here rather
|
||||
// than in cliutil because the progress messages are produced by this package.
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return strconv.FormatInt(n, 10) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit && exp < 4; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/pathutil"
|
||||
)
|
||||
|
||||
// skipDirs are never walked into. A build output directory should not contain
|
||||
// them at all, but "pages deploy ." on a repository root is a mistake people
|
||||
// make once, and uploading a .git directory publishes the whole history.
|
||||
var skipDirs = []string{".git", ".hg", ".svn"}
|
||||
|
||||
// LocalFile is one file of a scanned directory, already hashed.
|
||||
type LocalFile struct {
|
||||
// Path is site-relative and slash-separated: it is what the URL will be.
|
||||
Path string `json:"path"`
|
||||
// Digest is the lowercase hex SHA-256 of the contents.
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ScanOptions filters and paces a scan.
|
||||
type ScanOptions struct {
|
||||
// Include, when non-empty, keeps only files matching at least one pattern.
|
||||
// Exclude drops files matching any pattern, and prunes whole directories.
|
||||
// A pattern is path.Match syntax, tried against the site-relative path and
|
||||
// against the base name, so both "assets/*.map" and "*.map" work.
|
||||
Include []string
|
||||
Exclude []string
|
||||
|
||||
// FollowSymlinks reads through symbolic links instead of refusing them.
|
||||
// Links are still resolved inside the scanned directory, so one pointing at
|
||||
// /etc/passwd fails rather than publishing it.
|
||||
FollowSymlinks bool
|
||||
|
||||
// Concurrency bounds the hashing goroutines. Zero means GOMAXPROCS.
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// Source is a scanned directory: the manifest it produced, plus the handle the
|
||||
// upload path reads the contents back through.
|
||||
//
|
||||
// Files are read through an os.Root rather than by path, so a symlink swapped
|
||||
// in between the scan and the upload still cannot reach outside the directory
|
||||
// the user named.
|
||||
type Source struct {
|
||||
Dir string
|
||||
Files []LocalFile
|
||||
TotalBytes int64
|
||||
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
// Scan walks dir, hashes what it finds, and returns the result. The caller must
|
||||
// Close the Source.
|
||||
func Scan(ctx context.Context, dir string, opts ScanOptions) (*Source, error) {
|
||||
if err := checkPatterns("include", opts.Include); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPatterns("exclude", opts.Exclude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
root, err := os.OpenRoot(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Source{Dir: abs, root: root}
|
||||
if err := s.walk(ctx, opts); err != nil {
|
||||
root.Close()
|
||||
return nil, err
|
||||
}
|
||||
if len(s.Files) == 0 {
|
||||
root.Close()
|
||||
return nil, fmt.Errorf("%s contains no files to deploy", dir)
|
||||
}
|
||||
if err := s.hash(ctx, opts.Concurrency); err != nil {
|
||||
root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the directory handle.
|
||||
func (s *Source) Close() error {
|
||||
if s == nil || s.root == nil {
|
||||
return nil
|
||||
}
|
||||
return s.root.Close()
|
||||
}
|
||||
|
||||
// Open reads one of the scanned files.
|
||||
func (s *Source) Open(p string) (*os.File, error) {
|
||||
return s.root.Open(filepath.FromSlash(p))
|
||||
}
|
||||
|
||||
// Manifest renders the scan as the wire form the server expects.
|
||||
func (s *Source) Manifest() []api.FileEntry {
|
||||
out := make([]api.FileEntry, len(s.Files))
|
||||
for i, f := range s.Files {
|
||||
out[i] = api.FileEntry{Path: f.Path, Digest: f.Digest, Size: f.Size}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UniqueBlobs counts distinct digests, which is what the deduplicating upload
|
||||
// actually has to deal with.
|
||||
func (s *Source) UniqueBlobs() int {
|
||||
seen := make(map[string]struct{}, len(s.Files))
|
||||
for _, f := range s.Files {
|
||||
seen[f.Digest] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// walk collects the paths and sizes. Hashing is a separate pass so it can run
|
||||
// concurrently over a list that is already known to be valid: finding out on
|
||||
// file 40,000 that file 3 has an unusable name would waste the whole scan.
|
||||
func (s *Source) walk(ctx context.Context, opts ScanOptions) error {
|
||||
set := pathutil.NewSet(0)
|
||||
return filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(s.Dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := filepath.ToSlash(rel)
|
||||
|
||||
if d.IsDir() {
|
||||
if slices.Contains(skipDirs, d.Name()) || matchAny(opts.Exclude, name) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WalkDir reports entry types from Lstat, so a symlink arrives as a
|
||||
// symlink and is never silently followed.
|
||||
var size int64
|
||||
switch {
|
||||
case d.Type()&fs.ModeSymlink != 0:
|
||||
if !opts.FollowSymlinks {
|
||||
return fmt.Errorf("%s is a symbolic link; a deployment holds regular files only "+
|
||||
"(pass --follow-symlinks to upload what it points at)", name)
|
||||
}
|
||||
fi, err := s.root.Stat(filepath.FromSlash(name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s points at a %s, not a regular file", name, kindOf(fi.Mode()))
|
||||
}
|
||||
size = fi.Size()
|
||||
case d.Type().IsRegular():
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size = fi.Size()
|
||||
default:
|
||||
return fmt.Errorf("%s is a %s; a deployment holds regular files only",
|
||||
name, kindOf(d.Type()))
|
||||
}
|
||||
|
||||
if !keep(opts, name) {
|
||||
return nil
|
||||
}
|
||||
// The same checks the server runs, so a name that could never be stored
|
||||
// is reported here — with the local path in hand — instead of as a
|
||||
// rejected manifest after the walk.
|
||||
if err := set.Add(name); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
s.Files = append(s.Files, LocalFile{Path: name, Size: size})
|
||||
s.TotalBytes += size
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// hash fills in every digest. It is CPU-bound on small files and IO-bound on
|
||||
// large ones, so it runs at GOMAXPROCS by default.
|
||||
func (s *Source) hash(ctx context.Context, concurrency int) error {
|
||||
if concurrency <= 0 {
|
||||
concurrency = runtime.GOMAXPROCS(0)
|
||||
}
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(concurrency)
|
||||
for i := range s.Files {
|
||||
g.Go(func() error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
f := &s.Files[i]
|
||||
digest, size, err := s.digest(f.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
// The file may have been rewritten between the walk and now. The
|
||||
// digest and the size have to describe the same bytes, so take both
|
||||
// from the read that produced the digest.
|
||||
f.Digest, f.Size = digest, size
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sorted output makes "pages deploy --dry-run" diffable between runs.
|
||||
slices.SortFunc(s.Files, func(a, b LocalFile) int {
|
||||
if a.Path < b.Path {
|
||||
return -1
|
||||
}
|
||||
if a.Path > b.Path {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
s.TotalBytes = 0
|
||||
for _, f := range s.Files {
|
||||
s.TotalBytes += f.Size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) digest(p string) (string, int64, error) {
|
||||
f, err := s.Open(p)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
// keep applies the include/exclude filters to a file.
|
||||
func keep(opts ScanOptions, name string) bool {
|
||||
if len(opts.Include) > 0 && !matchAny(opts.Include, name) {
|
||||
return false
|
||||
}
|
||||
return !matchAny(opts.Exclude, name)
|
||||
}
|
||||
|
||||
// matchAny reports whether name matches a pattern, either as a whole path or by
|
||||
// its base name. Matching the base name too is what makes "--exclude '*.map'"
|
||||
// behave the way everyone expects, since path.Match's "*" does not cross "/".
|
||||
func matchAny(patterns []string, name string) bool {
|
||||
base := path.Base(name)
|
||||
for _, pat := range patterns {
|
||||
if ok, _ := path.Match(pat, name); ok {
|
||||
return true
|
||||
}
|
||||
if ok, _ := path.Match(pat, base); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPatterns rejects malformed globs up front. path.Match reports a bad
|
||||
// pattern only when it is tried, so an unchecked one would silently match
|
||||
// nothing and quietly deploy the wrong file set.
|
||||
func checkPatterns(flag string, patterns []string) error {
|
||||
for _, pat := range patterns {
|
||||
if _, err := path.Match(pat, "x"); err != nil {
|
||||
return fmt.Errorf("--%s %q: %w", flag, pat, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kindOf(m fs.FileMode) string {
|
||||
switch {
|
||||
case m&fs.ModeDir != 0:
|
||||
return "directory"
|
||||
case m&fs.ModeSymlink != 0:
|
||||
return "symbolic link"
|
||||
case m&fs.ModeDevice != 0:
|
||||
return "device file"
|
||||
case m&fs.ModeNamedPipe != 0:
|
||||
return "named pipe"
|
||||
case m&fs.ModeSocket != 0:
|
||||
return "socket"
|
||||
default:
|
||||
return "special file"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user