An instance is a directory under instances/<id>/ (config.json, state.json, stdout, stderr, run/, volumes/) written by package instance, the service behind the verbs. run -d and start fork this binary as a supervisor (werkdock supervise ID) that records the pids and the exit code; stop, kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap over the same instance tree. The command is pid 1 of its sandbox (bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and the anonymous volumes are instance directories, kept across stop/start and shared with exec. Version 0.5.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #4
218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"text/template"
|
|
"time"
|
|
|
|
"werkdock/internal/store"
|
|
)
|
|
|
|
// imagesCmd lists images. Without flags it prints one reference per
|
|
// line in the short form (":latest" omitted): machine-usable, and what
|
|
// Werkator matches lines against. `--format` takes docker's Go
|
|
// template over .Repository .Tag .ID .CreatedAt .CreatedSince .Size,
|
|
// with `table` (optionally followed by a template) for docker's
|
|
// column layout; `-q` prints IDs; a REPOSITORY[:TAG] argument filters,
|
|
// as `docker images alpine` does.
|
|
func imagesCmd(args []string) int {
|
|
fs := flag.NewFlagSet("images", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var format string
|
|
var quiet bool
|
|
fs.StringVar(&format, "format", "", "Go template, or 'table [TEMPLATE]' for docker's columns")
|
|
fs.BoolVar(&quiet, "q", false, "print image IDs only")
|
|
fs.BoolVar(&quiet, "quiet", false, "print image IDs only")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fail(err)
|
|
}
|
|
if len(fs.Args()) > 1 {
|
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1]))
|
|
}
|
|
var filter *store.Reference
|
|
if len(fs.Args()) == 1 {
|
|
ref, err := store.ParseReference(fs.Args()[0])
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
filter = &ref
|
|
if !strings.Contains(fs.Args()[0], ":") {
|
|
filter.Tag = "" // a bare repository matches every tag
|
|
}
|
|
}
|
|
st, err := store.Default()
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
names, err := st.List()
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
var rows []imageRow
|
|
for _, name := range names {
|
|
ref, _ := store.ParseReference(name)
|
|
if filter != nil && (ref.Repository != filter.Repository || (filter.Tag != "" && ref.Tag != filter.Tag)) {
|
|
continue
|
|
}
|
|
rows = append(rows, imageRow{st: st, ref: ref})
|
|
}
|
|
switch {
|
|
case quiet:
|
|
for _, row := range rows {
|
|
fmt.Println(row.ID())
|
|
}
|
|
case format != "":
|
|
var any []interface{}
|
|
for i := range rows {
|
|
any = append(any, &rows[i])
|
|
}
|
|
if err := printFormatted(os.Stdout, format, imageColumns, any); err != nil {
|
|
return fail(err)
|
|
}
|
|
default:
|
|
for _, row := range rows {
|
|
fmt.Println(row.ref.Short())
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// imageRow is one image as a template sees it; the fields docker's
|
|
// `images --format` offers, computed on demand because Size walks the
|
|
// rootfs.
|
|
type imageRow struct {
|
|
st store.Store
|
|
ref store.Reference
|
|
meta *store.ImageMeta
|
|
}
|
|
|
|
func (r *imageRow) load() store.ImageMeta {
|
|
if r.meta == nil {
|
|
meta, _ := r.st.Meta(r.ref.String())
|
|
r.meta = &meta
|
|
}
|
|
return *r.meta
|
|
}
|
|
|
|
func (r *imageRow) Repository() string { return r.ref.Repository }
|
|
func (r *imageRow) Tag() string { return r.ref.Tag }
|
|
func (r *imageRow) ID() string { return r.load().ID() }
|
|
func (r *imageRow) CreatedAt() string {
|
|
return r.load().CreatedAt.Local().Format("2006-01-02 15:04:05 -0700 MST")
|
|
}
|
|
func (r *imageRow) CreatedSince() string {
|
|
return humanDuration(time.Since(r.load().CreatedAt)) + " ago"
|
|
}
|
|
func (r *imageRow) Size() string {
|
|
n, err := r.st.Size(r.ref.String())
|
|
if err != nil {
|
|
return "?"
|
|
}
|
|
return humanSize(n)
|
|
}
|
|
|
|
// column is one cell of a docker-style table: the header, and the
|
|
// template that fills it.
|
|
type column struct{ header, cell string }
|
|
|
|
// imageColumns is `docker images` default table.
|
|
var imageColumns = []column{
|
|
{"REPOSITORY", "{{.Repository}}"},
|
|
{"TAG", "{{.Tag}}"},
|
|
{"IMAGE ID", "{{.ID}}"},
|
|
{"CREATED", "{{.CreatedSince}}"},
|
|
{"SIZE", "{{.Size}}"},
|
|
}
|
|
|
|
// printFormatted renders rows through docker's --format: a Go
|
|
// template per row, or "table [TEMPLATE]" with a header line and
|
|
// aligned columns (columns is the default table and names the headers).
|
|
func printFormatted(w io.Writer, format string, columns []column, rows []interface{}) error {
|
|
table := false
|
|
if rest, ok := strings.CutPrefix(format, "table"); ok && (rest == "" || rest[0] == ' ' || rest[0] == '\t') {
|
|
table = true
|
|
format = strings.TrimSpace(rest)
|
|
if format == "" {
|
|
var cells []string
|
|
for _, c := range columns {
|
|
cells = append(cells, c.cell)
|
|
}
|
|
format = strings.Join(cells, "\t")
|
|
}
|
|
}
|
|
// docker accepts \t and \n spelled out in the format string.
|
|
format = strings.NewReplacer(`\t`, "\t", `\n`, "\n").Replace(format)
|
|
tmpl, err := template.New("images").Parse(format)
|
|
if err != nil {
|
|
return fmt.Errorf("--format: %w", err)
|
|
}
|
|
out := w
|
|
if table {
|
|
tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0)
|
|
defer tw.Flush()
|
|
out = tw
|
|
fmt.Fprintln(tw, tableHeader(format, columns))
|
|
}
|
|
for _, row := range rows {
|
|
if err := tmpl.Execute(out, row); err != nil {
|
|
return fmt.Errorf("--format: %w", err)
|
|
}
|
|
fmt.Fprintln(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tableHeader turns a cell template into docker's header line:
|
|
// "{{.Repository}}\t{{.ID}}" becomes "REPOSITORY\tIMAGE ID".
|
|
func tableHeader(format string, columns []column) string {
|
|
header := format
|
|
for _, c := range columns {
|
|
header = strings.ReplaceAll(header, c.cell, c.header)
|
|
}
|
|
header = strings.ReplaceAll(header, "{{.CreatedAt}}", "CREATED AT")
|
|
return strings.ReplaceAll(header, "{{.RunningFor}}", "CREATED")
|
|
}
|
|
|
|
func humanSize(n int64) string {
|
|
const unit = 1000.0
|
|
value := float64(n)
|
|
for _, suffix := range []string{"B", "kB", "MB", "GB"} {
|
|
if value < unit || suffix == "GB" {
|
|
if suffix == "B" {
|
|
return fmt.Sprintf("%d%s", n, suffix)
|
|
}
|
|
return fmt.Sprintf("%.3g%s", value, suffix)
|
|
}
|
|
value /= unit
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func humanDuration(d time.Duration) string {
|
|
switch {
|
|
case d < time.Minute:
|
|
return fmt.Sprintf("%d seconds", int(d.Seconds()))
|
|
case d < 2*time.Minute:
|
|
return "About a minute"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%d minutes", int(d.Minutes()))
|
|
case d < 2*time.Hour:
|
|
return "About an hour"
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%d hours", int(d.Hours()))
|
|
case d < 14*24*time.Hour:
|
|
return fmt.Sprintf("%d days", int(d.Hours()/24))
|
|
case d < 60*24*time.Hour:
|
|
return fmt.Sprintf("%d weeks", int(d.Hours()/24/7))
|
|
case d < 2*365*24*time.Hour:
|
|
return fmt.Sprintf("%d months", int(d.Hours()/24/30))
|
|
default:
|
|
return fmt.Sprintf("%d years", int(d.Hours()/24/365))
|
|
}
|
|
}
|