358 lines
10 KiB
Go
358 lines
10 KiB
Go
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
|
|
})
|
|
},
|
|
}
|
|
}
|