This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+210
View File
@@ -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
}
+224
View File
@@ -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
}
}
+363
View File
@@ -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)
}
}
+492
View File
@@ -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])
}
+323
View File
@@ -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"
}
}