Files
werkdock/internal/cli/images.go
6d6060bb07 RFC 0005 milestone 2: instance lifecycle — run -d, ps, stop, logs, rm, exec (#4)
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
2026-09-10 20:55:59 +02:00

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