Skip to content

Commit f782458

Browse files
committed
feat: Add devices option
Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent 23fd05e commit f782458

File tree

5 files changed

+193
-4
lines changed

5 files changed

+193
-4
lines changed

api/handlers/container/create.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,17 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) {
187187
if req.HostConfig.SecurityOpt != nil {
188188
securityOpt = req.HostConfig.SecurityOpt
189189
}
190+
devices := []string{}
191+
if req.HostConfig.Devices != nil {
192+
// Validate device configurations
193+
for _, device := range req.HostConfig.Devices {
194+
if device.PathOnHost == "" {
195+
response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("invalid device configuration: PathOnHost cannot be empty"))
196+
return
197+
}
198+
}
199+
devices = translateDevices(req.HostConfig.Devices)
200+
}
190201

191202
globalOpt := ncTypes.GlobalCommandOptions(*h.Config)
192203
createOpt := ncTypes.ContainerCreateOptions{
@@ -237,6 +248,8 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) {
237248
BlkioDeviceWriteIOps: throttleDevicesToStrings(req.HostConfig.BlkioDeviceWriteIOps),
238249
IPC: req.HostConfig.IpcMode, // IPC namespace to use
239250
ShmSize: shmSize,
251+
Device: devices, // Device specifies add a host device to the container
252+
240253
// #endregion
241254

242255
// #region for user flags
@@ -430,3 +443,27 @@ func translateAnnotations(annotations map[string]string) []string {
430443
}
431444
return result
432445
}
446+
447+
// translateDevices converts a slice of DeviceMapping to a slice of strings in the format "PATH_ON_HOST[:PATH_IN_CONTAINER][:CGROUP_PERMISSIONS]".
448+
func translateDevices(devices []types.DeviceMapping) []string {
449+
if devices == nil {
450+
return nil
451+
}
452+
453+
var result []string
454+
for _, deviceMap := range devices {
455+
deviceString := deviceMap.PathOnHost
456+
457+
if deviceMap.PathInContainer != "" {
458+
deviceString += ":" + deviceMap.PathInContainer
459+
if deviceMap.CgroupPermissions != "" {
460+
deviceString += ":" + deviceMap.CgroupPermissions
461+
}
462+
} else if deviceMap.CgroupPermissions != "" {
463+
deviceString += ":" + deviceMap.CgroupPermissions
464+
}
465+
466+
result = append(result, deviceString)
467+
}
468+
return result
469+
}

api/handlers/container/create_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,42 @@ var _ = Describe("Container Create API ", func() {
974974
Expect(rr.Body).Should(MatchJSON(jsonResponse))
975975
})
976976

977+
It("should set Devices option", func() {
978+
body := []byte(`{
979+
"Image": "test-image",
980+
"HostConfig": {
981+
"Devices": [{"PathOnHost": "/dev/null", "PathInContainer": "/dev/null", "CgroupPermissions": "rwm"},{"PathOnHost": "/var/lib", "CgroupPermissions": "ro"}]
982+
}
983+
}`)
984+
req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body))
985+
986+
// expected create options
987+
createOpt.Device = []string{"/dev/null:/dev/null:rwm", "/var/lib:ro"}
988+
989+
service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return(
990+
cid, nil)
991+
992+
// handler should return success message with 201 status code.
993+
h.create(rr, req)
994+
Expect(rr).Should(HaveHTTPStatus(http.StatusCreated))
995+
Expect(rr.Body).Should(MatchJSON(jsonResponse))
996+
})
997+
998+
It("should return 400 for invalid Devices configuration", func() {
999+
body := []byte(`{
1000+
"Image": "test-image",
1001+
"HostConfig": {
1002+
"Devices": [{"PathOnHost": "", "PathInContainer": "/dev/null", "CgroupPermissions": "rwm"}]
1003+
}
1004+
}`)
1005+
req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body))
1006+
1007+
// handler should return bad request message with 400 status code
1008+
h.create(rr, req)
1009+
Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest))
1010+
Expect(rr.Body.String()).Should(ContainSubstring("invalid device configuration"))
1011+
})
1012+
9771013
Context("translate port mappings", func() {
9781014
It("should return empty if port mappings is nil", func() {
9791015
Expect(translatePortMappings(nil)).Should(BeEmpty())
@@ -1170,6 +1206,7 @@ func getDefaultCreateOpt(conf config.Config) types.ContainerCreateOptions {
11701206
BlkioDeviceWriteBps: []string{},
11711207
BlkioDeviceReadIOps: []string{},
11721208
BlkioDeviceWriteIOps: []string{},
1209+
Device: []string{},
11731210
// #endregion
11741211

11751212
// #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

@@ -271,3 +271,9 @@ type StatsJSON struct {
271271
}
272272

273273
type Ulimit = units.Ulimit
274+
275+
type DeviceMapping struct {
276+
PathOnHost string
277+
PathInContainer string
278+
CgroupPermissions string
279+
}

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
@@ -1412,6 +1414,113 @@ func ContainerCreate(opt *option.Option) {
14121414
Expect(ok).Should(BeTrue())
14131415
Expect(annotations["com.example.key"]).Should(Equal("test-value"))
14141416
})
1417+
1418+
It("should create a container with device mappings", func() {
1419+
// Create a temporary file to use as backing store
1420+
tmpFileOpt, _ := pOpt([]string{"touch", "/tmp/loopdev"})
1421+
command.Run(tmpFileOpt)
1422+
defer func() {
1423+
rmOpt, _ := pOpt([]string{"rm", "-f", "/tmp/loopdev"})
1424+
command.Run(rmOpt)
1425+
}()
1426+
1427+
// Write 4KB of data to the file
1428+
ddOpt, _ := pOpt([]string{"dd", "if=/dev/zero", "of=/tmp/loopdev", "bs=4096", "count=1"})
1429+
command.Run(ddOpt)
1430+
1431+
// Set up loop device
1432+
loopDevOpt, _ := pOpt([]string{"losetup", "-f", "--show", "/tmp/loopdev"})
1433+
loopDev := command.StdoutStr(loopDevOpt)
1434+
Expect(loopDev).ShouldNot(BeEmpty())
1435+
defer func() {
1436+
detachOpt, _ := pOpt([]string{"losetup", "-d", loopDev})
1437+
command.Run(detachOpt)
1438+
}()
1439+
1440+
// Write test content to the device
1441+
writeOpt, _ := pOpt([]string{"sh", "-c", "echo -n test-content > " + loopDev})
1442+
command.Run(writeOpt)
1443+
1444+
// Get device info to verify major/minor numbers
1445+
statOpt, _ := pOpt([]string{"stat", "-c", "%t,%T", loopDev})
1446+
devNums := command.StdoutStr(statOpt)
1447+
parts := strings.Split(devNums, ",")
1448+
major, _ := strconv.ParseUint(parts[0], 16, 64)
1449+
minor, _ := strconv.ParseUint(parts[1], 16, 64)
1450+
1451+
options.Cmd = []string{"sleep", "Infinity"}
1452+
options.HostConfig.Devices = []types.DeviceMapping{
1453+
{
1454+
PathOnHost: loopDev,
1455+
PathInContainer: loopDev,
1456+
CgroupPermissions: "rwm",
1457+
},
1458+
}
1459+
1460+
// Create container
1461+
statusCode, ctr := createContainer(uClient, url, testContainerName, options)
1462+
Expect(statusCode).Should(Equal(http.StatusCreated))
1463+
Expect(ctr.ID).ShouldNot(BeEmpty())
1464+
1465+
// Start container
1466+
command.Run(opt, "start", testContainerName)
1467+
1468+
// Inspect using native format
1469+
nativeResp := command.Stdout(opt, "inspect", "--mode=native", testContainerName)
1470+
var nativeInspect []map[string]interface{}
1471+
err := json.Unmarshal(nativeResp, &nativeInspect)
1472+
Expect(err).Should(BeNil())
1473+
Expect(nativeInspect).Should(HaveLen(1))
1474+
1475+
// Navigate to the linux section
1476+
spec, ok := nativeInspect[0]["Spec"].(map[string]interface{})
1477+
Expect(ok).Should(BeTrue())
1478+
linux, ok := spec["linux"].(map[string]interface{})
1479+
Expect(ok).Should(BeTrue())
1480+
1481+
// Verify device in linux.devices
1482+
devices, ok := linux["devices"].([]interface{})
1483+
Expect(ok).Should(BeTrue())
1484+
1485+
foundDevice := false
1486+
for _, device := range devices {
1487+
d := device.(map[string]interface{})
1488+
if d["path"] == loopDev {
1489+
foundDevice = true
1490+
Expect(d["type"]).Should(Equal("b")) // block device
1491+
Expect(d["major"].(float64)).Should(Equal(float64(major)))
1492+
Expect(d["minor"].(float64)).Should(Equal(float64(minor)))
1493+
break
1494+
}
1495+
}
1496+
Expect(foundDevice).Should(BeTrue())
1497+
1498+
// Verify device permissions in linux.resources.devices
1499+
resources, ok := linux["resources"].(map[string]interface{})
1500+
Expect(ok).Should(BeTrue())
1501+
resourceDevices, ok := resources["devices"].([]interface{})
1502+
Expect(ok).Should(BeTrue())
1503+
1504+
// First rule should be deny all
1505+
denyAll := resourceDevices[0].(map[string]interface{})
1506+
Expect(denyAll["allow"]).Should(BeFalse())
1507+
Expect(denyAll["access"]).Should(Equal("rwm"))
1508+
1509+
// Should find an allow rule for our device
1510+
foundAllowRule := false
1511+
for _, rule := range resourceDevices {
1512+
r := rule.(map[string]interface{})
1513+
if r["allow"] == true &&
1514+
r["type"] == "b" &&
1515+
r["major"].(float64) == float64(major) &&
1516+
r["minor"].(float64) == float64(minor) {
1517+
foundAllowRule = true
1518+
Expect(r["access"]).Should(Equal("rwm"))
1519+
break
1520+
}
1521+
}
1522+
Expect(foundAllowRule).Should(BeTrue())
1523+
})
14151524
})
14161525
}
14171526

0 commit comments

Comments
 (0)