mirror of
https://github.com/opencontainers/umoci.git
synced 2026-02-06 03:46:05 +01:00
umoci stat: include image config information
Most users probably want access to this information and previously there was no easy way of getting it from umoci. Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
This commit is contained in:
@@ -27,6 +27,12 @@ function teardown() {
|
||||
teardown_image
|
||||
}
|
||||
|
||||
function digest_to_path() {
|
||||
layout="$1"
|
||||
digest="$2"
|
||||
echo "$1/blobs/$(tr : / <<<"$2")"
|
||||
}
|
||||
|
||||
@test "umoci stat --json" {
|
||||
# Make sure that stat looks about right.
|
||||
umoci stat --image "${IMAGE}:${TAG}" --json
|
||||
@@ -35,6 +41,22 @@ function teardown() {
|
||||
statFile="$(setup_tmpdir)/stat"
|
||||
echo "$output" > "$statFile"
|
||||
|
||||
# .config.descriptor should describe a config blob
|
||||
sane_run jq -SMr '.config.descriptor.mediaType' "$statFile"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == "application/vnd.oci.image.config.v1+json" ]]
|
||||
|
||||
# .config.blob should match .config.descriptor data
|
||||
sane_run jq -SMr '.config.descriptor.digest' "$statFile"
|
||||
[ "$status" -eq 0 ]
|
||||
config_digest="$output"
|
||||
sane_run jq -SMr '.config.blob' "$statFile"
|
||||
[ "$status" -eq 0 ]
|
||||
config_data="$output"
|
||||
sane_run jq -SMr '.' "$(digest_to_path "$IMAGE" "$config_digest")"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == "$config_data" ]]
|
||||
|
||||
# .history should have at least one entry.
|
||||
sane_run jq -SMr '.history | length' "$statFile"
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -50,10 +72,19 @@ function teardown() {
|
||||
|
||||
# We can't really test the output for non-JSON output, but we can smoke test it.
|
||||
@test "umoci stat [smoke]" {
|
||||
# Set some values to make sure they show up in stat properly.
|
||||
umoci config --config.user "foobar" --image "${IMAGE}:${TAG}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Make sure that stat looks about right.
|
||||
umoci stat --image "${IMAGE}:${TAG}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# We should have some config information.
|
||||
echo "$output" | grep "== CONFIG =="
|
||||
echo "$output" | grep "Media Type: application/vnd.oci.image.config.v1+json"
|
||||
echo "$output" | grep "User: foobar"
|
||||
|
||||
# We should have some history information.
|
||||
echo "$output" | grep "== HISTORY =="
|
||||
echo "$output" | grep 'LAYER'
|
||||
@@ -65,6 +96,49 @@ function teardown() {
|
||||
image-verify "${IMAGE}"
|
||||
}
|
||||
|
||||
BLANK_IMAGE_STAT="$(cat <<EOF
|
||||
== CONFIG ==
|
||||
Created: 2025-09-05T13:05:10.12345+10:00
|
||||
Author: ""
|
||||
Image Config:
|
||||
User: ""
|
||||
Command:
|
||||
Descriptor:
|
||||
Media Type: application/vnd.oci.image.config.v1+json
|
||||
Digest: sha256:e5101a46118c740a7709af8eaeec19cbc50a567f4fe7741f8420af39a3779a77
|
||||
Size: 135B
|
||||
|
||||
== HISTORY ==
|
||||
LAYER CREATED CREATED BY SIZE COMMENT
|
||||
EOF
|
||||
)"
|
||||
|
||||
@test "umoci stat [blank image output snapshot]" {
|
||||
IMAGE="$(setup_tmpdir)/image" TAG="latest"
|
||||
STATFILE_DIR="$(setup_tmpdir)"
|
||||
|
||||
expected="${STATFILE_DIR}/expected"
|
||||
cat >"$expected" <<<"$BLANK_IMAGE_STAT"
|
||||
|
||||
umoci init --layout "${IMAGE}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
umoci new --image "${IMAGE}:${TAG}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
umoci config --no-history --created="2025-09-05T13:05:10.12345+10:00" --image "${IMAGE}:${TAG}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
umoci stat --image "${IMAGE}:${TAG}"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
got="${STATFILE_DIR}/got"
|
||||
cat >"$got" <<<"$output"
|
||||
|
||||
sane_run diff -u "$expected" "$got"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "umoci stat [invalid arguments]" {
|
||||
# Missing --image argument.
|
||||
umoci stat
|
||||
|
||||
229
utils.go
229
utils.go
@@ -24,9 +24,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/apex/log"
|
||||
@@ -149,13 +152,15 @@ func ReadBundleMeta(bundle string) (_ Meta, Err error) {
|
||||
|
||||
// ManifestStat has information about a given OCI manifest.
|
||||
// TODO: Implement support for manifest lists, this should also be able to
|
||||
//
|
||||
// contain stat information for a list of manifests.
|
||||
// contain stat information for a list of manifests.
|
||||
type ManifestStat struct {
|
||||
// TODO: Flesh this out. Currently it's only really being used to get an
|
||||
// equivalent of docker-history(1). We really need to add more
|
||||
// information about it.
|
||||
|
||||
// Config stores information about the configuration of a manifest.
|
||||
Config configStat `json:"config"`
|
||||
|
||||
// History stores the history information for the manifest.
|
||||
History historyStatList `json:"history"`
|
||||
}
|
||||
@@ -169,6 +174,119 @@ func quote(s string, quoteEmpty bool) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func pprint(w io.Writer, prefix, key string, values ...string) (err error) {
|
||||
if len(values) > 0 {
|
||||
for idx, value := range values {
|
||||
if strings.Contains(value, ",") {
|
||||
// Make sure "," leads to quoting.
|
||||
values[idx] = strconv.Quote(value)
|
||||
} else {
|
||||
values[idx] = quote(values[idx], true)
|
||||
}
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "%s%s: %s\n", prefix, key, strings.Join(values, ", "))
|
||||
} else {
|
||||
_, err = fmt.Fprintf(w, "%s%s:\n", prefix, key)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func pprintSlice(w io.Writer, prefix, name string, data []string) error {
|
||||
if err := pprint(w, prefix, name); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "\t"
|
||||
for _, line := range data {
|
||||
if _, err := fmt.Fprintf(w, "%s%s\n", prefix, quote(line, true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pprintMap(w io.Writer, prefix, name string, data map[string]string) error {
|
||||
if err := pprint(w, prefix, name); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "\t"
|
||||
for _, key := range slices.Sorted(maps.Keys(data)) {
|
||||
if err := pprint(w, prefix, key, data[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pprintSet(w io.Writer, prefix, name string, data map[string]struct{}) error {
|
||||
keys := slices.Sorted(maps.Keys(data))
|
||||
return pprint(w, prefix, name, keys...)
|
||||
}
|
||||
|
||||
func pprintPlatform(w io.Writer, prefix string, platform ispec.Platform) error {
|
||||
if err := pprint(w, prefix, "Platform"); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "\t"
|
||||
|
||||
if err := pprint(w, prefix, "OS", platform.OS); err != nil {
|
||||
return err
|
||||
}
|
||||
if platform.OSVersion != "" {
|
||||
if err := pprint(w, prefix, "OS Version", platform.OSVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(platform.OSFeatures) > 0 {
|
||||
if err := pprint(w, prefix, "OS Features", platform.OSFeatures...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
arch := platform.Architecture
|
||||
if platform.Variant != "" {
|
||||
arch = fmt.Sprintf("%s (%s)", platform.Architecture, platform.Variant)
|
||||
}
|
||||
if err := pprint(w, prefix, "Architecture", arch); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pprintDescriptor pretty-prints an ispec.Descriptor.
|
||||
func pprintDescriptor(w io.Writer, prefix string, descriptor ispec.Descriptor) error {
|
||||
if err := pprint(w, prefix, "Descriptor"); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "\t"
|
||||
if err := pprint(w, prefix, "Media Type", descriptor.MediaType); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pprint(w, prefix, "Digest", descriptor.Digest.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
size := units.HumanSize(float64(descriptor.Size))
|
||||
if err := pprint(w, prefix, "Size", size); err != nil {
|
||||
return err
|
||||
}
|
||||
if descriptor.Platform != nil {
|
||||
if err := pprintPlatform(w, "", *descriptor.Platform); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(descriptor.URLs) > 0 {
|
||||
if err := pprintSlice(w, prefix, "URLs", descriptor.URLs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(descriptor.Annotations) > 0 {
|
||||
if err := pprintMap(w, prefix, "Annotations", descriptor.Annotations); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// TODO(image-spec v1.1): descriptor.Data
|
||||
// TODO(image-spec v1.1): descriptor.ArtifactType
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format formats a ManifestStat using the default formatting, and writes the
|
||||
// result to the given writer.
|
||||
//
|
||||
@@ -176,8 +294,15 @@ func quote(s string, quoteEmpty bool) string {
|
||||
// define their own custom templates for different blocks (meaning that this
|
||||
// should use text/template rather than using tabwriters manually.
|
||||
func (ms ManifestStat) Format(w io.Writer) error {
|
||||
if _, err := fmt.Fprintln(w, "== CONFIG =="); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ms.Config.pprint(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output history information.
|
||||
if _, err := fmt.Fprintln(w, "== HISTORY =="); err != nil {
|
||||
if _, err := fmt.Fprintln(w, "\n== HISTORY =="); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ms.History.pprint(w); err != nil {
|
||||
@@ -186,6 +311,99 @@ func (ms ManifestStat) Format(w io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// configStat contains information about the image configuration of this
|
||||
// manifest.
|
||||
type configStat struct {
|
||||
// Descriptor is the descriptor for the configuration JSON.
|
||||
Descriptor ispec.Descriptor `json:"descriptor"`
|
||||
|
||||
// Image is the contents of the configuration.
|
||||
Image ispec.Image `json:"-"`
|
||||
|
||||
// RawData is the raw data stream of the blob, which is output when we
|
||||
// provide JSON output (to make sure no information is lost in --json
|
||||
// mode).
|
||||
RawData json.RawMessage `json:"blob"`
|
||||
}
|
||||
|
||||
func pprintImageConfig(w io.Writer, prefix string, config ispec.ImageConfig) error {
|
||||
if err := pprint(w, prefix, "Image Config"); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "\t"
|
||||
if err := pprint(w, prefix, "User", config.User); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(config.Entrypoint) > 0 {
|
||||
if err := pprintSlice(w, prefix, "Entrypoint", config.Entrypoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := pprintSlice(w, prefix, "Command", config.Cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.WorkingDir != "" {
|
||||
if err := pprint(w, prefix, "Working Directory", config.WorkingDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(config.Env) > 0 {
|
||||
if err := pprintSlice(w, prefix, "Environment", config.Env); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if config.StopSignal != "" {
|
||||
if err := pprint(w, prefix, "Stop Signal", config.StopSignal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(config.ExposedPorts) > 0 {
|
||||
if err := pprintSet(w, prefix, "Exposed Ports", config.ExposedPorts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(config.Volumes) > 0 {
|
||||
if err := pprintSet(w, prefix, "Volumes", config.Volumes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(config.Labels) > 0 {
|
||||
if err := pprintMap(w, prefix, "Labels", config.Labels); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c configStat) pprint(w io.Writer) error {
|
||||
image := c.Image
|
||||
if image.Created != nil {
|
||||
date := image.Created.Format(igen.ISO8601)
|
||||
if err := pprint(w, "", "Created", date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := pprint(w, "", "Author", image.Author); err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO(image-spec v1.1): Use embedded Platform.
|
||||
platform := ispec.Platform{
|
||||
OS: image.OS,
|
||||
Architecture: image.Architecture,
|
||||
}
|
||||
if err := pprintPlatform(w, "", platform); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pprintImageConfig(w, "", image.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pprintDescriptor(w, "", c.Descriptor); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// historyStat contains information about a single entry in the history of a
|
||||
// manifest. This is essentially equivalent to a single record from
|
||||
// docker-history(1).
|
||||
@@ -263,6 +481,11 @@ func Stat(ctx context.Context, engine casext.Engine, manifestDescriptor ispec.De
|
||||
// Should _never_ be reached.
|
||||
return stat, fmt.Errorf("[internal error] unknown config blob type: %s", configBlob.Descriptor.MediaType)
|
||||
}
|
||||
stat.Config = configStat{
|
||||
Descriptor: manifest.Config,
|
||||
Image: config,
|
||||
RawData: configBlob.RawData,
|
||||
}
|
||||
|
||||
// TODO: This should probably be moved into separate functions.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user