diff --git a/apis/server/image_bridge.go b/apis/server/image_bridge.go index 0ebee8ee6..b6a686916 100644 --- a/apis/server/image_bridge.go +++ b/apis/server/image_bridge.go @@ -181,3 +181,15 @@ func (s *Server) saveImage(ctx context.Context, rw http.ResponseWriter, req *htt return nil } + +// getImageHistory gets image history. +func (s *Server) getImageHistory(ctx context.Context, rw http.ResponseWriter, req *http.Request) error { + imageName := mux.Vars(req)["name"] + + history, err := s.ImageMgr.ImageHistory(ctx, imageName) + if err != nil { + return err + } + + return EncodeResponse(rw, http.StatusOK, history) +} diff --git a/apis/server/router.go b/apis/server/router.go index 8a4eb8ac6..e3b794316 100644 --- a/apis/server/router.go +++ b/apis/server/router.go @@ -68,6 +68,7 @@ func initRoute(s *Server) http.Handler { s.addRoute(r, http.MethodPost, "/images/{name:.*}/tag", s.postImageTag) s.addRoute(r, http.MethodPost, "/images/load", withCancelHandler(s.loadImage)) s.addRoute(r, http.MethodGet, "/images/save", withCancelHandler(s.saveImage)) + s.addRoute(r, http.MethodGet, "/images/{name:.*}/history", s.getImageHistory) // volume s.addRoute(r, http.MethodGet, "/volumes", s.listVolume) diff --git a/apis/swagger.yml b/apis/swagger.yml index 7b98aa7fc..e241dd20c 100644 --- a/apis/swagger.yml +++ b/apis/swagger.yml @@ -243,7 +243,7 @@ paths: /images/{imageid}/json: get: - summary: "Inspect a image" + summary: "Inspect an image" description: "Return the information about image" operationId: "ImageInspect" produces: @@ -273,6 +273,27 @@ paths: parameters: - $ref: "#/parameters/imageid" + /images/{imageid}/history: + get: + summary: "Get an image's history" + description: "Return the history of each layer of image" + operationId: "ImageHistory" + produces: + - "application/json" + responses: + 200: + description: "no error" + schema: + type: "array" + items: + $ref: "#/definitions/HistoryResultItem" + 404: + $ref: "#/responses/404ErrorResponse" + 500: + $ref: "#/responses/500ErrorResponse" + parameters: + - $ref: "#/parameters/imageid" + /images/json: get: summary: "List Images" @@ -2927,6 +2948,42 @@ definitions: description: "the base layer content hash." type: "string" + HistoryResultItem: + description: "An object containing image history at API side." + type: "object" + required: [ID, Created, CreatedBy, Author, Comment, EmptyLayer, Size] + properties: + ID: + description: "ID of each layer image." + type: "string" + x-nullable: false + Created: + description: "the combined date and time at which the layer was created." + type: "integer" + format: "int64" + x-nullable: false + CreatedBy: + description: "the command which created the layer." + type: "string" + x-nullable: false + Author: + description: "the author of the build point." + type: "string" + x-nullable: false + Comment: + description: "a custom message set when creating the layer." + type: "string" + x-nullable: false + EmptyLayer: + description: "mark whether the history item created a filesystem diff or not." + type: "boolean" + x-nullable: false + Size: + description: "size of each layer image." + type: "integer" + format: "int64" + x-nullable: false + SearchResultItem: type: "object" description: "search result item in search results." diff --git a/apis/types/history_result_item.go b/apis/types/history_result_item.go new file mode 100644 index 000000000..6924db624 --- /dev/null +++ b/apis/types/history_result_item.go @@ -0,0 +1,173 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + strfmt "github.com/go-openapi/strfmt" + + "github.com/go-openapi/errors" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// HistoryResultItem An object containing image history at API side. +// swagger:model HistoryResultItem +type HistoryResultItem struct { + + // the author of the build point. + // Required: true + Author string `json:"Author"` + + // a custom message set when creating the layer. + // Required: true + Comment string `json:"Comment"` + + // the combined date and time at which the layer was created. + // Required: true + Created int64 `json:"Created"` + + // the command which created the layer. + // Required: true + CreatedBy string `json:"CreatedBy"` + + // mark whether the history item created a filesystem diff or not. + // Required: true + EmptyLayer bool `json:"EmptyLayer"` + + // ID of every layer image. + // Required: true + ID string `json:"ID"` + + // size of every layer image. + // Required: true + Size int64 `json:"Size"` +} + +// Validate validates this history result item +func (m *HistoryResultItem) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateAuthor(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateComment(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateCreated(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateCreatedBy(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateEmptyLayer(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateID(formats); err != nil { + // prop + res = append(res, err) + } + + if err := m.validateSize(formats); err != nil { + // prop + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *HistoryResultItem) validateAuthor(formats strfmt.Registry) error { + + if err := validate.RequiredString("Author", "body", string(m.Author)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateComment(formats strfmt.Registry) error { + + if err := validate.RequiredString("Comment", "body", string(m.Comment)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateCreated(formats strfmt.Registry) error { + + if err := validate.Required("Created", "body", int64(m.Created)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateCreatedBy(formats strfmt.Registry) error { + + if err := validate.RequiredString("CreatedBy", "body", string(m.CreatedBy)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateEmptyLayer(formats strfmt.Registry) error { + + if err := validate.Required("EmptyLayer", "body", bool(m.EmptyLayer)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateID(formats strfmt.Registry) error { + + if err := validate.RequiredString("ID", "body", string(m.ID)); err != nil { + return err + } + + return nil +} + +func (m *HistoryResultItem) validateSize(formats strfmt.Registry) error { + + if err := validate.Required("Size", "body", int64(m.Size)); err != nil { + return err + } + + return nil +} + +// MarshalBinary interface implementation +func (m *HistoryResultItem) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *HistoryResultItem) UnmarshalBinary(b []byte) error { + var res HistoryResultItem + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/cli/history.go b/cli/history.go new file mode 100644 index 000000000..d4ad5931a --- /dev/null +++ b/cli/history.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/alibaba/pouch/pkg/utils" + + "github.com/docker/docker/pkg/stringid" + "github.com/spf13/cobra" +) + +// historyDescription is used to describe history command in detail and auto generate command doc. +var historyDescription = "Return the history information about image" + +// HistoryCommand is used to implement 'image history' command. +type HistoryCommand struct { + baseCommand + + // flags for history command + flagHuman bool + flagQuiet bool + flagNoTrunc bool +} + +// Init initialize "image history" command. +func (h *HistoryCommand) Init(c *Cli) { + h.cli = c + h.cmd = &cobra.Command{ + Use: "history [OPTIONS] IMAGE", + Short: "Display history information on image", + Long: historyDescription, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return h.runHistory(args) + }, + Example: h.example(), + } + h.addFlags() +} + +// addFlags adds flags for specific command. +func (h *HistoryCommand) addFlags() { + flagSet := h.cmd.Flags() + flagSet.BoolVar(&h.flagHuman, "human", true, "Print information in human readable format") + flagSet.BoolVarP(&h.flagQuiet, "quiet", "q", false, "Only show image numeric ID") + flagSet.BoolVar(&h.flagNoTrunc, "no-trunc", false, "Do not truncate output") +} + +// runHistory is used to get history of an image. +func (h *HistoryCommand) runHistory(args []string) error { + name := args[0] + + ctx := context.Background() + apiClient := h.cli.Client() + + history, err := apiClient.ImageHistory(ctx, name) + if err != nil { + return err + } + + display := h.cli.NewTableDisplay() + if h.flagQuiet { + for _, entry := range history { + if h.flagNoTrunc { + display.AddRow([]string{entry.ID}) + } else { + display.AddRow([]string{stringid.TruncateID(entry.ID)}) + } + } + display.Flush() + return nil + } + + var ( + imageID string + createdBy string + created string + size string + ) + + display.AddRow([]string{"IMAGE", "CREATED", "CREATED BY", "SIZE", "COMMENT"}) + for _, entry := range history { + imageID = entry.ID + createdBy = strings.Replace(entry.CreatedBy, "\t", " ", -1) + if !h.flagNoTrunc { + createdBy = ellipsis(createdBy, 45) + imageID = stringid.TruncateID(entry.ID) + } + + if h.flagHuman { + created, err = utils.FormatTimeInterval(entry.Created) + if err != nil { + return err + } + created = created + " ago" + size = utils.FormatSize(entry.Size) + } else { + created = time.Unix(0, entry.Created).Format(time.RFC3339) + size = strconv.FormatInt(entry.Size, 10) + } + + display.AddRow([]string{imageID, created, createdBy, size, entry.Comment}) + } + display.Flush() + return nil +} + +// example shows examples in history command, and is used in auto-generated cli docs. +func (h *HistoryCommand) example() string { + return `pouch history busybox:latest +IMAGE CREATED CREATED BY SIZE COMMENT +e1ddd7948a1c 1 week ago /bin/sh -c #(nop) CMD ["sh"] 0.00 B + 1 week ago /bin/sh -c #(nop) ADD file:96fda64a6b725d4... 716.06 KB ` +} + +// ellipsis truncates a string to fit within maxlen, and appends ellipsis (...). +// For maxlen of 3 and lower, no ellipsis is appended. +func ellipsis(s string, maxlen int) string { + r := []rune(s) + if len(r) <= maxlen { + return s + } + if maxlen <= 3 { + return string(r[:maxlen]) + } + return string(r[:maxlen-3]) + "..." +} diff --git a/cli/main.go b/cli/main.go index 2cc04234c..ec71110c8 100644 --- a/cli/main.go +++ b/cli/main.go @@ -32,6 +32,7 @@ func main() { cli.AddCommand(base, &TagCommand{}) cli.AddCommand(base, &LoadCommand{}) cli.AddCommand(base, &SaveCommand{}) + cli.AddCommand(base, &HistoryCommand{}) cli.AddCommand(base, &InspectCommand{}) cli.AddCommand(base, &RenameCommand{}) diff --git a/client/image_history.go b/client/image_history.go new file mode 100644 index 000000000..0de26330e --- /dev/null +++ b/client/image_history.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + + "github.com/alibaba/pouch/apis/types" +) + +// ImageHistory requests daemon to get history of an image. +func (client *APIClient) ImageHistory(ctx context.Context, name string) ([]types.HistoryResultItem, error) { + history := []types.HistoryResultItem{} + + resp, err := client.get(ctx, "/images/"+name+"/history", nil, nil) + if err != nil { + return history, err + } + + defer ensureCloseReader(resp) + err = decodeBody(&history, resp.Body) + return history, err +} diff --git a/client/image_history_test.go b/client/image_history_test.go new file mode 100644 index 000000000..89f107656 --- /dev/null +++ b/client/image_history_test.go @@ -0,0 +1,83 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/alibaba/pouch/apis/types" + + "github.com/stretchr/testify/assert" +) + +func TestImageHistoryServerError(t *testing.T) { + client := &APIClient{ + HTTPCli: newMockClient(errorMockResponse(http.StatusInternalServerError, "Server error")), + } + _, err := client.ImageHistory(context.Background(), "test_image_history_500") + if err == nil || !strings.Contains(err.Error(), "Server error") { + t.Fatalf("expected a Server Error, got %v", err) + } +} + +func TestImageHistoryNotFoundError(t *testing.T) { + client := &APIClient{ + HTTPCli: newMockClient(errorMockResponse(http.StatusNotFound, "Not Found")), + } + _, err := client.ImageHistory(context.Background(), "no image") + if err == nil || !strings.Contains(err.Error(), "Not Found") { + t.Fatalf("expected a Not Found Error, got %v", err) + } +} + +func TestImageHistory(t *testing.T) { + expectedURL := "/images/image_id/history" + + httpClient := newMockClient(func(req *http.Request) (*http.Response, error) { + if !strings.HasPrefix(req.URL.Path, expectedURL) { + return nil, fmt.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL) + } + if req.Method != "GET" { + return nil, fmt.Errorf("expected GET method, got %s", req.Method) + } + + imageHistoryResp, err := json.Marshal([]types.HistoryResultItem{ + { + ID: "1", + Size: int64(94), + }, + { + ID: "2", + Size: int64(703), + }, + }) + if err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(imageHistoryResp))), + }, nil + }) + + client := &APIClient{ + HTTPCli: httpClient, + } + + history, err := client.ImageHistory(context.Background(), "image_id") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, history[0].ID, "1") + assert.Equal(t, history[0].Size, int64(94)) + + assert.Equal(t, history[1].ID, "2") + assert.Equal(t, history[1].Size, int64(703)) +} diff --git a/client/image_inspect_test.go b/client/image_inspect_test.go index e2f4b5cce..b22aa6804 100644 --- a/client/image_inspect_test.go +++ b/client/image_inspect_test.go @@ -27,11 +27,11 @@ func TestImageInspectServerError(t *testing.T) { func TestImageInspectNotFoundError(t *testing.T) { client := &APIClient{ - HTTPCli: newMockClient(errorMockResponse(http.StatusConflict, "Not Found")), + HTTPCli: newMockClient(errorMockResponse(http.StatusNotFound, "Not Found")), } _, err := client.ImageInspect(context.Background(), "no image") if err == nil || !strings.Contains(err.Error(), "Not Found") { - t.Fatalf("expected a Server Error, got %v", err) + t.Fatalf("expected a Not Found Error, got %v", err) } } diff --git a/client/interface.go b/client/interface.go index 97aa9cbed..372398bd0 100644 --- a/client/interface.go +++ b/client/interface.go @@ -55,6 +55,7 @@ type ImageAPIClient interface { ImageTag(ctx context.Context, image string, tag string) error ImageLoad(ctx context.Context, name string, r io.Reader) error ImageSave(ctx context.Context, imageName string) (io.ReadCloser, error) + ImageHistory(ctx context.Context, name string) ([]types.HistoryResultItem, error) } // VolumeAPIClient defines methods of Volume client. diff --git a/daemon/mgr/image.go b/daemon/mgr/image.go index 37ed2298c..91a61c648 100644 --- a/daemon/mgr/image.go +++ b/daemon/mgr/image.go @@ -2,6 +2,7 @@ package mgr import ( "context" + "errors" "fmt" "io" "strings" @@ -17,8 +18,11 @@ import ( "github.com/alibaba/pouch/pkg/utils" "github.com/containerd/containerd" + "github.com/containerd/containerd/content" ctrdmetaimages "github.com/containerd/containerd/images" - "github.com/opencontainers/go-digest" + "github.com/containerd/containerd/platforms" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" pkgerrors "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -56,6 +60,9 @@ type ImageMgr interface { // SaveImage saves image to tarstream. SaveImage(ctx context.Context, idOrRef string) (io.ReadCloser, error) + + // ImageHistory returns image history by reference. + ImageHistory(ctx context.Context, idOrRef string) ([]types.HistoryResultItem, error) } // ImageManager is an implementation of interface ImageMgr. @@ -296,6 +303,72 @@ func (mgr *ImageManager) AddTag(ctx context.Context, sourceImage string, targetT return err } +// ImageHistory returns image history by reference. +func (mgr *ImageManager) ImageHistory(ctx context.Context, idOrRef string) ([]types.HistoryResultItem, error) { + img, err := mgr.fetchContainerdImage(ctx, idOrRef) + if err != nil { + return nil, err + } + + desc, err := img.Config(ctx) + if err != nil { + return nil, err + } + + ociImage, err := containerdImageToOciImage(ctx, img) + if err != nil { + return nil, err + } + + cs := img.ContentStore() + manifest, err := mgr.getManifest(ctx, cs, img, platforms.Default()) + if err != nil { + return nil, err + } + + ociImageHistory := ociImage.History + lenOciImageHistory := len(ociImageHistory) + history := make([]types.HistoryResultItem, lenOciImageHistory) + // Note: ociImage History layers info and manifest layers info are all in order from bottom-most to top-most, but the + // user-interactive history is in order from top-most to top-bottom, so we need to reverse ociImage History traverse order. + j := len(manifest.Layers) - 1 + for i := range ociImageHistory { + history[i] = types.HistoryResultItem{ + Created: ociImageHistory[lenOciImageHistory-i-1].Created.UnixNano(), + CreatedBy: ociImageHistory[lenOciImageHistory-i-1].CreatedBy, + Author: ociImageHistory[lenOciImageHistory-i-1].Author, + Comment: ociImageHistory[lenOciImageHistory-i-1].Comment, + EmptyLayer: ociImageHistory[lenOciImageHistory-i-1].EmptyLayer, + ID: "", + Size: 0, + } + + // TODO: here we just set imageID of top image layer, we do nothing with the lower image ID, after pouch + // enables build/commit functionality, we should get local lower image(parent image) layer ID. + if i == 0 { + history[i].ID = desc.Digest.String() + } + + // Note: number of manifest layers should be less than ociImage History messages due to the existence of empty layers. + // The size of these empty layers should be set to 0 by default. + if !history[i].EmptyLayer { + if j < 0 { + return nil, errors.New("number of manifest layers shouldn't be less than number of non-empty layer in history info") + } + info, err := cs.Info(ctx, manifest.Layers[j].Digest) + if err != nil { + return nil, err + } + history[i].Size = info.Size + j-- + } + } + if j != -1 { + return nil, errors.New("number of manifest layers shouldn't be greater than number of non-empty layer in history info") + } + return history, nil +} + // CheckReference returns image ID and actual reference. func (mgr *ImageManager) CheckReference(ctx context.Context, idOrRef string) (actualID digest.Digest, actualRef reference.Named, primaryRef reference.Named, err error) { var namedRef reference.Named @@ -490,6 +563,27 @@ func (mgr *ImageManager) validateTagReference(ref reference.Named) error { return nil } +// getManifest gets a manifest from the image for the given platform. +func (mgr *ImageManager) getManifest(ctx context.Context, cs content.Store, img containerd.Image, platform string) (ocispec.Manifest, error) { + // layers info + manifest, err := ctrdmetaimages.Manifest(ctx, cs, img.Target(), platform) + if err != nil { + return ocispec.Manifest{}, err + } + + // diffIDs info + diffIDs, err := img.RootFS(ctx) + if err != nil { + return ocispec.Manifest{}, err + } + + if len(manifest.Layers) != len(diffIDs) { + return ocispec.Manifest{}, errors.New("mismatched image rootfs and manifest layers") + } + + return manifest, nil +} + func parseTagReference(targetTag string) (reference.Named, error) { ref, err := reference.Parse(targetTag) if err != nil { diff --git a/test/api_image_history_test.go b/test/api_image_history_test.go new file mode 100644 index 000000000..cf67a760f --- /dev/null +++ b/test/api_image_history_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "github.com/alibaba/pouch/test/environment" + "github.com/alibaba/pouch/test/request" + + "github.com/go-check/check" +) + +// APIImageHistorySuite is the test suite for image history API. +type APIImageHistorySuite struct{} + +func init() { + check.Suite(&APIImageHistorySuite{}) +} + +// SetUpTest does common setup in the beginning of each test. +func (suite *APIImageHistorySuite) SetUpTest(c *check.C) { + SkipIfFalse(c, environment.IsLinux) +} + +// TestImageHistoryOk tests getting image history is OK. +func (suite *APIImageHistorySuite) TestImageHistoryOk(c *check.C) { + // TODO: We shouldn't compare dockerhub's image history with a fixed string, that's too unreadable. + // So this test case will be done when pouch enables build functionality. +} + +// TestImageHistoryNotFound tests getting history of non-existing image return 404. +func (suite *APIImageHistorySuite) TestImageHistoryNotFound(c *check.C) { + img := "TestImageHistoryNotFound" + resp, err := request.Get("/images/" + img + "/history") + c.Assert(err, check.IsNil) + CheckRespStatus(c, resp, 404) +} diff --git a/test/cli_history_test.go b/test/cli_history_test.go new file mode 100644 index 000000000..f1c6c8635 --- /dev/null +++ b/test/cli_history_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/alibaba/pouch/test/environment" + + "github.com/go-check/check" +) + +// PouchHistorySuite is the test suite for history CLI. +type PouchHistorySuite struct{} + +func init() { + check.Suite(&PouchHistorySuite{}) +} + +// SetUpSuite does common setup in the beginning of each test suite. +func (suite *PouchHistorySuite) SetUpSuite(c *check.C) { + SkipIfFalse(c, environment.IsLinux) +} + +// TestHistoryWorks tests "pouch history" work. +func (suite *PouchHistorySuite) TestHistoryWorks(c *check.C) { + // TODO: We shouldn't compare dockerhub's image history with a fixed string, that's too unreadable. + // So this test case will be done when pouch enables build functionality. +}