diff --git a/api/handlers/container/container.go b/api/handlers/container/container.go index e0fb1218..8d9eb531 100644 --- a/api/handlers/container/container.go +++ b/api/handlers/container/container.go @@ -37,6 +37,7 @@ type Service interface { ExecCreate(ctx context.Context, cid string, config types.ExecConfig) (string, error) Kill(ctx context.Context, cid string, options ncTypes.ContainerKillOptions) error Pause(ctx context.Context, cid string, options ncTypes.ContainerPauseOptions) error + Unpause(ctx context.Context, cid string, options ncTypes.ContainerUnpauseOptions) error } // RegisterHandlers register all the supported endpoints related to the container APIs. @@ -62,6 +63,7 @@ func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Con r.HandleFunc("/{id:.*}/exec", h.exec, http.MethodPost) r.HandleFunc("/{id:.*}/kill", h.kill, http.MethodPost) r.HandleFunc("/{id:.*}/pause", h.pause, http.MethodPost) + r.HandleFunc("/{id:.*}/unpause", h.unpause, http.MethodPost) } // newHandler creates the handler that serves all the container related APIs. diff --git a/api/handlers/container/unpause.go b/api/handlers/container/unpause.go new file mode 100644 index 00000000..6054f844 --- /dev/null +++ b/api/handlers/container/unpause.go @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + "os" + + "github.com/containerd/containerd/v2/pkg/namespaces" + ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/gorilla/mux" + + "github.com/runfinch/finch-daemon/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// unpause resumes a paused container. +func (h *handler) unpause(w http.ResponseWriter, r *http.Request) { + cid, ok := mux.Vars(r)["id"] + if !ok || cid == "" { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("must specify a container ID")) + return + } + + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + + devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0600) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("failed to open /dev/null")) + return + } + defer devNull.Close() + + globalOpt := ncTypes.GlobalCommandOptions(*h.Config) + options := ncTypes.ContainerUnpauseOptions{ + GOptions: globalOpt, + Stdout: devNull, + } + + err = h.service.Unpause(ctx, cid, options) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + response.JSON(w, code, response.NewError(err)) + return + } + + response.Status(w, http.StatusNoContent) +} diff --git a/api/handlers/container/unpause_test.go b/api/handlers/container/unpause_test.go new file mode 100644 index 00000000..ebd3ab6e --- /dev/null +++ b/api/handlers/container/unpause_test.go @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + + ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/mocks/mocks_container" + "github.com/runfinch/finch-daemon/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +var _ = Describe("Container Unpause API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + _ ncTypes.GlobalCommandOptions + _ error + ) + + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + }) + + Context("unpause handler", func() { + It("should return 204 No Content on successful unpause", func() { + req, err := http.NewRequest(http.MethodPost, "/containers/id1/unpause", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "id1"}) + + service.EXPECT().Unpause(gomock.Any(), "id1", gomock.Any()).DoAndReturn( + func(ctx context.Context, cid string, opts ncTypes.ContainerUnpauseOptions) error { + return nil + }) + + h.unpause(rr, req) + Expect(rr.Body.String()).Should(BeEmpty()) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + + It("should return 400 when container ID is missing", func() { + req, err := http.NewRequest(http.MethodPost, "/containers//pause", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": ""}) + + h.unpause(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "must specify a container ID"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + + It("should return 404 when service returns a not found error", func() { + req, err := http.NewRequest(http.MethodPost, "/containers/id1/unpause", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "id1"}) + + service.EXPECT().Unpause(gomock.Any(), "id1", gomock.Any()).Return( + errdefs.NewNotFound(fmt.Errorf("container not found"))) + + h.unpause(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + + It("should return 409 when service returns a conflict error", func() { + req, err := http.NewRequest(http.MethodPost, "/containers/id1/unpause", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "id1"}) + + service.EXPECT().Unpause(gomock.Any(), "id1", gomock.Any()).Return( + errdefs.NewConflict(fmt.Errorf("container not paused"))) + + h.unpause(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not paused"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + }) + + It("should return 500 when service returns an internal error", func() { + req, err := http.NewRequest(http.MethodPost, "/containers/id1/unpause", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "id1"}) + + service.EXPECT().Unpause(gomock.Any(), "id1", gomock.Any()).Return( + fmt.Errorf("unexpected internal error")) + + h.unpause(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "unexpected internal error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) +}) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 0840dafb..af5cf44b 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -53,6 +53,7 @@ func TestRun(t *testing.T) { tests.ContainerInspect(opt) tests.ContainerWait(opt) tests.ContainerPause(opt) + tests.ContainerUnpause(opt) // functional test for volume APIs tests.VolumeList(opt) diff --git a/e2e/tests/container_unpause.go b/e2e/tests/container_unpause.go new file mode 100644 index 00000000..675f64e3 --- /dev/null +++ b/e2e/tests/container_unpause.go @@ -0,0 +1,116 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + + "github.com/runfinch/finch-daemon/api/response" + "github.com/runfinch/finch-daemon/e2e/client" +) + +func ContainerUnpause(opt *option.Option) { + Describe("unpause a container", func() { + var ( + uClient *http.Client + version string + unpauseUrl string + pauseUrl string + ) + + BeforeEach(func() { + uClient = client.NewClient(GetDockerHostUrl()) + version = GetDockerApiVersion() + relativeUrl_pause := fmt.Sprintf("/containers/%s/pause", testContainerName) + relativeUrl_unpause := fmt.Sprintf("/containers/%s/unpause", testContainerName) + unpauseUrl = client.ConvertToFinchUrl(version, relativeUrl_unpause) + pauseUrl = client.ConvertToFinchUrl(version, relativeUrl_pause) + }) + + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should unpause a paused container", func() { + // Start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + + // Pause this running container + res, err := uClient.Post(pauseUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + + // Verify container is paused + output := command.StdoutStr(opt, "inspect", "--format", "{{.State.Status}}", testContainerName) + Expect(output).Should(Equal("paused")) + + // Unpause the paused container + res, err = uClient.Post(unpauseUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + + // Verify container is running again + output = command.StdoutStr(opt, "inspect", "--format", "{{.State.Status}}", testContainerName) + Expect(output).Should(Equal("running")) + }) + + It("should fail to unpause a non-existent container", func() { + res, err := uClient.Post(unpauseUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + + var body response.Error + err = json.NewDecoder(res.Body).Decode(&body) + Expect(err).Should(BeNil()) + }) + + It("should fail to unpause a running container", func() { + // Start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + + // Try to unpause the running container + res, err := uClient.Post(unpauseUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + + var body response.Error + err = json.NewDecoder(res.Body).Decode(&body) + Expect(err).Should(BeNil()) + Expect(body.Message).Should(Equal(fmt.Sprintf("container %s is already running", testContainerName))) + + // Verify container is still running + output := command.StdoutStr(opt, "inspect", "--format", "{{.State.Status}}", testContainerName) + Expect(output).Should(Equal("running")) + }) + + It("should fail to unpause a stopped container", func() { + // Create and start a container + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + + // Verify container is running + output := command.StdoutStr(opt, "inspect", "--format", "{{.State.Status}}", testContainerName) + Expect(output).Should(Equal("running")) + + // Stop the container with a timeout to ensure it stops + command.Run(opt, "stop", "-t", "1", testContainerName) + + // Try to unpause the stopped container + res, err := uClient.Post(unpauseUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + + var body response.Error + err = json.NewDecoder(res.Body).Decode(&body) + Expect(err).Should(BeNil()) + Expect(body.Message).Should(Equal(fmt.Sprintf("container %s is not paused", testContainerName))) + }) + }) +} diff --git a/internal/backend/container.go b/internal/backend/container.go index a87f7188..dad5e9d2 100644 --- a/internal/backend/container.go +++ b/internal/backend/container.go @@ -38,6 +38,7 @@ type NerdctlContainerSvc interface { KillContainer(ctx context.Context, cid string, options types.ContainerKillOptions) error ContainerWait(ctx context.Context, cid string, options types.ContainerWaitOptions) error PauseContainer(ctx context.Context, cid string, options types.ContainerPauseOptions) error + UnpauseContainer(ctx context.Context, cid string, options types.ContainerUnpauseOptions) error // Mocked functions for container attach GetDataStore() (string, error) @@ -135,6 +136,10 @@ func (w *NerdctlWrapper) PauseContainer(ctx context.Context, cid string, options return container.Pause(ctx, w.clientWrapper.client, []string{cid}, options) } +func (w *NerdctlWrapper) UnpauseContainer(ctx context.Context, cid string, options types.ContainerUnpauseOptions) error { + return container.Unpause(ctx, w.clientWrapper.client, []string{cid}, options) +} + func (w *NerdctlWrapper) GetNerdctlExe() (string, error) { if w.nerdctlExe != "" { return w.nerdctlExe, nil diff --git a/internal/service/container/unpause.go b/internal/service/container/unpause.go new file mode 100644 index 00000000..629630f4 --- /dev/null +++ b/internal/service/container/unpause.go @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + containerd "github.com/containerd/containerd/v2/client" + cerrdefs "github.com/containerd/errdefs" + ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types" + + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Unpause(ctx context.Context, cid string, options ncTypes.ContainerUnpauseOptions) error { + cont, err := s.getContainer(ctx, cid) + if err != nil { + if cerrdefs.IsNotFound(err) { + return errdefs.NewNotFound(err) + } + return err + } + status := s.client.GetContainerStatus(ctx, cont) + if status != containerd.Paused { + if status == containerd.Running { + return errdefs.NewConflict(fmt.Errorf("container %s is already running", cid)) + } + return errdefs.NewConflict(fmt.Errorf("container %s is not paused", cid)) + } + + err = s.nctlContainerSvc.UnpauseContainer(ctx, cid, options) + if err != nil { + return err + } + + return nil +} diff --git a/internal/service/container/unpause_test.go b/internal/service/container/unpause_test.go new file mode 100644 index 00000000..e47d874c --- /dev/null +++ b/internal/service/container/unpause_test.go @@ -0,0 +1,111 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "fmt" + + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + containerd "github.com/containerd/containerd/v2/client" + cerrdefs "github.com/containerd/errdefs" + ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/runfinch/finch-daemon/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/mocks/mocks_container" + "github.com/runfinch/finch-daemon/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +var _ = Describe("Container Unpause API", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncContainerSvc *mocks_backend.MockNerdctlContainerSvc + ncNetworkSvc *mocks_backend.MockNerdctlNetworkSvc + svc *service + cid string + con *mocks_container.MockContainer + unpauseOptions ncTypes.ContainerUnpauseOptions + ) + + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncContainerSvc = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + ncNetworkSvc = mocks_backend.NewMockNerdctlNetworkSvc(mockCtrl) + + cid = "test-container-id" + unpauseOptions = ncTypes.ContainerUnpauseOptions{} + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + + svc = &service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncContainerSvc, ncNetworkSvc}, + logger: logger, + } + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("Unpause API", func() { + It("should successfully unpause a paused container", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Paused) + ncContainerSvc.EXPECT().UnpauseContainer(ctx, cid, unpauseOptions).Return(nil) + + err := svc.Unpause(ctx, cid, unpauseOptions) + Expect(err).Should(BeNil()) + }) + + It("should return NotFound error if container is not found", func() { + mockErr := cerrdefs.ErrNotFound.WithMessage(fmt.Sprintf("no such container: %s", cid)) + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return(nil, mockErr) + logger.EXPECT().Errorf("failed to search container: %s. error: %s", cid, mockErr.Error()) + + err := svc.Unpause(ctx, cid, unpauseOptions) + Expect(err.Error()).Should(Equal(errdefs.NewNotFound(fmt.Errorf("no such container: %s", cid)).Error())) + }) + + It("should return a Conflict error if container is already running", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + + err := svc.Unpause(ctx, cid, unpauseOptions) + Expect(err.Error()).Should(Equal(errdefs.NewConflict(fmt.Errorf("container %s is already running", cid)).Error())) + }) + + It("should return a Conflict error if container is not paused", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Stopped) + + err := svc.Unpause(ctx, cid, unpauseOptions) + Expect(err.Error()).Should(Equal(errdefs.NewConflict(fmt.Errorf("container %s is not paused", cid)).Error())) + }) + + It("should return a generic error if unpause operation fails", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Paused) + mockErr := errors.New("generic error while unpausing container") + ncContainerSvc.EXPECT().UnpauseContainer(ctx, cid, unpauseOptions).Return(mockErr) + + err := svc.Unpause(ctx, cid, unpauseOptions) + Expect(err).Should(Equal(mockErr)) + }) + }) +}) diff --git a/mocks/mocks_backend/nerdctlcontainersvc.go b/mocks/mocks_backend/nerdctlcontainersvc.go index 2313e953..6f92e96e 100644 --- a/mocks/mocks_backend/nerdctlcontainersvc.go +++ b/mocks/mocks_backend/nerdctlcontainersvc.go @@ -276,3 +276,17 @@ func (mr *MockNerdctlContainerSvcMockRecorder) StopContainer(arg0, arg1, arg2 in mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).StopContainer), arg0, arg1, arg2) } + +// UnpauseContainer mocks base method. +func (m *MockNerdctlContainerSvc) UnpauseContainer(arg0 context.Context, arg1 string, arg2 types.ContainerUnpauseOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnpauseContainer", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnpauseContainer indicates an expected call of UnpauseContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) UnpauseContainer(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpauseContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).UnpauseContainer), arg0, arg1, arg2) +} diff --git a/mocks/mocks_container/containersvc.go b/mocks/mocks_container/containersvc.go index a84cd9d3..edd53220 100644 --- a/mocks/mocks_container/containersvc.go +++ b/mocks/mocks_container/containersvc.go @@ -269,6 +269,20 @@ func (mr *MockServiceMockRecorder) Stop(arg0, arg1, arg2 interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockService)(nil).Stop), arg0, arg1, arg2) } +// Unpause mocks base method. +func (m *MockService) Unpause(arg0 context.Context, arg1 string, arg2 types.ContainerUnpauseOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unpause", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Unpause indicates an expected call of Unpause. +func (mr *MockServiceMockRecorder) Unpause(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unpause", reflect.TypeOf((*MockService)(nil).Unpause), arg0, arg1, arg2) +} + // Wait mocks base method. func (m *MockService) Wait(arg0 context.Context, arg1 string, arg2 types.ContainerWaitOptions) error { m.ctrl.T.Helper()