Skip to content

Commit 8a40617

Browse files
feat: Add container kill API (runfinch#146)
Signed-off-by: Shubharanshu Mahapatra <[email protected]>
1 parent 96b109e commit 8a40617

File tree

11 files changed

+440
-3
lines changed

11 files changed

+440
-3
lines changed

api/handlers/container/container.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Service interface {
3535
ExtractArchiveInContainer(ctx context.Context, putArchiveOpt *types.PutArchiveOptions, body io.ReadCloser) error
3636
Stats(ctx context.Context, cid string) (<-chan *types.StatsJSON, error)
3737
ExecCreate(ctx context.Context, cid string, config types.ExecConfig) (string, error)
38+
Kill(ctx context.Context, cid string, options ncTypes.ContainerKillOptions) error
3839
}
3940

4041
// RegisterHandlers register all the supported endpoints related to the container APIs.
@@ -58,6 +59,7 @@ func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Con
5859
r.HandleFunc("/{id:.*}/archive", h.putArchive, http.MethodPut)
5960
r.HandleFunc("/{id:.*}/stats", h.stats, http.MethodGet)
6061
r.HandleFunc("/{id:.*}/exec", h.exec, http.MethodPost)
62+
r.HandleFunc("/{id:.*}/kill", h.kill, http.MethodPost)
6163
}
6264

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

api/handlers/container/kill.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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/namespaces"
11+
ncTypes "github.com/containerd/nerdctl/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+
// kill creates a new kill instance.
19+
func (h *handler) kill(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+
signal := r.URL.Query().Get("signal")
29+
if signal == "" {
30+
signal = "SIGKILL"
31+
}
32+
33+
devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0600)
34+
if err != nil {
35+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("failed to open /dev/null"))
36+
return
37+
}
38+
defer devNull.Close()
39+
40+
globalOpt := ncTypes.GlobalCommandOptions(*h.Config)
41+
options := ncTypes.ContainerKillOptions{
42+
GOptions: globalOpt,
43+
KillSignal: signal,
44+
Stdout: devNull,
45+
Stderr: devNull,
46+
}
47+
48+
err = h.service.Kill(ctx, cid, options)
49+
if err != nil {
50+
var code int
51+
switch {
52+
case errdefs.IsNotFound(err):
53+
code = http.StatusNotFound
54+
case errdefs.IsConflict(err):
55+
code = http.StatusConflict
56+
default:
57+
code = http.StatusInternalServerError
58+
}
59+
response.JSON(w, code, response.NewError(err))
60+
return
61+
}
62+
63+
response.Status(w, http.StatusNoContent)
64+
}

api/handlers/container/kill_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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/pkg/api/types"
13+
"github.com/containerd/nerdctl/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 Kill 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("kill handler", func() {
46+
It("should return 204 No Content on successful kill", func() {
47+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/kill", nil)
48+
Expect(err).Should(BeNil())
49+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
50+
req.URL.RawQuery = "signal=SIGTERM"
51+
52+
service.EXPECT().Kill(gomock.Any(), "id1", gomock.Any()).DoAndReturn(func(ctx context.Context, cid string, opts ncTypes.ContainerKillOptions) error {
53+
Expect(opts.KillSignal).Should(Equal("SIGTERM"))
54+
return nil
55+
})
56+
57+
h.kill(rr, req)
58+
Expect(rr.Body.String()).Should(BeEmpty())
59+
Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent))
60+
})
61+
62+
It("should return 400 when container ID is missing", func() {
63+
req, err := http.NewRequest(http.MethodPost, "/containers//kill", nil)
64+
Expect(err).Should(BeNil())
65+
req = mux.SetURLVars(req, map[string]string{"id": ""})
66+
67+
h.kill(rr, req)
68+
Expect(rr.Body).Should(MatchJSON(`{"message": "must specify a container ID"}`))
69+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
70+
})
71+
It("should return 404 when service returns a not found error", func() {
72+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/kill", nil)
73+
Expect(err).Should(BeNil())
74+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
75+
76+
service.EXPECT().Kill(gomock.Any(), "id1", gomock.Any()).Return(errdefs.NewNotFound(fmt.Errorf("not found")))
77+
78+
h.kill(rr, req)
79+
Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`))
80+
Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound))
81+
})
82+
83+
It("should return 409 when service returns a conflict error", func() {
84+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/kill", nil)
85+
Expect(err).Should(BeNil())
86+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
87+
88+
service.EXPECT().Kill(gomock.Any(), "id1", gomock.Any()).Return(errdefs.NewConflict(fmt.Errorf("conflict")))
89+
90+
h.kill(rr, req)
91+
Expect(rr.Body).Should(MatchJSON(`{"message": "conflict"}`))
92+
Expect(rr).Should(HaveHTTPStatus(http.StatusConflict))
93+
})
94+
95+
It("should return 500 when service returns an internal error", func() {
96+
req, err := http.NewRequest(http.MethodPost, "/containers/id1/kill", nil)
97+
Expect(err).Should(BeNil())
98+
req = mux.SetURLVars(req, map[string]string{"id": "id1"})
99+
100+
service.EXPECT().Kill(gomock.Any(), "id1", gomock.Any()).Return(fmt.Errorf("unexpected error"))
101+
102+
h.kill(rr, req)
103+
Expect(rr.Body).Should(MatchJSON(`{"message": "unexpected error"}`))
104+
Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError))
105+
})
106+
})
107+
})

e2e/e2e_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func TestRun(t *testing.T) {
4747
tests.ContainerStats(opt)
4848
tests.ContainerAttach(opt)
4949
tests.ContainerLogs(opt)
50+
tests.ContainerKill(opt)
5051

5152
// functional test for volume APIs
5253
tests.VolumeList(opt)

e2e/tests/container_kill.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
"time"
11+
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
"github.com/runfinch/common-tests/command"
15+
"github.com/runfinch/common-tests/option"
16+
17+
"github.com/runfinch/finch-daemon/api/response"
18+
"github.com/runfinch/finch-daemon/e2e/client"
19+
)
20+
21+
func ContainerKill(opt *option.Option) {
22+
Describe("kill a container", func() {
23+
var (
24+
uClient *http.Client
25+
version string
26+
apiUrl string
27+
)
28+
BeforeEach(func() {
29+
uClient = client.NewClient(GetDockerHostUrl())
30+
version = GetDockerApiVersion()
31+
relativeUrl := fmt.Sprintf("/containers/%s/kill", testContainerName)
32+
apiUrl = client.ConvertToFinchUrl(version, relativeUrl)
33+
})
34+
AfterEach(func() {
35+
command.RemoveAll(opt)
36+
})
37+
It("should kill the container with default SIGKILL", func() {
38+
// start a container that keeps running
39+
command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity")
40+
res, err := uClient.Post(apiUrl, "application/json", nil)
41+
Expect(err).Should(BeNil())
42+
Expect(res.StatusCode).Should(Equal(http.StatusNoContent))
43+
containerShouldNotBeRunning(opt, testContainerName)
44+
})
45+
It("should fail to kill a non-existent container", func() {
46+
res, err := uClient.Post(apiUrl, "application/json", nil)
47+
Expect(err).Should(BeNil())
48+
Expect(res.StatusCode).Should(Equal(http.StatusNotFound))
49+
var body response.Error
50+
err = json.NewDecoder(res.Body).Decode(&body)
51+
Expect(err).Should(BeNil())
52+
})
53+
It("should fail to kill a non running container", func() {
54+
command.Run(opt, "create", "--name", testContainerName, defaultImage, "sleep", "infinity")
55+
res, err := uClient.Post(apiUrl, "application/json", nil)
56+
Expect(err).Should(BeNil())
57+
Expect(res.StatusCode).Should(Equal(http.StatusConflict))
58+
var body response.Error
59+
err = json.NewDecoder(res.Body).Decode(&body)
60+
Expect(err).Should(BeNil())
61+
containerShouldExist(opt, testContainerName)
62+
})
63+
It("should kill the container with SIGINT", func() {
64+
relativeUrl := fmt.Sprintf("/containers/%s/kill?signal=SIGINT", testContainerName)
65+
apiUrl = client.ConvertToFinchUrl(version, relativeUrl)
66+
// sleep infinity doesnot respond to SIGINT
67+
command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "/bin/sh", "-c", "trap 'exit 0' SIGINT; while true; do sleep 1; done")
68+
res, err := uClient.Post(apiUrl, "application/json", nil)
69+
Expect(err).Should(BeNil())
70+
Expect(res.StatusCode).Should(Equal(http.StatusNoContent))
71+
// This is an async operation as a result we need to wait for the container to exit gracefully before checking the status
72+
time.Sleep(1 * time.Second)
73+
containerShouldNotBeRunning(opt, testContainerName)
74+
})
75+
It("should not kill the container and throw error on unrecognized signal", func() {
76+
relativeUrl := fmt.Sprintf("/containers/%s/kill?signal=SIGRAND", testContainerName)
77+
apiUrl = client.ConvertToFinchUrl(version, relativeUrl)
78+
command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity")
79+
res, err := uClient.Post(apiUrl, "application/json", nil)
80+
Expect(err).Should(BeNil())
81+
Expect(res.StatusCode).Should(Equal(http.StatusInternalServerError))
82+
containerShouldExist(opt, testContainerName)
83+
containerShouldBeRunning(opt, testContainerName)
84+
})
85+
})
86+
}

internal/backend/container.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type NerdctlContainerSvc interface {
3232
NewNetworkingOptionsManager(types.NetworkOptions) (containerutil.NetworkOptionsManager, error)
3333
ListContainers(ctx context.Context, options types.ContainerListOptions) ([]container.ListItem, error)
3434
RenameContainer(ctx context.Context, container containerd.Container, newName string, options types.ContainerRenameOptions) error
35+
KillContainer(ctx context.Context, cid string, options types.ContainerKillOptions) error
3536

3637
// Mocked functions for container attach
3738
GetDataStore() (string, error)
@@ -96,6 +97,10 @@ func (*NerdctlWrapper) LoggingPrintLogsTo(stdout, stderr io.Writer, clv *logging
9697
return clv.PrintLogsTo(stdout, stderr)
9798
}
9899

100+
func (w *NerdctlWrapper) KillContainer(ctx context.Context, cid string, options types.ContainerKillOptions) error {
101+
return container.Kill(ctx, w.clientWrapper.client, []string{cid}, options)
102+
}
103+
99104
func (w *NerdctlWrapper) GetNerdctlExe() (string, error) {
100105
if w.nerdctlExe != "" {
101106
return w.nerdctlExe, nil

internal/service/container/kill.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
"github.com/containerd/containerd"
11+
cerrdefs "github.com/containerd/errdefs"
12+
ncTypes "github.com/containerd/nerdctl/pkg/api/types"
13+
14+
"github.com/runfinch/finch-daemon/pkg/errdefs"
15+
)
16+
17+
func (s *service) Kill(ctx context.Context, cid string, options ncTypes.ContainerKillOptions) 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+
return errdefs.NewConflict(fmt.Errorf("container %s is not running", cid))
28+
}
29+
30+
err = s.nctlContainerSvc.KillContainer(ctx, cid, options)
31+
if err != nil {
32+
return err
33+
}
34+
35+
return nil
36+
}

0 commit comments

Comments
 (0)