Skip to content

Commit 4198dcc

Browse files
authored
feat: Add devices option (runfinch#236)
Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent 831e2a2 commit 4198dcc

File tree

5 files changed

+232
-28
lines changed

5 files changed

+232
-28
lines changed

api/handlers/container/create.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"encoding/json"
88
"fmt"
99
"net/http"
10+
"os"
11+
"path/filepath"
1012
"strconv"
1113
"strings"
1214

@@ -24,6 +26,8 @@ import (
2426
"github.com/runfinch/finch-daemon/pkg/utility/maputility"
2527
)
2628

29+
const errGatheringDeviceInfo = "error gathering device information while adding custom device"
30+
2731
type containerCreateResponse struct {
2832
ID string `json:"Id"`
2933
}
@@ -197,6 +201,16 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) {
197201
} else {
198202
cgroupnsMode = defaults.CgroupnsMode()
199203
}
204+
devices := []string{}
205+
if req.HostConfig.Devices != nil {
206+
// Validate device configurations
207+
for _, device := range req.HostConfig.Devices {
208+
if err := validateDevice(w, device.PathOnHost); err != nil {
209+
return
210+
}
211+
}
212+
devices = translateDevices(req.HostConfig.Devices)
213+
}
200214

201215
globalOpt := ncTypes.GlobalCommandOptions(*h.Config)
202216
createOpt := ncTypes.ContainerCreateOptions{
@@ -247,6 +261,8 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) {
247261
BlkioDeviceWriteIOps: throttleDevicesToStrings(req.HostConfig.BlkioDeviceWriteIOps),
248262
IPC: req.HostConfig.IpcMode, // IPC namespace to use
249263
ShmSize: shmSize,
264+
Device: devices, // Device specifies add a host device to the container
265+
250266
// #endregion
251267

252268
// #region for user flags
@@ -440,3 +456,61 @@ func translateAnnotations(annotations map[string]string) []string {
440456
}
441457
return result
442458
}
459+
460+
// translateDevices converts a slice of DeviceMapping to a slice of strings in the format "PATH_ON_HOST[:PATH_IN_CONTAINER][:CGROUP_PERMISSIONS]".
461+
func translateDevices(devices []types.DeviceMapping) []string {
462+
if devices == nil {
463+
return nil
464+
}
465+
466+
var result []string
467+
for _, deviceMap := range devices {
468+
deviceString := deviceMap.PathOnHost
469+
470+
if deviceMap.PathInContainer != "" {
471+
deviceString += ":" + deviceMap.PathInContainer
472+
if deviceMap.CgroupPermissions != "" {
473+
deviceString += ":" + deviceMap.CgroupPermissions
474+
}
475+
} else if deviceMap.CgroupPermissions != "" {
476+
deviceString += ":" + deviceMap.CgroupPermissions
477+
}
478+
479+
result = append(result, deviceString)
480+
}
481+
return result
482+
}
483+
484+
// validateDevice validates a device path and returns an error if validation fails.
485+
// The error is sent as a JSON response with HTTP 400 status code.
486+
func validateDevice(w http.ResponseWriter, pathOnHost string) error {
487+
if pathOnHost == "" {
488+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(errGatheringDeviceInfo))
489+
return fmt.Errorf("empty device path")
490+
}
491+
492+
// Check if path exists and resolve symlinks
493+
resolvedPath := pathOnHost
494+
if src, err := os.Lstat(pathOnHost); err != nil {
495+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("%s %q: %v", errGatheringDeviceInfo, pathOnHost, err)))
496+
return err
497+
} else if src.Mode()&os.ModeSymlink == os.ModeSymlink {
498+
if linkedPath, err := filepath.EvalSymlinks(pathOnHost); err != nil {
499+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("%s %q: %v", errGatheringDeviceInfo, pathOnHost, err)))
500+
return err
501+
} else {
502+
resolvedPath = linkedPath
503+
}
504+
}
505+
506+
// Check if path is a device or directory
507+
if src, err := os.Stat(resolvedPath); err != nil {
508+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("%s %q: %v", errGatheringDeviceInfo, pathOnHost, err)))
509+
return err
510+
} else if !src.IsDir() && (src.Mode()&os.ModeDevice) == 0 {
511+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("%s %q: not a device", errGatheringDeviceInfo, pathOnHost)))
512+
return fmt.Errorf("not a device")
513+
}
514+
515+
return nil
516+
}

api/handlers/container/create_test.go

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -928,50 +928,64 @@ var _ = Describe("Container Create API ", func() {
928928
Expect(rr.Body).Should(MatchJSON(jsonResponse))
929929
})
930930

931-
It("should set specified annotation", func() {
931+
It("should reject invalid device paths", func() {
932932
body := []byte(`{
933933
"Image": "test-image",
934934
"HostConfig": {
935-
"Annotations": {
936-
"com.example.key": "value1"
937-
}
935+
"Devices": [
936+
{
937+
"PathOnHost": "/dev/nonexistent",
938+
"PathInContainer": "/dev/nonexistent",
939+
"CgroupPermissions": "rwm"
940+
}
941+
]
938942
}
939943
}`)
940944
req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body))
941945

942-
// Create a copy of default options and add annotation
943-
expectedCreateOpt := createOpt
944-
expectedCreateOpt.Annotations = []string{"com.example.key=value1"}
945-
946-
service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(expectedCreateOpt), equalTo(netOpt)).Return(
947-
cid, nil)
948-
949946
h.create(rr, req)
950-
Expect(rr).Should(HaveHTTPStatus(http.StatusCreated))
951-
Expect(rr.Body).Should(MatchJSON(jsonResponse))
947+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
948+
Expect(rr.Body.String()).Should(ContainSubstring(`{"message":"error gathering device information while adding custom device \"/dev/nonexistent\": lstat /dev/nonexistent: no such file or directory"}`))
952949
})
953950

954-
It("should set specified annotation", func() {
951+
It("should reject empty device paths", func() {
955952
body := []byte(`{
956953
"Image": "test-image",
957954
"HostConfig": {
958-
"Annotations": {
959-
"com.example.key": "value1"
960-
}
955+
"Devices": [
956+
{
957+
"PathOnHost": "",
958+
"PathInContainer": "/dev/null",
959+
"CgroupPermissions": "rwm"
960+
}
961+
]
961962
}
962963
}`)
963964
req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body))
964965

965-
// Create a copy of default options and add annotation
966-
expectedCreateOpt := createOpt
967-
expectedCreateOpt.Annotations = []string{"com.example.key=value1"}
966+
h.create(rr, req)
967+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
968+
Expect(rr.Body.String()).Should(ContainSubstring(`{"message":"error gathering device information while adding custom device"}`))
969+
})
968970

969-
service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(expectedCreateOpt), equalTo(netOpt)).Return(
970-
cid, nil)
971+
It("should reject non-device paths", func() {
972+
body := []byte(`{
973+
"Image": "test-image",
974+
"HostConfig": {
975+
"Devices": [
976+
{
977+
"PathOnHost": "/etc/hosts",
978+
"PathInContainer": "/dev/null",
979+
"CgroupPermissions": "rwm"
980+
}
981+
]
982+
}
983+
}`)
984+
req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body))
971985

972986
h.create(rr, req)
973-
Expect(rr).Should(HaveHTTPStatus(http.StatusCreated))
974-
Expect(rr.Body).Should(MatchJSON(jsonResponse))
987+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
988+
Expect(rr.Body.String()).Should(ContainSubstring(`{"message":"error gathering device information while adding custom device \"/etc/hosts\": not a device"}`))
975989
})
976990

977991
It("should set CgroupnsMode option", func() {
@@ -1206,6 +1220,7 @@ func getDefaultCreateOpt(conf config.Config) types.ContainerCreateOptions {
12061220
BlkioDeviceWriteBps: []string{},
12071221
BlkioDeviceReadIOps: []string{},
12081222
BlkioDeviceWriteIOps: []string{},
1223+
Device: []string{},
12091224
// #endregion
12101225

12111226
// #region for user flags

api/types/container_types.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ type ContainerHostConfig struct {
121121
BlkioDeviceWriteBps []*blkiodev.ThrottleDevice
122122
BlkioDeviceReadIOps []*blkiodev.ThrottleDevice
123123
BlkioDeviceWriteIOps []*blkiodev.ThrottleDevice
124-
// TODO: Devices []DeviceMapping // List of devices to map inside the container
125-
PidsLimit int64 // Setting PIDs limit for a container; Set `0` or `-1` for unlimited, or `null` to not change.
124+
Devices []DeviceMapping // List of devices to map inside the container
125+
PidsLimit int64 // Setting PIDs limit for a container; Set `0` or `-1` for unlimited, or `null` to not change.
126126
// Mounts specs used by the container
127127
// TODO: Mounts []mount.Mount `json:",omitempty"`
128128

@@ -286,3 +286,9 @@ const (
286286
func (c CgroupnsMode) Valid() bool {
287287
return c == CgroupnsModePrivate || c == CgroupnsModeHost
288288
}
289+
290+
type DeviceMapping struct {
291+
PathOnHost string
292+
PathInContainer string
293+
CgroupPermissions string
294+
}

e2e/e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func TestRun(t *testing.T) {
5959
const description = "Finch Daemon Functional test"
6060
ginkgo.Describe(description, func() {
6161
// functional test for container APIs
62-
tests.ContainerCreate(opt)
62+
tests.ContainerCreate(opt, pOpt)
6363
tests.ContainerStart(opt)
6464
tests.ContainerStop(opt)
6565
tests.ContainerRestart(opt)

e2e/tests/container_create.go

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14+
"strconv"
1415
"strings"
1516
"time"
1617

@@ -26,6 +27,7 @@ import (
2627

2728
"github.com/runfinch/finch-daemon/api/types"
2829
"github.com/runfinch/finch-daemon/e2e/client"
30+
"github.com/runfinch/finch-daemon/e2e/util"
2931
)
3032

3133
type containerCreateResponse struct {
@@ -34,7 +36,7 @@ type containerCreateResponse struct {
3436
}
3537

3638
// ContainerCreate tests the `POST containers/create` API.
37-
func ContainerCreate(opt *option.Option) {
39+
func ContainerCreate(opt *option.Option, pOpt util.NewOpt) {
3840
Describe("create container", func() {
3941
var (
4042
uClient *http.Client
@@ -1452,6 +1454,113 @@ func ContainerCreate(opt *option.Option) {
14521454
}
14531455
Expect(foundCgroupNS).Should(BeFalse())
14541456
})
1457+
1458+
It("should create a container with device mappings", func() {
1459+
// Create a temporary file to use as backing store
1460+
tmpFileOpt, _ := pOpt([]string{"touch", "/tmp/loopdev"})
1461+
command.Run(tmpFileOpt)
1462+
defer func() {
1463+
rmOpt, _ := pOpt([]string{"rm", "-f", "/tmp/loopdev"})
1464+
command.Run(rmOpt)
1465+
}()
1466+
1467+
// Write 4KB of data to the file
1468+
ddOpt, _ := pOpt([]string{"dd", "if=/dev/zero", "of=/tmp/loopdev", "bs=4096", "count=1"})
1469+
command.Run(ddOpt)
1470+
1471+
// Set up loop device
1472+
loopDevOpt, _ := pOpt([]string{"losetup", "-f", "--show", "/tmp/loopdev"})
1473+
loopDev := command.StdoutStr(loopDevOpt)
1474+
Expect(loopDev).ShouldNot(BeEmpty())
1475+
defer func() {
1476+
detachOpt, _ := pOpt([]string{"losetup", "-d", loopDev})
1477+
command.Run(detachOpt)
1478+
}()
1479+
1480+
// Write test content to the device
1481+
writeOpt, _ := pOpt([]string{"sh", "-c", "echo -n test-content > " + loopDev})
1482+
command.Run(writeOpt)
1483+
1484+
// Get device info to verify major/minor numbers
1485+
statOpt, _ := pOpt([]string{"stat", "-c", "%t,%T", loopDev})
1486+
devNums := command.StdoutStr(statOpt)
1487+
parts := strings.Split(devNums, ",")
1488+
major, _ := strconv.ParseUint(parts[0], 16, 64)
1489+
minor, _ := strconv.ParseUint(parts[1], 16, 64)
1490+
1491+
options.Cmd = []string{"sleep", "Infinity"}
1492+
options.HostConfig.Devices = []types.DeviceMapping{
1493+
{
1494+
PathOnHost: loopDev,
1495+
PathInContainer: loopDev,
1496+
CgroupPermissions: "rwm",
1497+
},
1498+
}
1499+
1500+
// Create container
1501+
statusCode, ctr := createContainer(uClient, url, testContainerName, options)
1502+
Expect(statusCode).Should(Equal(http.StatusCreated))
1503+
Expect(ctr.ID).ShouldNot(BeEmpty())
1504+
1505+
// Start container
1506+
command.Run(opt, "start", testContainerName)
1507+
1508+
// Inspect using native format
1509+
nativeResp := command.Stdout(opt, "inspect", "--mode=native", testContainerName)
1510+
var nativeInspect []map[string]interface{}
1511+
err := json.Unmarshal(nativeResp, &nativeInspect)
1512+
Expect(err).Should(BeNil())
1513+
Expect(nativeInspect).Should(HaveLen(1))
1514+
1515+
// Navigate to the linux section
1516+
spec, ok := nativeInspect[0]["Spec"].(map[string]interface{})
1517+
Expect(ok).Should(BeTrue())
1518+
linux, ok := spec["linux"].(map[string]interface{})
1519+
Expect(ok).Should(BeTrue())
1520+
1521+
// Verify device in linux.devices
1522+
devices, ok := linux["devices"].([]interface{})
1523+
Expect(ok).Should(BeTrue())
1524+
1525+
foundDevice := false
1526+
for _, device := range devices {
1527+
d := device.(map[string]interface{})
1528+
if d["path"] == loopDev {
1529+
foundDevice = true
1530+
Expect(d["type"]).Should(Equal("b")) // block device
1531+
Expect(d["major"].(float64)).Should(Equal(float64(major)))
1532+
Expect(d["minor"].(float64)).Should(Equal(float64(minor)))
1533+
break
1534+
}
1535+
}
1536+
Expect(foundDevice).Should(BeTrue())
1537+
1538+
// Verify device permissions in linux.resources.devices
1539+
resources, ok := linux["resources"].(map[string]interface{})
1540+
Expect(ok).Should(BeTrue())
1541+
resourceDevices, ok := resources["devices"].([]interface{})
1542+
Expect(ok).Should(BeTrue())
1543+
1544+
// First rule should be deny all
1545+
denyAll := resourceDevices[0].(map[string]interface{})
1546+
Expect(denyAll["allow"]).Should(BeFalse())
1547+
Expect(denyAll["access"]).Should(Equal("rwm"))
1548+
1549+
// Should find an allow rule for our device
1550+
foundAllowRule := false
1551+
for _, rule := range resourceDevices {
1552+
r := rule.(map[string]interface{})
1553+
if r["allow"] == true &&
1554+
r["type"] == "b" &&
1555+
r["major"].(float64) == float64(major) &&
1556+
r["minor"].(float64) == float64(minor) {
1557+
foundAllowRule = true
1558+
Expect(r["access"]).Should(Equal("rwm"))
1559+
break
1560+
}
1561+
}
1562+
Expect(foundAllowRule).Should(BeTrue())
1563+
})
14551564
})
14561565
}
14571566

0 commit comments

Comments
 (0)