Skip to content

Commit b03f126

Browse files
authored
feat: add detachKeys option to container start (runfinch#159)
feat: add detachKeys option to container start Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent 71a062a commit b03f126

File tree

13 files changed

+172
-46
lines changed

13 files changed

+172
-46
lines changed

api/handlers/container/container.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ type Service interface {
2222
GetPathToFilesInContainer(ctx context.Context, cid string, path string) (string, func(), error)
2323
Remove(ctx context.Context, cid string, force, removeVolumes bool) error
2424
Wait(ctx context.Context, cid string, condition string) (code int64, err error)
25-
Start(ctx context.Context, cid string) error
25+
Start(ctx context.Context, cid string, options ncTypes.ContainerStartOptions) error
2626
Stop(ctx context.Context, cid string, timeout *time.Duration) error
27-
Restart(ctx context.Context, cid string, timeout time.Duration) error
27+
Restart(ctx context.Context, cid string, options ncTypes.ContainerRestartOptions) error
2828
Create(ctx context.Context, image string, cmd []string, createOpt ncTypes.ContainerCreateOptions, netOpt ncTypes.NetworkOptions) (string, error)
2929
Inspect(ctx context.Context, cid string, size bool) (*types.Container, error)
3030
WriteFilesAsTarArchive(filePath string, writer io.Writer, slashDot bool) error

api/handlers/container/container_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ var _ = Describe("Container API", func() {
6060
})
6161
It("should call container start method", func() {
6262
// setup mocks
63-
service.EXPECT().Start(gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from start api"))
63+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from start api"))
6464
req, _ = http.NewRequest(http.MethodPost, "/containers/123/start", nil)
6565
// call the API to check if it returns the error generated from start method
6666
router.ServeHTTP(rr, req)

api/handlers/container/restart.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ package container
55

66
import (
77
"net/http"
8+
"os"
89
"strconv"
910
"time"
1011

1112
"github.com/containerd/containerd/v2/pkg/namespaces"
13+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
1214
"github.com/gorilla/mux"
1315
"github.com/runfinch/finch-daemon/api/response"
1416
"github.com/runfinch/finch-daemon/pkg/errdefs"
@@ -22,8 +24,21 @@ func (h *handler) restart(w http.ResponseWriter, r *http.Request) {
2224
}
2325
timeout := time.Second * time.Duration(t)
2426

27+
devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0600)
28+
if err != nil {
29+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("failed to open /dev/null"))
30+
return
31+
}
32+
defer devNull.Close()
33+
2534
ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace)
26-
err = h.service.Restart(ctx, cid, timeout)
35+
globalOpt := ncTypes.GlobalCommandOptions(*h.Config)
36+
options := ncTypes.ContainerRestartOptions{
37+
GOption: globalOpt,
38+
Stdout: devNull,
39+
Timeout: &timeout,
40+
}
41+
err = h.service.Restart(ctx, cid, options)
2742
// map the error into http status code and send response.
2843
if err != nil {
2944
var code int

api/handlers/container/restart_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ var _ = Describe("Container Restart API ", func() {
4242
Context("handler", func() {
4343
It("should return 204 as success response", func() {
4444
// service mock returns nil to mimic handler started the container successfully.
45-
service.EXPECT().Restart(gomock.Any(), "123", gomock.Any()).Return(nil)
45+
service.EXPECT().Restart(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
4646

4747
//handler should return success message with 204 status code.
4848
h.restart(rr, req)
@@ -51,7 +51,7 @@ var _ = Describe("Container Restart API ", func() {
5151

5252
It("should return 404 not found response", func() {
5353
// service mock returns not found error to mimic user trying to start container that does not exist
54-
service.EXPECT().Restart(gomock.Any(), "123", gomock.Any()).Return(
54+
service.EXPECT().Restart(gomock.Any(), gomock.Any(), gomock.Any()).Return(
5555
errdefs.NewNotFound(fmt.Errorf("container not found")))
5656
logger.EXPECT().Debugf("Restart container API responding with error code. Status code %d, Message: %s", 404, "container not found")
5757

@@ -63,7 +63,7 @@ var _ = Describe("Container Restart API ", func() {
6363
It("should return 500 internal error response", func() {
6464
// service mock return error to mimic a user trying to start a container with an id that has
6565
// multiple containers with same prefix.
66-
service.EXPECT().Restart(gomock.Any(), "123", gomock.Any()).Return(
66+
service.EXPECT().Restart(gomock.Any(), gomock.Any(), gomock.Any()).Return(
6767
fmt.Errorf("multiple IDs found with provided prefix"))
6868
logger.EXPECT().Debugf("Restart container API responding with error code. Status code %d, Message: %s", 500, "multiple IDs found with provided prefix")
6969

api/handlers/container/start.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
package container
55

66
import (
7+
"fmt"
78
"net/http"
9+
"os"
10+
"strings"
11+
"unicode"
812

913
"github.com/containerd/containerd/v2/pkg/namespaces"
14+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
1015
"github.com/gorilla/mux"
1116
"github.com/sirupsen/logrus"
1217

@@ -17,7 +22,30 @@ import (
1722
func (h *handler) start(w http.ResponseWriter, r *http.Request) {
1823
cid := mux.Vars(r)["id"]
1924
ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace)
20-
err := h.service.Start(ctx, cid)
25+
26+
detachKeys := r.URL.Query().Get("detachKeys")
27+
28+
if err := validateDetachKeys(detachKeys); err != nil {
29+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("Invalid detach keys: %v", err)))
30+
return
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.ContainerStartOptions{
42+
GOptions: globalOpt,
43+
Stdout: devNull,
44+
Attach: false,
45+
DetachKeys: detachKeys,
46+
}
47+
48+
err = h.service.Start(ctx, cid, options)
2149
// map the error into http status code and send response.
2250
if err != nil {
2351
var code int
@@ -36,3 +64,40 @@ func (h *handler) start(w http.ResponseWriter, r *http.Request) {
3664
// successfully started the container. Send no content status.
3765
response.Status(w, http.StatusNoContent)
3866
}
67+
68+
func validateDetachKeys(detachKeys string) error {
69+
if detachKeys == "" {
70+
return nil // Empty string is valid (use default)
71+
}
72+
73+
parts := strings.Split(detachKeys, ",")
74+
for _, part := range parts {
75+
if err := validateSingleKey(part); err != nil {
76+
return err
77+
}
78+
}
79+
80+
return nil
81+
}
82+
83+
func validateSingleKey(key string) error {
84+
key = strings.TrimSpace(key)
85+
if len(key) == 1 {
86+
// Single character key
87+
if !unicode.IsLetter(rune(key[0])) {
88+
return fmt.Errorf("invalid key: %s - single character must be a letter", key)
89+
}
90+
} else if strings.HasPrefix(key, "ctrl-") {
91+
ctrlKey := strings.TrimPrefix(key, "ctrl-")
92+
if len(ctrlKey) != 1 {
93+
return fmt.Errorf("invalid ctrl key: %s - must be a single character", ctrlKey)
94+
}
95+
validCtrlKeys := "abcdefghijklmnopqrstuvwxyz@[\\]^_"
96+
if !strings.Contains(validCtrlKeys, strings.ToLower(ctrlKey)) {
97+
return fmt.Errorf("invalid ctrl key: %s - must be one of %s", ctrlKey, validCtrlKeys)
98+
}
99+
} else {
100+
return fmt.Errorf("invalid key: %s - must be a single character or ctrl- combination", key)
101+
}
102+
return nil
103+
}

api/handlers/container/start_test.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
package container
55

66
import (
7+
"context"
78
"fmt"
89
"net/http"
910
"net/http/httptest"
1011

12+
ncTypes "github.com/containerd/nerdctl/v2/pkg/api/types"
1113
"github.com/containerd/nerdctl/v2/pkg/config"
1214
"github.com/golang/mock/gomock"
1315
"github.com/gorilla/mux"
@@ -42,15 +44,15 @@ var _ = Describe("Container Start API ", func() {
4244
Context("handler", func() {
4345
It("should return 204 as success response", func() {
4446
// service mock returns nil to mimic handler started the container successfully.
45-
service.EXPECT().Start(gomock.Any(), "123").Return(nil)
47+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
4648

4749
h.start(rr, req)
4850
Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent))
4951
})
5052

5153
It("should return 404 not found response", func() {
5254
// service mock returns not found error to mimic user trying to start container that does not exist
53-
service.EXPECT().Start(gomock.Any(), "123").Return(
55+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Return(
5456
errdefs.NewNotFound(fmt.Errorf("container not found")))
5557
h.start(rr, req)
5658
Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound))
@@ -59,7 +61,7 @@ var _ = Describe("Container Start API ", func() {
5961
It("should return 500 internal error response", func() {
6062
// service mock return error to mimic a user trying to start a container with an id that has
6163
// multiple containers with same prefix.
62-
service.EXPECT().Start(gomock.Any(), "123").Return(
64+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Return(
6365
fmt.Errorf("multiple IDs found with provided prefix"))
6466

6567
h.start(rr, req)
@@ -68,11 +70,38 @@ var _ = Describe("Container Start API ", func() {
6870
})
6971
It("should return 304 not-modified error when container is already running", func() {
7072
// service mock returns not found error to mimic user trying to start container that is running
71-
service.EXPECT().Start(gomock.Any(), "123").Return(
73+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Return(
7274
errdefs.NewNotModified(fmt.Errorf("container already running")))
7375

7476
h.start(rr, req)
7577
Expect(rr).Should(HaveHTTPStatus(http.StatusNotModified))
7678
})
79+
It("should pass detachKeys to the service", func() {
80+
// Set up the request with detachKeys query parameter
81+
req, _ = http.NewRequest(http.MethodPost, "/containers/123/start?detachKeys=ctrl-p,ctrl-q", nil)
82+
req = mux.SetURLVars(req, map[string]string{"id": "123"})
83+
84+
// Expect the service to be called with the correct options
85+
service.EXPECT().Start(
86+
gomock.Any(),
87+
gomock.Any(),
88+
gomock.Any(),
89+
).DoAndReturn(func(_ context.Context, cid string, options ncTypes.ContainerStartOptions) error {
90+
Expect(cid).To(Equal("123"))
91+
Expect(options.DetachKeys).To(Equal("ctrl-p,ctrl-q"))
92+
return nil
93+
})
94+
95+
h.start(rr, req)
96+
Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent))
97+
})
98+
It("should return 400 Bad Request for invalid ctrl- combination", func() {
99+
req, _ = http.NewRequest(http.MethodPost, "/containers/123/start?detachKeys=ctrl-1", nil)
100+
req = mux.SetURLVars(req, map[string]string{"id": "123"})
101+
service.EXPECT().Start(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
102+
h.start(rr, req)
103+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
104+
Expect(rr.Body).Should(MatchJSON(`{"message": "Invalid detach keys: invalid ctrl key: 1 - must be one of abcdefghijklmnopqrstuvwxyz@[\\]^_"}`))
105+
})
77106
})
78107
})

internal/backend/container.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import (
2727
//go:generate mockgen --destination=../../mocks/mocks_backend/nerdctlcontainersvc.go -package=mocks_backend github.com/runfinch/finch-daemon/internal/backend NerdctlContainerSvc
2828
type NerdctlContainerSvc interface {
2929
RemoveContainer(ctx context.Context, c containerd.Container, force bool, removeAnonVolumes bool) error
30-
StartContainer(ctx context.Context, container containerd.Container) error
30+
StartContainer(ctx context.Context, cid string, options types.ContainerStartOptions) error
3131
StopContainer(ctx context.Context, container containerd.Container, timeout *time.Duration) error
3232
CreateContainer(ctx context.Context, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error)
3333
InspectContainer(ctx context.Context, c containerd.Container, size bool) (*dockercompat.Container, error)
@@ -51,8 +51,8 @@ func (w *NerdctlWrapper) RemoveContainer(ctx context.Context, c containerd.Conta
5151
}
5252

5353
// StartContainer wrapper function to call nerdctl function to start a container.
54-
func (w *NerdctlWrapper) StartContainer(ctx context.Context, container containerd.Container) error {
55-
return containerutil.Start(ctx, container, false, w.clientWrapper.client, "")
54+
func (w *NerdctlWrapper) StartContainer(ctx context.Context, cid string, options types.ContainerStartOptions) error {
55+
return container.Start(ctx, w.clientWrapper.client, []string{cid}, options)
5656
}
5757

5858
// StopContainer wrapper function to call nerdctl function to stop a container.

internal/service/container/restart.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ package container
55

66
import (
77
"context"
8-
"time"
98

9+
"github.com/containerd/nerdctl/v2/pkg/api/types"
1010
"github.com/runfinch/finch-daemon/pkg/errdefs"
1111
)
1212

13-
func (s *service) Restart(ctx context.Context, cid string, timeout time.Duration) error {
13+
func (s *service) Restart(ctx context.Context, cid string, options types.ContainerRestartOptions) error {
1414
con, err := s.getContainer(ctx, cid)
1515
if err != nil {
1616
return err
@@ -19,11 +19,19 @@ func (s *service) Restart(ctx context.Context, cid string, timeout time.Duration
1919
// restart the container and if error occurs then return error otherwise return nil
2020
// swallow IsNotModified error on StopContainer for already stopped container, simply call StartContainer
2121
s.logger.Debugf("restarting container: %s", cid)
22-
if err := s.nctlContainerSvc.StopContainer(ctx, con, &timeout); err != nil && !errdefs.IsNotModified(err) {
22+
if err := s.nctlContainerSvc.StopContainer(ctx, con, options.Timeout); err != nil && !errdefs.IsNotModified(err) {
2323
s.logger.Errorf("Failed to stop container: %s. Error: %v", cid, err)
2424
return err
2525
}
26-
if err = s.nctlContainerSvc.StartContainer(ctx, con); err != nil {
26+
27+
startContainerOptions := types.ContainerStartOptions{
28+
Stdout: options.Stdout,
29+
GOptions: options.GOption,
30+
DetachKeys: "",
31+
Attach: false,
32+
}
33+
34+
if err = s.nctlContainerSvc.StartContainer(ctx, cid, startContainerOptions); err != nil {
2735
s.logger.Errorf("Failed to start container: %s. Error: %v", cid, err)
2836
return err
2937
}

0 commit comments

Comments
 (0)