Skip to content

Commit 9aa41bf

Browse files
authored
feat: Add pause container support (runfinch#185)
Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent f285fcd commit 9aa41bf

File tree

11 files changed

+439
-1
lines changed

11 files changed

+439
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
GINKGO = go run github.com/onsi/ginkgo/v2/ginkgo
55
# Common ginkgo options: -v for verbose mode, --focus="test name" for running single tests
6-
GFLAGS ?= --race --randomize-all --randomize-suites --vv
6+
GFLAGS ?= --race --randomize-all --randomize-suites
77
BIN = $(PWD)/bin
88
FINCH_DAEMON_PROJECT_ROOT ?= $(shell pwd)
99

api/handlers/container/container.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Service interface {
3636
Stats(ctx context.Context, cid string) (<-chan *types.StatsJSON, error)
3737
ExecCreate(ctx context.Context, cid string, config types.ExecConfig) (string, error)
3838
Kill(ctx context.Context, cid string, options ncTypes.ContainerKillOptions) error
39+
Pause(ctx context.Context, cid string, options ncTypes.ContainerPauseOptions) error
3940
}
4041

4142
// RegisterHandlers register all the supported endpoints related to the container APIs.
@@ -60,6 +61,7 @@ func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Con
6061
r.HandleFunc("/{id:.*}/stats", h.stats, http.MethodGet)
6162
r.HandleFunc("/{id:.*}/exec", h.exec, http.MethodPost)
6263
r.HandleFunc("/{id:.*}/kill", h.kill, http.MethodPost)
64+
r.HandleFunc("/{id:.*}/pause", h.pause, http.MethodPost)
6365
}
6466

6567
// newHandler creates the handler that serves all the container related APIs.

api/handlers/container/pause.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package container
5+
6+
import (
7+
"net/http"
8+
"os"
9+
10+
"github.com/containerd/containerd/v2/pkg/namespaces"
11+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
12+
"github.com/gorilla/mux"
13+
14+
"github.com/runfinch/finch-daemon/api/response"
15+
"github.com/runfinch/finch-daemon/pkg/errdefs"
16+
)
17+
18+
// pause pauses a running container.
19+
func (h *handler) pause(w http.ResponseWriter, r *http.Request) {
20+
cid, ok := mux.Vars(r)["id"]
21+
if !ok || cid == "" {
22+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("must specify a container ID"))
23+
return
24+
}
25+
26+
ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace)
27+
28+
devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0600)
29+
if err != nil {
30+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("failed to open /dev/null"))
31+
return
32+
}
33+
defer devNull.Close()
34+
35+
globalOpt := ncTypes.GlobalCommandOptions(*h.Config)
36+
options := ncTypes.ContainerPauseOptions{
37+
GOptions: globalOpt,
38+
Stdout: devNull,
39+
}
40+
41+
err = h.service.Pause(ctx, cid, options)
42+
if err != nil {
43+
var code int
44+
switch {
45+
case errdefs.IsNotFound(err):
46+
code = http.StatusNotFound
47+
case errdefs.IsConflict(err):
48+
code = http.StatusConflict
49+
default:
50+
code = http.StatusInternalServerError
51+
}
52+
response.JSON(w, code, response.NewError(err))
53+
return
54+
}
55+
56+
response.Status(w, http.StatusNoContent)
57+
}

api/handlers/container/pause_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package container
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"net/http/httptest"
11+
12+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
13+
"github.com/containerd/nerdctl/v2/pkg/config"
14+
"github.com/golang/mock/gomock"
15+
"github.com/gorilla/mux"
16+
. "github.com/onsi/ginkgo/v2"
17+
. "github.com/onsi/gomega"
18+
"github.com/runfinch/finch-daemon/pkg/errdefs"
19+
20+
"github.com/runfinch/finch-daemon/mocks/mocks_container"
21+
"github.com/runfinch/finch-daemon/mocks/mocks_logger"
22+
)
23+
24+
var _ = Describe("Container Pause API", func() {
25+
var (
26+
mockCtrl *gomock.Controller
27+
logger *mocks_logger.Logger
28+
service *mocks_container.MockService
29+
h *handler
30+
rr *httptest.ResponseRecorder
31+
_ ncTypes.GlobalCommandOptions
32+
_ error
33+
)
34+
35+
BeforeEach(func() {
36+
mockCtrl = gomock.NewController(GinkgoT())
37+
defer mockCtrl.Finish()
38+
logger = mocks_logger.NewLogger(mockCtrl)
39+
service = mocks_container.NewMockService(mockCtrl)
40+
c := config.Config{}
41+
h = newHandler(service, &c, logger)
42+
rr = httptest.NewRecorder()
43+
})
44+
45+
Context("pause handler", func() {
46+
It("should return 204 No Content on successful pause", func() {
47+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/pause", nil)
48+
Expect(err).Should(BeNil())
49+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
50+
51+
service.EXPECT().Pause(gomock.Any(), "id1", gomock.Any()).DoAndReturn(
52+
func(ctx context.Context, cid string, opts ncTypes.ContainerPauseOptions) error {
53+
return nil
54+
})
55+
56+
h.pause(rr, req)
57+
Expect(rr.Body.String()).Should(BeEmpty())
58+
Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent))
59+
})
60+
61+
It("should return 400 when container ID is missing", func() {
62+
req, err := http.NewRequest(http.MethodPost, "/containers//pause", nil)
63+
Expect(err).Should(BeNil())
64+
req = mux.SetURLVars(req, map[string]string{"id": ""})
65+
66+
h.pause(rr, req)
67+
Expect(rr.Body).Should(MatchJSON(`{"message": "must specify a container ID"}`))
68+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
69+
})
70+
71+
It("should return 404 when service returns a not found error", func() {
72+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/pause", nil)
73+
Expect(err).Should(BeNil())
74+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
75+
76+
service.EXPECT().Pause(gomock.Any(), "id1", gomock.Any()).Return(
77+
errdefs.NewNotFound(fmt.Errorf("container not found")))
78+
79+
h.pause(rr, req)
80+
Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`))
81+
Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound))
82+
})
83+
84+
It("should return 409 when service returns a conflict error", func() {
85+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/pause", nil)
86+
Expect(err).Should(BeNil())
87+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
88+
89+
service.EXPECT().Pause(gomock.Any(), "id1", gomock.Any()).Return(
90+
errdefs.NewConflict(fmt.Errorf("container already paused")))
91+
92+
h.pause(rr, req)
93+
Expect(rr.Body).Should(MatchJSON(`{"message": "container already paused"}`))
94+
Expect(rr).Should(HaveHTTPStatus(http.StatusConflict))
95+
})
96+
97+
It("should return 500 when service returns an internal error", func() {
98+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/pause", nil)
99+
Expect(err).Should(BeNil())
100+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
101+
102+
service.EXPECT().Pause(gomock.Any(), "id1", gomock.Any()).Return(
103+
fmt.Errorf("unexpected internal error"))
104+
105+
h.pause(rr, req)
106+
Expect(rr.Body).Should(MatchJSON(`{"message": "unexpected internal error"}`))
107+
Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError))
108+
})
109+
})
110+
})

e2e/e2e_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func TestRun(t *testing.T) {
5252
tests.ContainerKill(opt)
5353
tests.ContainerInspect(opt)
5454
tests.ContainerWait(opt)
55+
tests.ContainerPause(opt)
5556

5657
// functional test for volume APIs
5758
tests.VolumeList(opt)

e2e/tests/container_pause.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package tests
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"net/http"
10+
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
"github.com/runfinch/common-tests/command"
14+
"github.com/runfinch/common-tests/option"
15+
16+
"github.com/runfinch/finch-daemon/api/response"
17+
"github.com/runfinch/finch-daemon/e2e/client"
18+
)
19+
20+
func ContainerPause(opt *option.Option) {
21+
Describe("pause a container", func() {
22+
var (
23+
uClient *http.Client
24+
version string
25+
apiUrl string
26+
)
27+
28+
BeforeEach(func() {
29+
uClient = client.NewClient(GetDockerHostUrl())
30+
version = GetDockerApiVersion()
31+
relativeUrl := fmt.Sprintf("/containers/%s/pause", testContainerName)
32+
apiUrl = client.ConvertToFinchUrl(version, relativeUrl)
33+
})
34+
35+
AfterEach(func() {
36+
command.RemoveAll(opt)
37+
})
38+
39+
It("should pause a running container", func() {
40+
// Start a container that keeps running
41+
command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity")
42+
43+
res, err := uClient.Post(apiUrl, "application/json", nil)
44+
Expect(err).Should(BeNil())
45+
Expect(res.StatusCode).Should(Equal(http.StatusNoContent))
46+
47+
// Verify container is paused
48+
output := command.StdoutStr(opt, "inspect", "--format", "{{.State.Status}}", testContainerName)
49+
Expect(output).Should(Equal("paused"))
50+
})
51+
52+
It("should fail to pause a non-existent container", func() {
53+
res, err := uClient.Post(apiUrl, "application/json", nil)
54+
Expect(err).Should(BeNil())
55+
Expect(res.StatusCode).Should(Equal(http.StatusNotFound))
56+
57+
var body response.Error
58+
err = json.NewDecoder(res.Body).Decode(&body)
59+
Expect(err).Should(BeNil())
60+
})
61+
62+
It("should fail to pause a non-running container", func() {
63+
command.Run(opt, "create", "--name", testContainerName, defaultImage, "sleep", "infinity")
64+
65+
res, err := uClient.Post(apiUrl, "application/json", nil)
66+
Expect(err).Should(BeNil())
67+
Expect(res.StatusCode).Should(Equal(http.StatusConflict))
68+
69+
var body response.Error
70+
err = json.NewDecoder(res.Body).Decode(&body)
71+
Expect(err).Should(BeNil())
72+
73+
containerShouldExist(opt, testContainerName)
74+
})
75+
76+
It("should fail to pause an already paused container", func() {
77+
// Start and pause the container
78+
command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity")
79+
command.Run(opt, "pause", testContainerName)
80+
81+
res, err := uClient.Post(apiUrl, "application/json", nil)
82+
Expect(err).Should(BeNil())
83+
Expect(res.StatusCode).Should(Equal(http.StatusConflict))
84+
85+
var body response.Error
86+
err = json.NewDecoder(res.Body).Decode(&body)
87+
Expect(err).Should(BeNil())
88+
})
89+
})
90+
}

internal/backend/container.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type NerdctlContainerSvc interface {
3737
RenameContainer(ctx context.Context, container containerd.Container, newName string, options types.ContainerRenameOptions) error
3838
KillContainer(ctx context.Context, cid string, options types.ContainerKillOptions) error
3939
ContainerWait(ctx context.Context, cid string, options types.ContainerWaitOptions) error
40+
PauseContainer(ctx context.Context, cid string, options types.ContainerPauseOptions) error
4041

4142
// Mocked functions for container attach
4243
GetDataStore() (string, error)
@@ -130,6 +131,10 @@ func (w *NerdctlWrapper) ContainerWait(ctx context.Context, cid string, options
130131
return container.Wait(ctx, w.clientWrapper.client, []string{cid}, options)
131132
}
132133

134+
func (w *NerdctlWrapper) PauseContainer(ctx context.Context, cid string, options types.ContainerPauseOptions) error {
135+
return container.Pause(ctx, w.clientWrapper.client, []string{cid}, options)
136+
}
137+
133138
func (w *NerdctlWrapper) GetNerdctlExe() (string, error) {
134139
if w.nerdctlExe != "" {
135140
return w.nerdctlExe, nil

internal/service/container/pause.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package container
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
containerd "github.com/containerd/containerd/v2/client"
11+
cerrdefs "github.com/containerd/errdefs"
12+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
13+
14+
"github.com/runfinch/finch-daemon/pkg/errdefs"
15+
)
16+
17+
func (s *service) Pause(ctx context.Context, cid string, options ncTypes.ContainerPauseOptions) error {
18+
cont, err := s.getContainer(ctx, cid)
19+
if err != nil {
20+
if cerrdefs.IsNotFound(err) {
21+
return errdefs.NewNotFound(err)
22+
}
23+
return err
24+
}
25+
status := s.client.GetContainerStatus(ctx, cont)
26+
if status != containerd.Running {
27+
if status == containerd.Paused {
28+
return errdefs.NewConflict(fmt.Errorf("container %s is already paused", cid))
29+
}
30+
return errdefs.NewConflict(fmt.Errorf("container %s is not running", cid))
31+
}
32+
33+
return s.nctlContainerSvc.PauseContainer(ctx, cid, options)
34+
}

0 commit comments

Comments
 (0)