Files
pages/internal/clicmd/deployment.go
T
2026-08-15 07:13:00 +00:00

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
})
},
}
}