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