init
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigFile is the CLI's on-disk configuration.
|
||||
//
|
||||
// JSON rather than TOML: encoding/json is already linked into the binary for
|
||||
// the API, and adding a TOML parser to read four fields would cost more than
|
||||
// the file saves. CI never reads it at all — there, everything comes from
|
||||
// PAGES_* environment variables.
|
||||
type ConfigFile struct {
|
||||
Server string `json:"server,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfigPath is $PAGES_CONFIG, else $XDG_CONFIG_HOME/pages/config.json,
|
||||
// else ~/.config/pages/config.json. It returns "" when no home directory can be
|
||||
// determined, which simply means there is no config file.
|
||||
func DefaultConfigPath() string {
|
||||
if p := os.Getenv("PAGES_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "pages", "config.json")
|
||||
}
|
||||
|
||||
// LoadConfig reads path. A missing file is not an error — it is the normal
|
||||
// state on a CI runner. warn receives a note if the file is readable by anyone
|
||||
// but its owner, because it may hold a token.
|
||||
func LoadConfig(path string, warn io.Writer) (ConfigFile, error) {
|
||||
var f ConfigFile
|
||||
if path == "" {
|
||||
return f, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return f, nil
|
||||
}
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
|
||||
fmt.Fprintf(warn, "warning: %s is mode %#o and may contain a token; run: chmod 600 %s\n",
|
||||
path, fi.Mode().Perm(), path)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return ConfigFile{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes f to path with mode 0600, replacing any existing file
|
||||
// atomically so a crash cannot leave a truncated config behind.
|
||||
func SaveConfig(path string, f ConfigFile) error {
|
||||
if path == "" {
|
||||
return errors.New("no config path: set PAGES_CONFIG or pass --config")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
raw, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
|
||||
// Written in the destination directory so the rename stays within one
|
||||
// filesystem, and created 0600 from the start so the token is never briefly
|
||||
// world-readable.
|
||||
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary config: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("install config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// redactToken shows enough of a token to recognise which one it is and nothing
|
||||
// that could be used with it. The key id is the public half by construction
|
||||
// (pgs_<keyid>_<secret>), so it is safe to print in full.
|
||||
func redactToken(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(token, "_", 3)
|
||||
if len(parts) == 3 {
|
||||
return parts[0] + "_" + parts[1] + "_…"
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveConfigIsOwnerOnlyAndAtomic(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
want := ConfigFile{Server: "https://p.example.com", Token: "pgs_abcdefghijklmnop_secret", Output: "json"}
|
||||
if err := SaveConfig(path, want); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("config mode = %#o, want 0600 — it holds a token", perm)
|
||||
}
|
||||
if di, err := os.Stat(dir); err == nil {
|
||||
if perm := di.Mode().Perm(); perm&0o077 != 0 {
|
||||
t.Errorf("config directory mode = %#o, want no group or other bits", perm)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := LoadConfig(path, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// The temporary file is written in the destination directory; leaving one
|
||||
// behind would leave a mode-0600 copy of the token lying around.
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 || ents[0].Name() != "config.json" {
|
||||
names := make([]string, len(ents))
|
||||
for i, e := range ents {
|
||||
names[i] = e.Name()
|
||||
}
|
||||
t.Errorf("directory contains %q, want only config.json", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigReplacesInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := SaveConfig(path, ConfigFile{Server: "https://one.example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveConfig(path, ConfigFile{Server: "https://two.example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadConfig(path, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Server != "https://two.example.com" {
|
||||
t.Errorf("server = %q, want the second write", got.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
t.Run("a missing file is the normal state on CI", func(t *testing.T) {
|
||||
got, err := LoadConfig(filepath.Join(t.TempDir(), "absent.json"), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got != (ConfigFile{}) {
|
||||
t.Errorf("got %+v, want the zero value", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty path means there is no config file", func(t *testing.T) {
|
||||
if _, err := LoadConfig("", io.Discard); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON is reported, not ignored", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(path, []byte("{not json"), 0o600)
|
||||
if _, err := LoadConfig(path, io.Discard); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a readable-by-others config warns", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(path, []byte(`{"token":"pgs_abcdefghijklmnop_secret"}`), 0o644)
|
||||
var warn strings.Builder
|
||||
if _, err := LoadConfig(path, &warn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "chmod 600") {
|
||||
t.Errorf("warning = %q, want it to say how to fix the mode", warn.String())
|
||||
}
|
||||
if strings.Contains(warn.String(), "secret") {
|
||||
t.Error("the warning printed the token it was warning about")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactToken(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"pgs_abcdefghijklmnop_thesecrethalf", "pgs_abcdefghijklmnop_…"},
|
||||
{"", ""},
|
||||
{"garbage", "…"},
|
||||
{"pgs_onlytwo", "…"},
|
||||
// The secret half is base64url, so it can itself contain underscores;
|
||||
// SplitN with n=3 keeps them in the part that gets dropped.
|
||||
{"pgs_abcdefghijklmnop_a_b_c", "pgs_abcdefghijklmnop_…"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := redactToken(tc.in); got != tc.want {
|
||||
t.Errorf("redactToken(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigPathPrefersEnv(t *testing.T) {
|
||||
t.Setenv("PAGES_CONFIG", "/tmp/explicit.json")
|
||||
if got := DefaultConfigPath(); got != "/tmp/explicit.json" {
|
||||
t.Errorf("DefaultConfigPath = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// stringList is a flag that may be given more than once.
|
||||
type stringList []string
|
||||
|
||||
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||
|
||||
func (s *stringList) Set(v string) error {
|
||||
*s = append(*s, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deployCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
activate = true
|
||||
meta stringList
|
||||
include stringList
|
||||
exclude stringList
|
||||
concurrency int
|
||||
retries int
|
||||
follow bool
|
||||
dryRun bool
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "deploy",
|
||||
Args: "<dir>",
|
||||
Short: "Upload a directory and switch the site to it",
|
||||
Long: "Hashes the directory, uploads only the files the server does not\n" +
|
||||
"already have, then switches the project over in one step — a visitor\n" +
|
||||
"sees the old site or the new one, never a mixture.\n\n" +
|
||||
"Interrupting a deploy is safe: the blobs that made it are kept, so\n" +
|
||||
"running the same command again uploads only what is still missing.\n\n" +
|
||||
"Commit metadata is read from the CI environment (GitHub Actions,\n" +
|
||||
"GitLab CI) and can be set or overridden with --meta.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&activate, "activate", true, "switch the project to this deployment once it is uploaded")
|
||||
fs.Var(&meta, "meta", "`key=value` recorded with the deployment; repeatable")
|
||||
fs.Var(&include, "include", "only upload files matching `glob`; repeatable")
|
||||
fs.Var(&exclude, "exclude", "skip files and directories matching `glob`; repeatable")
|
||||
fs.IntVar(&concurrency, "concurrency", 8, "`number` of blobs uploaded at once")
|
||||
fs.IntVar(&retries, "retries", 4, "`number` of extra attempts per blob on a transient failure")
|
||||
fs.BoolVar(&follow, "follow-symlinks", false, "upload what symlinks point at instead of refusing them")
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "scan and report without contacting the server")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := args[0]
|
||||
|
||||
metaMap, err := parseMeta(meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := client.Scan(ctx, dir, client.ScanOptions{
|
||||
Include: include,
|
||||
Exclude: exclude,
|
||||
FollowSymlinks: follow,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if dryRun {
|
||||
// Deliberately offline: negotiating the manifest to find out what is
|
||||
// missing would create a deployment on the server, which is exactly
|
||||
// what --dry-run promises not to do.
|
||||
return p.Print(dryRunResult{
|
||||
Dir: src.Dir,
|
||||
FileCount: len(src.Files),
|
||||
TotalBytes: src.TotalBytes,
|
||||
UniqueBlobs: src.UniqueBlobs(),
|
||||
Files: src.Files,
|
||||
}, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("PATH", "SIZE", "DIGEST")
|
||||
for _, f := range src.Files {
|
||||
t.Row(f.Path, cliutil.Bytes(f.Size), f.Digest[:12])
|
||||
}
|
||||
t.Row("", "", "")
|
||||
t.Row(fmt.Sprintf("%d files", len(src.Files)),
|
||||
cliutil.Bytes(src.TotalBytes),
|
||||
fmt.Sprintf("%d unique", src.UniqueBlobs()))
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Deploy(ctx, client.DeployOptions{
|
||||
Project: project,
|
||||
Source: src,
|
||||
Meta: metaMap,
|
||||
Activate: activate,
|
||||
Concurrency: concurrency,
|
||||
Retries: retries,
|
||||
// Progress goes to stderr so it stays out of a piped -o json
|
||||
// document, and is shown even without --verbose: the deduplication
|
||||
// win is the reason this tool exists, and a CI log should record it.
|
||||
Progress: func(msg string) { fmt.Fprintln(g.Err, msg) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.Print(res, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("deployment", res.Deployment.ID)
|
||||
t.Row("project", project)
|
||||
t.Row("state", res.Deployment.State)
|
||||
t.Row("files", strconv.Itoa(res.FileCount))
|
||||
t.Row("total_bytes", cliutil.Bytes(res.TotalBytes))
|
||||
t.Row("uploaded", fmt.Sprintf("%s, %s",
|
||||
cliutil.Plural(res.Uploaded, "blob"), cliutil.Bytes(res.UploadedBytes)))
|
||||
t.Row("reused", strconv.Itoa(res.Deduplicated))
|
||||
t.Row("activated", cliutil.Bool(res.Activated))
|
||||
t.Row("url", cliutil.Str(res.URL))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dryRunResult is what --dry-run reports: everything decided locally, and
|
||||
// nothing that would need the server.
|
||||
type dryRunResult struct {
|
||||
Dir string `json:"dir"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UniqueBlobs int `json:"unique_blobs"`
|
||||
Files []client.LocalFile `json:"files"`
|
||||
}
|
||||
|
||||
// parseMeta folds --meta over whatever the CI environment reveals, so an
|
||||
// explicit flag always wins over a guessed value.
|
||||
func parseMeta(pairs []string) (map[string]string, error) {
|
||||
out := detectCIMeta()
|
||||
for _, p := range pairs {
|
||||
k, v, ok := strings.Cut(p, "=")
|
||||
if !ok {
|
||||
return nil, cliutil.UsageErrorf("--meta %q: expected key=value", p)
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
return nil, cliutil.UsageErrorf("--meta %q: empty key", p)
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string]string, len(pairs))
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// detectCIMeta reads the commit metadata the common CI systems export, so a
|
||||
// deployment can be traced back to what produced it without every pipeline
|
||||
// having to spell out the same four --meta flags.
|
||||
func detectCIMeta() map[string]string {
|
||||
out := make(map[string]string, 4)
|
||||
set := func(key string, envs ...string) {
|
||||
for _, e := range envs {
|
||||
if v := strings.TrimSpace(os.Getenv(e)); v != "" {
|
||||
out[key] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
set("git_sha", "GITHUB_SHA", "CI_COMMIT_SHA", "GIT_COMMIT")
|
||||
set("git_ref", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "GIT_BRANCH")
|
||||
set("ci_run", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "BUILD_NUMBER")
|
||||
set("actor", "GITHUB_ACTOR", "GITLAB_USER_LOGIN")
|
||||
|
||||
if url := ciRunURL(); url != "" {
|
||||
out["ci_url"] = url
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ciRunURL reconstructs a link back to the job. GitHub does not export one
|
||||
// directly; GitLab does.
|
||||
func ciRunURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CI_PIPELINE_URL")); v != "" {
|
||||
return v
|
||||
}
|
||||
server := strings.TrimRight(os.Getenv("GITHUB_SERVER_URL"), "/")
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
run := os.Getenv("GITHUB_RUN_ID")
|
||||
if server != "" && repo != "" && run != "" {
|
||||
return server + "/" + repo + "/actions/runs/" + run
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
func deploymentCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "deployment",
|
||||
Short: "Inspect, roll back and remove deployments",
|
||||
Long: "Every deployment a project has ever finished is kept until retention\n" +
|
||||
"drops it, which is what makes a rollback one command rather than a\n" +
|
||||
"rebuild. A project key may manage only its own project's deployments.",
|
||||
Sub: []*cliutil.Command{
|
||||
deploymentListCmd(g),
|
||||
deploymentShowCmd(g),
|
||||
deploymentActivateCmd(g),
|
||||
deploymentDeleteCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentListCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
state string
|
||||
limit int
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List a project's deployments",
|
||||
Long: "Newest first. The one marked active is what the site is serving, and\n" +
|
||||
"is the one to roll back from; any other ready deployment can be rolled\n" +
|
||||
"back to with \"pages deployment activate\".",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&state, "state", "", "show only deployments in this `state`: pending, uploading, ready, failed or deleting")
|
||||
fs.IntVar(&limit, "limit", 0, "stop after this many deployments; 0 lists them all")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps, err := listDeployments(ctx, c, project, state, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The API's own shape, so -o json is the same document whether it came
|
||||
// from here or from curl, and an empty listing prints as [] not null.
|
||||
out := api.DeploymentList{Deployments: deps}
|
||||
if out.Deployments == nil {
|
||||
out.Deployments = []api.Deployment{}
|
||||
}
|
||||
return p.Print(out, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("ID", "STATE", "ACTIVE", "FILES", "SIZE", "CREATED", "COMMIT")
|
||||
for _, d := range deps {
|
||||
t.Row(d.ID, d.State, cliutil.Bool(d.Active),
|
||||
strconv.Itoa(d.FileCount), cliutil.Bytes(d.TotalBytes),
|
||||
cliutil.Time(d.CreatedAt),
|
||||
cliutil.Str(cliutil.Truncate(d.Meta["git_sha"], 12)))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// listDeployments follows the cursor, stopping at limit when one was given.
|
||||
// Paging on the caller's behalf matters here for the same reason it does for
|
||||
// projects: a listing that silently showed the first page would be a lie, and
|
||||
// this one is read to decide which deployment to roll back to.
|
||||
func listDeployments(ctx context.Context, c *client.Client, project, state string, limit int) ([]api.Deployment, error) {
|
||||
opts := client.DeploymentListOptions{State: state}
|
||||
opts.Limit = 500
|
||||
if limit > 0 && limit < opts.Limit {
|
||||
opts.Limit = limit
|
||||
}
|
||||
var all []api.Deployment
|
||||
for {
|
||||
page, err := c.ListDeployments(ctx, project, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page.Deployments...)
|
||||
if limit > 0 && len(all) >= limit {
|
||||
return all[:limit], nil
|
||||
}
|
||||
if page.NextCursor == "" || len(page.Deployments) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
opts.Cursor = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentShowCmd(g *Globals) *cliutil.Command {
|
||||
var files bool
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Args: "<id>",
|
||||
Short: "Show one deployment",
|
||||
Long: "With --files, also lists the manifest: every path, its size and the\n" +
|
||||
"digest of its content. That is one line per file, so it is a lot of\n" +
|
||||
"output for a large site.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&files, "files", false, "also list the deployment's files")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := c.GetDeployment(ctx, project, args[0], files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(d, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("id", d.ID)
|
||||
t.Row("project", d.Project)
|
||||
t.Row("state", d.State)
|
||||
t.Row("active", cliutil.Bool(d.Active))
|
||||
t.Row("files", strconv.Itoa(d.FileCount))
|
||||
t.Row("total_bytes", cliutil.Bytes(d.TotalBytes))
|
||||
t.Row("created_at", cliutil.Time(d.CreatedAt))
|
||||
t.Row("finalized_at", cliutil.TimePtr(d.FinalizedAt))
|
||||
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
|
||||
if d.URL != "" {
|
||||
t.Row("url", d.URL)
|
||||
}
|
||||
if d.Error != "" {
|
||||
t.Row("error", d.Error)
|
||||
}
|
||||
for _, k := range sortedKeys(d.Meta) {
|
||||
t.Row("meta."+k, d.Meta[k])
|
||||
}
|
||||
for _, f := range d.Files {
|
||||
t.Row(f.Path, fmt.Sprintf("%s %s",
|
||||
cliutil.Bytes(f.Size), cliutil.Truncate(f.Digest, 12)))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys gives map-backed output a stable order, so two runs of the same
|
||||
// command produce the same lines and a diff of them means something.
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
return slices.Sorted(maps.Keys(m))
|
||||
}
|
||||
|
||||
func deploymentActivateCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "activate",
|
||||
Args: "<id>",
|
||||
Short: "Switch the site to a deployment",
|
||||
Long: "This is how a rollback is done: name an older deployment and the\n" +
|
||||
"project serves it again. The switch is atomic and costs nothing —\n" +
|
||||
"the content is still on disk — so it takes effect immediately.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := c.Activate(ctx, project, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(d, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("deployment", d.ID)
|
||||
t.Row("project", project)
|
||||
t.Row("state", d.State)
|
||||
t.Row("active", cliutil.Bool(d.Active))
|
||||
t.Row("files", strconv.Itoa(d.FileCount))
|
||||
t.Row("url", cliutil.Str(d.URL))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentDeleteCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "delete",
|
||||
Args: "<id>",
|
||||
Short: "Delete a deployment",
|
||||
Long: "The deployment the project is serving cannot be deleted; activate\n" +
|
||||
"another one first. Content no other deployment references is\n" +
|
||||
"reclaimed by the next garbage collection, not immediately.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
id := args[0]
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err,
|
||||
fmt.Sprintf("Delete deployment %s of project %q?", id, project)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.DeleteDeployment(ctx, project, id); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("deleted deployment %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ upkeep
|
||||
//
|
||||
// These two are server-wide rather than per-project, which is why they sit
|
||||
// under "pages system" next to "system info" and not under "deployment".
|
||||
|
||||
func systemGCCmd(g *Globals) *cliutil.Command {
|
||||
var dryRun bool
|
||||
return &cliutil.Command{
|
||||
Name: "gc",
|
||||
Short: "Run a garbage collection pass now",
|
||||
Long: "The server collects on a timer anyway; this is for an operator who\n" +
|
||||
"wants the disk back sooner. --dry-run reports what would be deleted\n" +
|
||||
"without deleting it, though it cannot count the content the listed\n" +
|
||||
"deployments hold — nothing was deleted, so it is all still in use.\n" +
|
||||
"Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "report what would be deleted without deleting it")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := c.Collect(ctx, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(stats, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("dry_run", cliutil.Bool(stats.DryRun))
|
||||
t.Row("deployments_deleted", strconv.Itoa(stats.DeploymentsDeleted))
|
||||
t.Row("blobs_deleted", strconv.Itoa(stats.BlobsDeleted))
|
||||
t.Row("bytes_freed", cliutil.Bytes(stats.BytesFreed))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func systemFsckCmd(g *Globals) *cliutil.Command {
|
||||
var repair bool
|
||||
return &cliutil.Command{
|
||||
Name: "fsck",
|
||||
Short: "Check the stored reference counts against the manifests",
|
||||
Long: "On a healthy server this always reports no drift: the counts are\n" +
|
||||
"maintained by database triggers. It is for the cases outside normal\n" +
|
||||
"operation — a restored backup, a database edited by hand — because a\n" +
|
||||
"count that reads low is content the collector will delete while a\n" +
|
||||
"deployment still needs it. --repair rewrites the counts from the\n" +
|
||||
"manifests. Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&repair, "repair", false, "correct the counts that disagree")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep, err := c.Fsck(ctx, repair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(rep, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("blobs", strconv.FormatInt(rep.Blobs, 10))
|
||||
t.Row("drift", strconv.Itoa(rep.DriftCount))
|
||||
t.Row("repaired", strconv.Itoa(rep.Repaired))
|
||||
for _, d := range rep.Drift {
|
||||
t.Row(cliutil.Truncate(d.Digest, 12),
|
||||
fmt.Sprintf("stored %d, actual %d", d.Stored, d.Actual))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// cliToken is syntactically plausible and otherwise meaningless: none of the
|
||||
// fake servers below look at it, but Globals refuses to build a client without
|
||||
// one.
|
||||
const cliToken = "pgs_abcdefghijklmnop_secret"
|
||||
|
||||
// runCLI drives the real command tree the way main does and returns what the
|
||||
// user would have seen. Going through Root rather than calling a command's Exec
|
||||
// directly is the point: it covers the flag registration and the dispatch that
|
||||
// a hand-built call would skip.
|
||||
func runCLI(t *testing.T, server, stdin string, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", cliToken)
|
||||
var out, errOut strings.Builder
|
||||
g := &Globals{
|
||||
In: strings.NewReader(stdin),
|
||||
Out: &out,
|
||||
Err: &errOut,
|
||||
Config: filepath.Join(t.TempDir(), "absent.json"),
|
||||
Server: server,
|
||||
}
|
||||
err = cliutil.Run(context.Background(), Root(g), args, &errOut, g.Register)
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDeployment(id string) api.Deployment {
|
||||
return api.Deployment{
|
||||
ID: id, Project: "demo", State: "ready",
|
||||
FileCount: 2, TotalBytes: 4096,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// indexOf is strings.Index with a failure message, used to assert ordering.
|
||||
func indexOf(t *testing.T, haystack, needle string) int {
|
||||
t.Helper()
|
||||
i := strings.Index(haystack, needle)
|
||||
if i < 0 {
|
||||
t.Fatalf("output does not mention %q:\n%s", needle, haystack)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// TestDeploymentListFollowsTheCursor: the listing is read to decide which
|
||||
// deployment to roll back to, so one that silently showed the first page would
|
||||
// be worse than one that failed.
|
||||
func TestDeploymentListFollowsTheCursor(t *testing.T) {
|
||||
var queries []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
queries = append(queries, r.URL.RawQuery)
|
||||
if r.URL.Query().Get("cursor") == "dpl_2" {
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_1")},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
||||
NextCursor: "dpl_2",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
// Newest first, as the server returned them: the order is what tells the
|
||||
// reader which one is the previous release.
|
||||
first := indexOf(t, out, "dpl_3")
|
||||
second := indexOf(t, out, "dpl_2")
|
||||
third := indexOf(t, out, "dpl_1")
|
||||
if !(first < second && second < third) {
|
||||
t.Errorf("rows out of order:\n%s", out)
|
||||
}
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("made %d requests (%q), want 2", len(queries), queries)
|
||||
}
|
||||
if !strings.Contains(queries[1], "cursor=dpl_2") {
|
||||
t.Errorf("second request query = %q, want the cursor from the first page", queries[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentListStopsAtTheLimit — the server here always offers another
|
||||
// page, so a --limit that was not honoured would page until the test timed out.
|
||||
func TestDeploymentListStopsAtTheLimit(t *testing.T) {
|
||||
var requests int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if requests > 4 {
|
||||
t.Errorf("still paging after %d requests; --limit was ignored", requests)
|
||||
writeJSON(t, w, api.DeploymentList{})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
||||
NextCursor: "dpl_2",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo", "--limit", "2")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Errorf("made %d requests, want 1: two rows already satisfy --limit 2", requests)
|
||||
}
|
||||
if !strings.Contains(out, "dpl_3") || !strings.Contains(out, "dpl_2") {
|
||||
t.Errorf("output is missing a row:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentListEmptyIsAnEmptyArray: -o json is what a CI step parses, and
|
||||
// jq treats null and [] very differently.
|
||||
func TestDeploymentListEmptyIsAnEmptyArray(t *testing.T) {
|
||||
var query string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query = r.URL.RawQuery
|
||||
writeJSON(t, w, api.DeploymentList{})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "",
|
||||
"deployment", "list", "--project", "demo", "--state", "failed", "-o", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
if !strings.Contains(query, "state=failed") {
|
||||
t.Errorf("query = %q, want the state filter", query)
|
||||
}
|
||||
if !strings.Contains(out, `"deployments": []`) {
|
||||
t.Errorf("output = %s, want an empty array", out)
|
||||
}
|
||||
if strings.Contains(out, "null") {
|
||||
t.Errorf("output = %s, want no null", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentShowAsksForFilesOnlyWhenTold — the manifest is one line per
|
||||
// file, so a large site's would drown the rest of the output.
|
||||
func TestDeploymentShowAsksForFilesOnlyWhenTold(t *testing.T) {
|
||||
d := fakeDeployment("dpl_1")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
out := d
|
||||
if r.URL.Query().Get("files") == "true" {
|
||||
out.Files = []api.FileEntry{
|
||||
{Path: "assets/app.js", Digest: strings.Repeat("ab", 32), Size: 15},
|
||||
{Path: "index.html", Digest: strings.Repeat("cd", 32), Size: 42},
|
||||
}
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plain, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment show: %v", err)
|
||||
}
|
||||
if strings.Contains(plain, "assets/app.js") {
|
||||
t.Errorf("the manifest was printed without --files:\n%s", plain)
|
||||
}
|
||||
|
||||
full, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo", "--files")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment show --files: %v", err)
|
||||
}
|
||||
if !strings.Contains(full, "assets/app.js") || !strings.Contains(full, "index.html") {
|
||||
t.Errorf("--files did not list the manifest:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentDeleteAsksFirst. Deleting the wrong deployment is not
|
||||
// recoverable from the CLI, so the prompt is the safety net and --yes is the
|
||||
// documented way past it.
|
||||
func TestDeploymentDeleteAsksFirst(t *testing.T) {
|
||||
newServer := func(seen *[]string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*seen = append(*seen, r.Method+" "+r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("declining deletes nothing", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
_, errOut, err := runCLI(t, srv.URL, "n\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
||||
if !errors.Is(err, cliutil.ErrAborted) {
|
||||
t.Fatalf("err = %v, want it to report the abort", err)
|
||||
}
|
||||
if len(seen) != 0 {
|
||||
t.Errorf("requests = %q, want none", seen)
|
||||
}
|
||||
if !strings.Contains(errOut, "dpl_1") {
|
||||
t.Errorf("prompt = %q, want it to name the deployment", errOut)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("confirming deletes", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "y\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment delete: %v", err)
|
||||
}
|
||||
want := "DELETE " + api.PathDeployment("demo", "dpl_1")
|
||||
if len(seen) != 1 || seen[0] != want {
|
||||
t.Errorf("requests = %q, want [%q]", seen, want)
|
||||
}
|
||||
if !strings.Contains(out, "dpl_1") {
|
||||
t.Errorf("output = %q, want it to confirm what was deleted", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--yes does not prompt", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
// Empty stdin: without --yes this would abort rather than delete.
|
||||
_, errOut, err := runCLI(t, srv.URL, "", "deployment", "delete", "dpl_1", "--project", "demo", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment delete --yes: %v", err)
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Errorf("requests = %q, want one delete", seen)
|
||||
}
|
||||
if strings.Contains(errOut, "[y/N]") {
|
||||
t.Errorf("stderr = %q, want no prompt", errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSystemGCPostsTheDryRunFlag: a dry run that silently ran for real is the
|
||||
// worst bug this command could have, so the flag's trip to the wire is checked
|
||||
// rather than assumed.
|
||||
func TestSystemGCPostsTheDryRunFlag(t *testing.T) {
|
||||
var gotPath, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, 256)
|
||||
n, _ := r.Body.Read(body)
|
||||
gotPath, gotBody = r.Method+" "+r.URL.Path, string(body[:n])
|
||||
writeJSON(t, w, api.GCStats{DryRun: true, DeploymentsDeleted: 3, BlobsDeleted: 4, BytesFreed: 5120})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "system", "gc", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("system gc: %v", err)
|
||||
}
|
||||
if want := "POST " + api.PathGC(); gotPath != want {
|
||||
t.Errorf("request = %q, want %q", gotPath, want)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"dry_run":true`) {
|
||||
t.Errorf("body = %q, want dry_run set", gotBody)
|
||||
}
|
||||
for _, want := range []string{"dry_run", "yes", "deployments_deleted", "3", "blobs_deleted", "4"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemFsckReportsDrift — the report exists for the case where the counts
|
||||
// are wrong, so the drifting digests have to reach the operator's screen.
|
||||
func TestSystemFsckReportsDrift(t *testing.T) {
|
||||
digest := strings.Repeat("ab", 32)
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, 256)
|
||||
n, _ := r.Body.Read(body)
|
||||
gotBody = string(body[:n])
|
||||
writeJSON(t, w, api.FsckReport{
|
||||
Blobs: 9,
|
||||
DriftCount: 1,
|
||||
Repaired: 1,
|
||||
Drift: []api.BlobDrift{{Digest: digest, Stored: 7, Actual: 1}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "system", "fsck", "--repair")
|
||||
if err != nil {
|
||||
t.Fatalf("system fsck: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"repair":true`) {
|
||||
t.Errorf("body = %q, want repair set", gotBody)
|
||||
}
|
||||
// Truncate spends its last column on the ellipsis, so twelve columns of
|
||||
// digest are eleven characters and a marker that there is more.
|
||||
if !strings.Contains(out, digest[:11]+"…") {
|
||||
t.Errorf("output does not name the drifting blob:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "stored 7, actual 1") {
|
||||
t.Errorf("output does not give the counts:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package clicmd implements the pages subcommands.
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// Globals are the settings every command shares.
|
||||
//
|
||||
// Precedence is flag > environment > config file > default. Flags are parsed
|
||||
// into these fields first, so Resolve fills in only what is still empty — which
|
||||
// is why a flag given as the empty string counts as absent.
|
||||
type Globals struct {
|
||||
Server string
|
||||
Token string
|
||||
TokenFile string
|
||||
Project string
|
||||
Config string
|
||||
Output string
|
||||
Timeout time.Duration
|
||||
Verbose bool
|
||||
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
Err io.Writer
|
||||
|
||||
file ConfigFile
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewGlobals returns Globals wired to the process's standard streams.
|
||||
func NewGlobals() *Globals {
|
||||
return &Globals{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}
|
||||
}
|
||||
|
||||
// Register adds the global flags to fs.
|
||||
//
|
||||
// Every flag's default is the field's current value, so registering on a
|
||||
// subcommand's FlagSet preserves what an outer parse already set. Passing a
|
||||
// fixed default here would make "pages --server X project list" lose --server
|
||||
// the moment the subcommand registered its own copy.
|
||||
func (g *Globals) Register(fs *flag.FlagSet) {
|
||||
fs.StringVar(&g.Server, "server", g.Server, "management API base `URL` (env PAGES_SERVER)")
|
||||
fs.StringVar(&g.Token, "token", g.Token,
|
||||
"API `token`; avoid it — argv is world-readable via /proc on shared runners, so prefer --token-file or PAGES_TOKEN")
|
||||
fs.StringVar(&g.TokenFile, "token-file", g.TokenFile, "read the API token from `path` (env PAGES_TOKEN_FILE)")
|
||||
fs.StringVar(&g.Project, "project", g.Project, "project `name` to operate on (env PAGES_PROJECT)")
|
||||
fs.StringVar(&g.Config, "config", g.Config, "config file `path` (env PAGES_CONFIG)")
|
||||
fs.StringVar(&g.Output, "o", g.Output, "output `format`: table or json (env PAGES_OUTPUT)")
|
||||
fs.StringVar(&g.Output, "output", g.Output, "output `format`: table or json")
|
||||
fs.DurationVar(&g.Timeout, "timeout", g.Timeout, "per-request `timeout`")
|
||||
fs.BoolVar(&g.Verbose, "v", g.Verbose, "verbose progress on stderr")
|
||||
fs.BoolVar(&g.Verbose, "verbose", g.Verbose, "verbose progress on stderr")
|
||||
}
|
||||
|
||||
// Resolve applies the environment, then the config file, to anything the flags
|
||||
// did not set. It runs once; later calls are no-ops.
|
||||
func (g *Globals) Resolve() error {
|
||||
if g.resolved {
|
||||
return nil
|
||||
}
|
||||
g.resolved = true
|
||||
|
||||
if g.Config == "" {
|
||||
g.Config = DefaultConfigPath()
|
||||
}
|
||||
f, err := LoadConfig(g.Config, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.file = f
|
||||
|
||||
g.Server = firstNonEmpty(g.Server, os.Getenv("PAGES_SERVER"), f.Server)
|
||||
g.Project = firstNonEmpty(g.Project, os.Getenv("PAGES_PROJECT"), f.Project)
|
||||
|
||||
if err := g.resolveToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.Output = firstNonEmpty(g.Output, os.Getenv("PAGES_OUTPUT"), f.Output, cliutil.FormatTable)
|
||||
if !cliutil.ValidFormat(g.Output) {
|
||||
return fmt.Errorf("unknown output format %q: use table or json", g.Output)
|
||||
}
|
||||
|
||||
if g.Timeout == 0 {
|
||||
if s := os.Getenv("PAGES_TIMEOUT"); s != "" {
|
||||
d, err := cliutil.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PAGES_TIMEOUT: %w", err)
|
||||
}
|
||||
g.Timeout = d
|
||||
}
|
||||
}
|
||||
if g.Timeout <= 0 {
|
||||
g.Timeout = client.DefaultTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveToken picks the token from the most specific source that has one:
|
||||
// --token, --token-file, $PAGES_TOKEN, $PAGES_TOKEN_FILE, config file.
|
||||
func (g *Globals) resolveToken() error {
|
||||
if g.Token != "" {
|
||||
// Only a flag can have set this: the environment and the config file are
|
||||
// read below. Say so once — on a shared runner every other user can read
|
||||
// this token out of /proc/<pid>/cmdline for as long as the process lives.
|
||||
fmt.Fprintln(g.Err, "warning: --token puts the token in the process list; prefer --token-file or PAGES_TOKEN")
|
||||
return nil
|
||||
}
|
||||
if g.TokenFile != "" {
|
||||
t, err := readTokenFile(g.TokenFile, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Token = t
|
||||
return nil
|
||||
}
|
||||
if t := os.Getenv("PAGES_TOKEN"); t != "" {
|
||||
g.Token = strings.TrimSpace(t)
|
||||
return nil
|
||||
}
|
||||
if p := os.Getenv("PAGES_TOKEN_FILE"); p != "" {
|
||||
t, err := readTokenFile(p, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Token = t
|
||||
return nil
|
||||
}
|
||||
g.Token = g.file.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
// readTokenFile reads a token, tolerating the trailing newline that every text
|
||||
// editor and `pages key create ... > token` adds.
|
||||
func readTokenFile(path string, warn io.Writer) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token file: %w", err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
|
||||
fmt.Fprintf(warn, "warning: token file %s is mode %#o; run: chmod 600 %s\n",
|
||||
path, fi.Mode().Perm(), path)
|
||||
}
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("token file %s is empty", path)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Client builds an API client from the resolved settings.
|
||||
func (g *Globals) Client() (*client.Client, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.New(client.Config{
|
||||
BaseURL: g.Server,
|
||||
Token: g.Token,
|
||||
Timeout: g.Timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// Printer renders command output in the requested format.
|
||||
func (g *Globals) Printer() (*cliutil.Printer, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cliutil.Printer{Out: g.Out, Format: g.Output}, nil
|
||||
}
|
||||
|
||||
// ProjectName returns the project to operate on, or an explanation of how to
|
||||
// name one.
|
||||
func (g *Globals) ProjectName() (string, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.Project == "" {
|
||||
return "", errors.New("no project: pass --project or set PAGES_PROJECT")
|
||||
}
|
||||
return g.Project, nil
|
||||
}
|
||||
|
||||
// logf writes a progress note to stderr under --verbose.
|
||||
func (g *Globals) logf(format string, args ...any) {
|
||||
if g.Verbose {
|
||||
fmt.Fprintf(g.Err, format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// exactArgs is the argument-count check every leaf command starts with. It
|
||||
// reports a usage error, so the command's own usage text follows the message.
|
||||
func exactArgs(args []string, n int, want string) error {
|
||||
if len(args) != n {
|
||||
return cliutil.UsageErrorf("expected %s, got %d argument(s)", want, len(args))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// clearEnv makes a test independent of whatever the developer has exported.
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"PAGES_SERVER", "PAGES_TOKEN", "PAGES_TOKEN_FILE",
|
||||
"PAGES_PROJECT", "PAGES_OUTPUT", "PAGES_TIMEOUT", "PAGES_CONFIG",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig returns the path to a config file holding f.
|
||||
func writeConfig(t *testing.T, f ConfigFile) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := SaveConfig(path, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// newTestGlobals is what NewGlobals would give a command, with the streams
|
||||
// captured and the config file pinned so no real one can interfere.
|
||||
func newTestGlobals(config string) (*Globals, *strings.Builder, *strings.Builder) {
|
||||
var out, errOut strings.Builder
|
||||
return &Globals{In: strings.NewReader(""), Out: &out, Err: &errOut, Config: config}, &out, &errOut
|
||||
}
|
||||
|
||||
// TestPrecedence is the rule stated in the help text: flag, then environment,
|
||||
// then config file, then default. It is easy to get subtly wrong, and wrong
|
||||
// here means a CI job deploying to the wrong server.
|
||||
func TestPrecedence(t *testing.T) {
|
||||
file := ConfigFile{Server: "https://file.example.com", Project: "fileproj", Output: "json"}
|
||||
|
||||
t.Run("flag wins", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_SERVER", "https://env.example.com")
|
||||
t.Setenv("PAGES_PROJECT", "envproj")
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
g.Server, g.Project = "https://flag.example.com", "flagproj"
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://flag.example.com" || g.Project != "flagproj" {
|
||||
t.Errorf("server=%q project=%q, want the flag values", g.Server, g.Project)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("environment beats the config file", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_SERVER", "https://env.example.com")
|
||||
t.Setenv("PAGES_PROJECT", "envproj")
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://env.example.com" || g.Project != "envproj" {
|
||||
t.Errorf("server=%q project=%q, want the environment values", g.Server, g.Project)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the config file is the last word before defaults", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://file.example.com" || g.Project != "fileproj" || g.Output != "json" {
|
||||
t.Errorf("server=%q project=%q output=%q, want the file values", g.Server, g.Project, g.Output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults fill the rest", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Output != cliutil.FormatTable {
|
||||
t.Errorf("output = %q, want %q", g.Output, cliutil.FormatTable)
|
||||
}
|
||||
if g.Timeout <= 0 {
|
||||
t.Errorf("timeout = %v, want a positive default", g.Timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlagsSurviveTheSubcommandParse covers the trap that makes this design
|
||||
// work: every global flag is re-registered on each subcommand's FlagSet, and
|
||||
// registering with a fixed default would wipe a value given before the
|
||||
// subcommand name.
|
||||
func TestFlagsSurviveTheSubcommandParse(t *testing.T) {
|
||||
clearEnv(t)
|
||||
cfg := writeConfig(t, ConfigFile{})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--server", "https://flag.example.com", "--config", cfg, "probe", "demo"},
|
||||
{"probe", "--server", "https://flag.example.com", "--config", cfg, "demo"},
|
||||
{"--server", "https://flag.example.com", "probe", "demo", "--config", cfg},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
g, _, _ := newTestGlobals("")
|
||||
var gotProject string
|
||||
root := &cliutil.Command{
|
||||
Name: "pages",
|
||||
Sub: []*cliutil.Command{{
|
||||
Name: "probe",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 1 {
|
||||
gotProject = args[0]
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := cliutil.Run(context.Background(), root, args, io.Discard, g.Register); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if g.Server != "https://flag.example.com" {
|
||||
t.Errorf("server = %q, want the flag to survive dispatch", g.Server)
|
||||
}
|
||||
if gotProject != "demo" {
|
||||
t.Errorf("positional = %q, want demo", gotProject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPrecedence(t *testing.T) {
|
||||
const (
|
||||
flagTok = "pgs_flagflagflagfl_secret"
|
||||
flagFileTok = "pgs_flagfileflagfi_secret"
|
||||
envTok = "pgs_envenvenvenven_secret"
|
||||
envFileTok = "pgs_envfileenvfile_secret"
|
||||
fileTok = "pgs_fileconfigfile_secret"
|
||||
)
|
||||
tokenFile := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("--token wins but warns", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
g, _, errOut := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
g.Token = flagTok
|
||||
g.TokenFile = tokenFile(t, flagFileTok)
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != flagTok {
|
||||
t.Errorf("token = %q, want the flag", g.Token)
|
||||
}
|
||||
// argv is world-readable through /proc on a shared runner, so this
|
||||
// warning is the whole reason --token is documented as a last resort.
|
||||
if !strings.Contains(errOut.String(), "process list") {
|
||||
t.Errorf("stderr = %q, want a warning about the process list", errOut.String())
|
||||
}
|
||||
if strings.Contains(errOut.String(), "secret") {
|
||||
t.Error("the warning printed the token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--token-file beats the environment", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
g.TokenFile = tokenFile(t, flagFileTok)
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != flagFileTok {
|
||||
t.Errorf("token = %q, want the one from --token-file", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PAGES_TOKEN beats PAGES_TOKEN_FILE", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != envTok {
|
||||
t.Errorf("token = %q, want PAGES_TOKEN", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PAGES_TOKEN_FILE beats the config file", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != envFileTok {
|
||||
t.Errorf("token = %q, want PAGES_TOKEN_FILE", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the config file is the fallback", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != fileTok {
|
||||
t.Errorf("token = %q, want the config file's", g.Token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTokenFileTrimsTrailingNewline: `pages key create ... > token` and every
|
||||
// text editor add one.
|
||||
func TestTokenFileTrimsTrailingNewline(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret\n"), 0o600)
|
||||
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != "pgs_abcdefghijklmnop_secret" {
|
||||
t.Errorf("token = %q, want the newline trimmed", g.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFileProblemsAreReported(t *testing.T) {
|
||||
t.Run("missing", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = filepath.Join(t.TempDir(), "nope")
|
||||
if err := g.Resolve(); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("\n\n"), 0o600)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
err := g.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("err = %v, want it to say the file is empty", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("world readable warns", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret"), 0o644)
|
||||
g, _, errOut := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "chmod 600") {
|
||||
t.Errorf("stderr = %q, want a mode warning", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownOutputFormat(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.Output = "yaml"
|
||||
err := g.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "table or json") {
|
||||
t.Fatalf("err = %v, want it to list the formats", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveParsesFriendlyTimeouts(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TIMEOUT", "2m")
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Timeout.Minutes() != 2 {
|
||||
t.Errorf("timeout = %v, want 2m", g.Timeout)
|
||||
}
|
||||
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TIMEOUT", "later")
|
||||
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
err := g2.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_TIMEOUT") {
|
||||
t.Fatalf("err = %v, want it to name the variable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNameExplainsHowToSetIt(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
_, err := g.ProjectName()
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_PROJECT") {
|
||||
t.Fatalf("err = %v, want it to name the flag and the variable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientNeedsServerAndToken: the two settings a fresh CI job forgets.
|
||||
func TestClientNeedsServerAndToken(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if _, err := g.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_SERVER") {
|
||||
t.Fatalf("err = %v, want it to name PAGES_SERVER", err)
|
||||
}
|
||||
|
||||
clearEnv(t)
|
||||
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g2.Server = "https://p.example.com"
|
||||
if _, err := g2.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_TOKEN") {
|
||||
t.Fatalf("err = %v, want it to name PAGES_TOKEN", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
func keyCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "key",
|
||||
Short: "Manage API keys",
|
||||
Sub: []*cliutil.Command{
|
||||
keyCreateCmd(g),
|
||||
keyListCmd(g),
|
||||
keyRevokeCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyCreateCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
name string
|
||||
admin bool
|
||||
expires string
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "create",
|
||||
Short: "Mint a key",
|
||||
Long: "The token is printed once and cannot be retrieved again — the server\n" +
|
||||
"stores only its hash. Redirect it straight into a file or a secret\n" +
|
||||
"store; do not let it reach a CI log.\n\n" +
|
||||
"Without --admin the key is scoped to one project and can do nothing\n" +
|
||||
"outside it. Requires an admin key either way.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&name, "name", "", "`label` recorded with the key, e.g. github-actions")
|
||||
fs.BoolVar(&admin, "admin", false, "mint an admin key instead of a project key")
|
||||
fs.StringVar(&expires, "expires", "", "expire after this `duration`, e.g. 90d; default never")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
req := api.CreateKeyRequest{Name: name}
|
||||
if expires != "" {
|
||||
d, err := cliutil.ParseDuration(expires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d <= 0 {
|
||||
return errors.New("--expires must be positive")
|
||||
}
|
||||
// Computed here, sent absolute: a clock difference between this
|
||||
// machine and the server then shifts nothing.
|
||||
t := time.Now().Add(d).UTC().Truncate(time.Second)
|
||||
req.ExpiresAt = &t
|
||||
}
|
||||
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out api.CreateKeyResponse
|
||||
if admin {
|
||||
out, err = c.CreateAdminKey(ctx, req)
|
||||
} else {
|
||||
var project string
|
||||
if project, err = g.ProjectName(); err != nil {
|
||||
return fmt.Errorf("%w, or pass --admin for a server-wide key", err)
|
||||
}
|
||||
out, err = c.CreateProjectKey(ctx, project, req)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Format == cliutil.FormatJSON {
|
||||
return p.JSON(out)
|
||||
}
|
||||
// Table format prints the token on a line of its own so it survives
|
||||
// a copy-paste and so `pages key create | tail -1` is not tempting.
|
||||
if err := keyTable(out.Key).Write(g.Out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(g.Out, "\n%s\n", out.Token)
|
||||
fmt.Fprintln(g.Err, "this token is shown once and cannot be recovered; store it now")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyListCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List keys",
|
||||
Long: "With --project, lists that project's keys; a project key may list its\n" +
|
||||
"own. Without it, lists every key on the server and requires an admin\n" +
|
||||
"key. Secrets are never listed — only key ids.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var list api.KeyList
|
||||
if g.Project != "" {
|
||||
list, err = c.ListProjectKeys(ctx, g.Project)
|
||||
} else {
|
||||
list, err = c.ListKeys(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if list.Keys == nil {
|
||||
list.Keys = []api.Key{}
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(list, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("ID", "SCOPE", "PROJECT", "NAME", "CREATED", "EXPIRES", "LAST USED", "STATE")
|
||||
for _, k := range list.Keys {
|
||||
t.Row(k.ID, k.Scope, cliutil.Str(k.Project), cliutil.Str(k.Name),
|
||||
cliutil.Time(k.CreatedAt), cliutil.TimePtr(k.ExpiresAt),
|
||||
cliutil.TimePtr(k.LastUsed), keyState(k))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyRevokeCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "revoke",
|
||||
Args: "<key-id>",
|
||||
Short: "Revoke a key",
|
||||
Long: "Takes effect immediately across the server. The key id is the middle\n" +
|
||||
"segment of a token (pgs_<key-id>_<secret>) and is what `key list`\n" +
|
||||
"shows. Revoking an already-revoked key succeeds.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one key id"); err != nil {
|
||||
return err
|
||||
}
|
||||
id := args[0]
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err, fmt.Sprintf("Revoke key %s?", id)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RevokeKey(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("revoked key %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// keyState collapses the two timestamps that decide whether a key still works.
|
||||
func keyState(k api.Key) string {
|
||||
switch {
|
||||
case k.Revoked():
|
||||
return "revoked"
|
||||
case k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()):
|
||||
return "expired"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func keyTable(k api.Key) *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("id", k.ID)
|
||||
t.Row("scope", k.Scope)
|
||||
t.Row("project", cliutil.Str(k.Project))
|
||||
t.Row("name", cliutil.Str(k.Name))
|
||||
t.Row("created_at", cliutil.Time(k.CreatedAt))
|
||||
t.Row("expires_at", cliutil.TimePtr(k.ExpiresAt))
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// projectFlags are the settings shared by "project create" and "project
|
||||
// update". They map one-to-one onto api.ProjectPatch, whose fields are pointers
|
||||
// so an absent flag and an explicitly emptied one stay distinguishable.
|
||||
type projectFlags struct {
|
||||
displayName cliutil.OptString
|
||||
indexFile cliutil.OptString
|
||||
notFoundFile cliutil.OptString
|
||||
spa cliutil.OptBool
|
||||
cacheControl cliutil.OptString
|
||||
retention cliutil.OptInt
|
||||
grace cliutil.OptDuration
|
||||
maxFiles cliutil.OptInt
|
||||
maxFileBytes cliutil.OptBytes
|
||||
maxTotalBytes cliutil.OptBytes
|
||||
}
|
||||
|
||||
func (p *projectFlags) register(fs *flag.FlagSet) {
|
||||
fs.Var(&p.displayName, "display-name", "human-readable `name` shown in listings")
|
||||
fs.Var(&p.indexFile, "index-file", "document served for a directory, e.g. `index.html`")
|
||||
fs.Var(&p.notFoundFile, "not-found-file", "document served with 404, e.g. `404.html`; empty clears it")
|
||||
fs.Var(&p.spa, "spa", "serve the index document for unknown paths that accept HTML")
|
||||
fs.Var(&p.cacheControl, "cache-control", "Cache-Control `header` sent with every file")
|
||||
fs.Var(&p.retention, "retention", "`count` of finished deployments to keep per project")
|
||||
fs.Var(&p.grace, "retention-grace", "`duration` a deployment stays after being replaced, e.g. 1h")
|
||||
fs.Var(&p.maxFiles, "max-files", "`count` of files allowed in one deployment")
|
||||
fs.Var(&p.maxFileBytes, "max-file-bytes", "largest single file, e.g. `256MiB`")
|
||||
fs.Var(&p.maxTotalBytes, "max-total-bytes", "largest total deployment, e.g. `2GiB`")
|
||||
}
|
||||
|
||||
func (p *projectFlags) patch() api.ProjectPatch {
|
||||
return api.ProjectPatch{
|
||||
DisplayName: p.displayName.Ptr(),
|
||||
IndexFile: p.indexFile.Ptr(),
|
||||
NotFoundFile: p.notFoundFile.Ptr(),
|
||||
SPAFallback: p.spa.Ptr(),
|
||||
CacheControl: p.cacheControl.Ptr(),
|
||||
RetentionCount: p.retention.Ptr(),
|
||||
RetentionGrace: p.grace.SecondsPtr(),
|
||||
MaxFiles: p.maxFiles.Ptr(),
|
||||
MaxFileBytes: p.maxFileBytes.Ptr(),
|
||||
MaxTotalBytes: p.maxTotalBytes.Ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
func projectCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "project",
|
||||
Short: "Manage projects",
|
||||
Sub: []*cliutil.Command{
|
||||
projectCreateCmd(g),
|
||||
projectListCmd(g),
|
||||
projectShowCmd(g),
|
||||
projectUpdateCmd(g),
|
||||
projectDeleteCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateCmd(g *Globals) *cliutil.Command {
|
||||
var pf projectFlags
|
||||
return &cliutil.Command{
|
||||
Name: "create",
|
||||
Args: "<name>",
|
||||
Short: "Create a project",
|
||||
Long: "The name becomes the URL prefix, so it is restricted to lowercase\n" +
|
||||
"letters, digits, dot, dash and underscore, and cannot be changed later.\n" +
|
||||
"Settings left unset take the server's defaults. Requires an admin key.",
|
||||
Flags: pf.register,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one project name"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch := pf.patch()
|
||||
p, err := c.CreateProject(ctx, api.CreateProjectRequest{Name: args[0], Patch: &patch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectListCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List projects",
|
||||
Long: "Requires an admin key. Follows paging to the end.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, err := c.ListAllProjects(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// api.ProjectList rather than the bare slice, so -o json produces the
|
||||
// same shape the API returns and a nil slice still prints as [].
|
||||
out := api.ProjectList{Projects: ps}
|
||||
if out.Projects == nil {
|
||||
out.Projects = []api.Project{}
|
||||
}
|
||||
return p.Print(out, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("NAME", "DISPLAY NAME", "INDEX", "SPA", "KEEP", "UPDATED")
|
||||
for _, pr := range ps {
|
||||
t.Row(pr.Name, cliutil.Str(cliutil.Truncate(pr.DisplayName, 32)),
|
||||
pr.IndexFile, cliutil.Bool(pr.SPAFallback),
|
||||
strconv.Itoa(pr.RetentionCount), cliutil.Time(pr.UpdatedAt))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectShowCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Args: "[name]",
|
||||
Short: "Show one project",
|
||||
Long: "Defaults to --project. A project key may read only its own project.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := c.GetProject(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectUpdateCmd(g *Globals) *cliutil.Command {
|
||||
var pf projectFlags
|
||||
return &cliutil.Command{
|
||||
Name: "update",
|
||||
Args: "[name]",
|
||||
Short: "Change a project's settings",
|
||||
Long: "Only the settings named by flags are changed. Requires an admin key.\n" +
|
||||
"--not-found-file= with an empty value clears the custom 404 document.",
|
||||
Flags: pf.register,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch := pf.patch()
|
||||
if patch == (api.ProjectPatch{}) {
|
||||
return fmt.Errorf("nothing to change: pass at least one setting flag")
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := c.PatchProject(ctx, name, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectDeleteCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "delete",
|
||||
Args: "[name]",
|
||||
Short: "Delete a project and everything in it",
|
||||
Long: "Removes the project's keys and deployments and unpublishes the site.\n" +
|
||||
"The uploaded content is reclaimed by the next garbage collection.\n" +
|
||||
"Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err,
|
||||
fmt.Sprintf("Delete project %q, its keys and all its deployments?", name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.DeleteProject(ctx, name); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("deleted project %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// oneProject takes the project from the positional argument, falling back to
|
||||
// --project. Both are accepted because "pages project show demo" reads better
|
||||
// at a prompt while "--project" is what a CI job already has set.
|
||||
func (g *Globals) oneProject(args []string) (string, error) {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return g.ProjectName()
|
||||
case 1:
|
||||
return args[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("expected at most one project name")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Globals) printProject(pr api.Project) error {
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(pr, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("name", pr.Name)
|
||||
t.Row("display_name", cliutil.Str(pr.DisplayName))
|
||||
t.Row("url", cliutil.Str(pr.URL))
|
||||
t.Row("index_file", pr.IndexFile)
|
||||
t.Row("not_found_file", cliutil.Str(pr.NotFoundFile))
|
||||
t.Row("spa_fallback", cliutil.Bool(pr.SPAFallback))
|
||||
t.Row("cache_control", pr.CacheControl)
|
||||
t.Row("retention_count", strconv.Itoa(pr.RetentionCount))
|
||||
t.Row("retention_grace", (time.Duration(pr.RetentionGrace) * time.Second).String())
|
||||
t.Row("max_files", strconv.Itoa(pr.MaxFiles))
|
||||
t.Row("max_file_bytes", cliutil.Bytes(pr.MaxFileBytes))
|
||||
t.Row("max_total_bytes", cliutil.Bytes(pr.MaxTotalBytes))
|
||||
t.Row("created_at", cliutil.Time(pr.CreatedAt))
|
||||
t.Row("updated_at", cliutil.Time(pr.UpdatedAt))
|
||||
if d := pr.ActiveDeployment; d != nil {
|
||||
t.Row("active_deployment", d.ID)
|
||||
t.Row("active_files", strconv.Itoa(d.FileCount))
|
||||
t.Row("active_bytes", cliutil.Bytes(d.TotalBytes))
|
||||
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
)
|
||||
|
||||
// Root builds the command tree.
|
||||
func Root(g *Globals) *cliutil.Command {
|
||||
var showVersion bool
|
||||
return &cliutil.Command{
|
||||
Name: "pages",
|
||||
Short: "Deploy static sites atomically",
|
||||
Long: "Settings are taken from flags first, then PAGES_* environment\n" +
|
||||
"variables, then the config file, then built-in defaults.\n\n" +
|
||||
" PAGES_SERVER management API base URL\n" +
|
||||
" PAGES_TOKEN API token\n" +
|
||||
" PAGES_TOKEN_FILE file holding the API token\n" +
|
||||
" PAGES_PROJECT default project\n" +
|
||||
" PAGES_OUTPUT table or json\n" +
|
||||
" PAGES_TIMEOUT per-request timeout\n" +
|
||||
" PAGES_CONFIG config file path",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&showVersion, "version", false, "print the version and exit")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if showVersion {
|
||||
fmt.Fprintln(g.Out, version.String())
|
||||
return nil
|
||||
}
|
||||
return cliutil.ErrUsage
|
||||
},
|
||||
Sub: []*cliutil.Command{
|
||||
deployCmd(g),
|
||||
projectCmd(g),
|
||||
deploymentCmd(g),
|
||||
keyCmd(g),
|
||||
whoamiCmd(g),
|
||||
systemCmd(g),
|
||||
configCmd(g),
|
||||
versionCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "version",
|
||||
Short: "Print the version",
|
||||
Long: "Reports the client build only; it does not contact the server.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(g.Out, version.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func whoamiCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "whoami",
|
||||
Short: "Show which key is being used",
|
||||
Long: "Useful for confirming a CI runner picked up the credential you meant.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
who, err := c.WhoAmI(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(who, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("key_id", who.KeyID)
|
||||
t.Row("scope", who.Scope)
|
||||
t.Row("project", cliutil.Str(who.Project))
|
||||
t.Row("name", cliutil.Str(who.Name))
|
||||
t.Row("expires_at", cliutil.TimePtr(who.ExpiresAt))
|
||||
t.Row("server", c.BaseURL())
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func systemCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "system",
|
||||
Short: "Inspect and maintain the server",
|
||||
Sub: []*cliutil.Command{systemInfoCmd(g), systemGCCmd(g), systemFsckCmd(g)},
|
||||
}
|
||||
}
|
||||
|
||||
func systemInfoCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "info",
|
||||
Short: "Show server version and storage counters",
|
||||
Long: "Requires an admin key.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := c.SystemInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(info, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("version", info.Version)
|
||||
t.Row("uptime", (time.Duration(info.UptimeS) * time.Second).String())
|
||||
t.Row("schema_version", strconv.Itoa(info.SchemaVer))
|
||||
t.Row("projects", strconv.FormatInt(info.Projects, 10))
|
||||
t.Row("deployments", strconv.FormatInt(info.Deployments, 10))
|
||||
t.Row("blobs", strconv.FormatInt(info.Blobs, 10))
|
||||
t.Row("cas_bytes", cliutil.Bytes(info.CASBytes))
|
||||
// How deployment trees are built on this host: hardlink shares
|
||||
// inodes with the store, copy does not, and the difference shows
|
||||
// up as disk usage.
|
||||
t.Row("link_mode", info.LinkMode)
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- config
|
||||
|
||||
func configCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "config",
|
||||
Short: "Read and write the CLI config file",
|
||||
Long: "The config file is JSON, mode 0600, at $PAGES_CONFIG or\n" +
|
||||
"$XDG_CONFIG_HOME/pages/config.json. It is a convenience for a\n" +
|
||||
"workstation; CI should use PAGES_* environment variables instead.",
|
||||
Sub: []*cliutil.Command{
|
||||
configShowCmd(g),
|
||||
configSetCmd(g),
|
||||
configPathCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// configView is what "config show" prints: the file's contents with the token
|
||||
// reduced to its public half.
|
||||
type configView struct {
|
||||
Path string `json:"path"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func configShowCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Short: "Show the config file, with the token redacted",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
// From the file, not from the resolved settings: this command answers
|
||||
// "what is stored here", and printing an environment-supplied token
|
||||
// back at the user would be actively misleading.
|
||||
view := configView{
|
||||
Path: g.Config,
|
||||
Server: g.file.Server,
|
||||
Token: redactToken(g.file.Token),
|
||||
Project: g.file.Project,
|
||||
Output: g.file.Output,
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(view, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("path", cliutil.Str(view.Path))
|
||||
t.Row("server", cliutil.Str(view.Server))
|
||||
t.Row("token", cliutil.Str(view.Token))
|
||||
t.Row("project", cliutil.Str(view.Project))
|
||||
t.Row("output", cliutil.Str(view.Output))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configSetCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "set",
|
||||
Args: "<server|token|project|output> <value>",
|
||||
Short: "Set one config value",
|
||||
Long: "An empty value removes the setting. The file is created mode 0600.\n" +
|
||||
"Reading a token from a file avoids putting it in your shell history:\n" +
|
||||
" pages config set token \"$(cat token.txt)\"",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 2, "a key and a value"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
key, value := args[0], args[1]
|
||||
f := g.file
|
||||
switch key {
|
||||
case "server":
|
||||
f.Server = value
|
||||
case "token":
|
||||
f.Token = value
|
||||
case "project":
|
||||
f.Project = value
|
||||
case "output":
|
||||
if value != "" && !cliutil.ValidFormat(value) {
|
||||
return fmt.Errorf("unknown output format %q: use table or json", value)
|
||||
}
|
||||
f.Output = value
|
||||
default:
|
||||
return cliutil.UsageErrorf("unknown config key %q", key)
|
||||
}
|
||||
if err := SaveConfig(g.Config, f); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("set %s in %s\n", key, g.Config)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configPathCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "path",
|
||||
Short: "Print the config file path",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(g.Out, g.Config)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user