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