1package domain
2
3import (
4 "errors"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10)
11
12func validDevicePolicy() AppDevicePolicy {
13 return AppDevicePolicy{
14 Name: "test_gpu",
15 CDI: []string{"example.com/gpu=GPU-test-uuid"},
16 AllowedApps: []string{"demo"},
17 AllowedServices: []string{"worker"},
18 }
19}
20
21func TestDevicePolicyValid(t *testing.T) {
22 p := validDevicePolicy()
23 require.NoError(t, p.Validate())
24}
25
26func TestDevicePolicyNameRejected(t *testing.T) {
27 for _, name := range []string{"", "Bad", "a--b", "has space"} {
28 p := validDevicePolicy()
29 p.Name = name
30 err := p.Validate()
31 require.Error(t, err, "name %q", name)
32 assert.True(t, errors.Is(err, ErrDevicePolicy))
33 }
34}
35
36func TestDevicePolicyCDIRejected(t *testing.T) {
37 cases := map[string][]string{
38 "empty": {},
39 "empty entry": {""},
40 "raw path": {"/dev/card0"},
41 "unqualified": {"gpu0"},
42 "missing name": {"example.com/gpu="},
43 "missing kind": {"=gpu0"},
44 "aggregate": {"example.com/gpu=all"},
45 "duplicate": {"example.com/gpu=0", "example.com/gpu=0"},
46 "qualified host path": {"example.com/gpu=/dev/card0"},
47 }
48 for name, cdi := range cases {
49 p := validDevicePolicy()
50 p.CDI = cdi
51 err := p.Validate()
52 require.Error(t, err, "case %s", name)
53 assert.True(t, errors.Is(err, ErrDevicePolicy), "case %s", name)
54 }
55}
56
57func TestDevicePolicyAllowlistRejected(t *testing.T) {
58 p := validDevicePolicy()
59 p.AllowedApps = nil
60 err := p.Validate()
61 require.Error(t, err)
62 assert.True(t, errors.Is(err, ErrDevicePolicy))
63
64 p = validDevicePolicy()
65 p.AllowedApps = []string{"demo", "demo"}
66 require.Error(t, p.Validate())
67
68 p = validDevicePolicy()
69 p.AllowedServices = []string{"worker", "worker"}
70 require.Error(t, p.Validate())
71
72 // An invalid allowlist entry is both a policy violation and an invalid
73 // app spec: callers must be able to detect either sentinel.
74 p = validDevicePolicy()
75 p.AllowedServices = []string{"Bad Name"}
76 err = p.Validate()
77 require.Error(t, err)
78 assert.True(t, errors.Is(err, ErrDevicePolicy))
79 assert.True(t, errors.Is(err, ErrInvalidAppSpec), "the invalid-spec chain must survive allowlist validation")
80}
81
82func TestResolveAppDeviceDenyByDefault(t *testing.T) {
83 p := validDevicePolicy()
84 _, err := p.ResolveAppDevice("other", "worker")
85 require.Error(t, err)
86 assert.True(t, errors.Is(err, ErrDevicePolicy))
87
88 _, err = p.ResolveAppDevice("demo", "helper")
89 require.Error(t, err)
90 assert.True(t, errors.Is(err, ErrDevicePolicy))
91}
92
93func TestResolveAppDeviceExactGrant(t *testing.T) {
94 p := validDevicePolicy()
95 ids, err := p.ResolveAppDevice("demo", "worker")
96 require.NoError(t, err)
97 assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, ids)
98 // The returned slice must be a copy: mutating it must not affect
99 // later resolutions.
100 ids[0] = "mutated"
101 again, err := p.ResolveAppDevice("demo", "worker")
102 require.NoError(t, err)
103 assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, again)
104}
105
106func TestResolveAppDevicesUnknownDenied(t *testing.T) {
107 policies := map[string]AppDevicePolicy{"test_gpu": validDevicePolicy()}
108 _, err := ResolveAppDevices("demo", "worker", []string{"unknown"}, policies)
109 require.Error(t, err)
110 assert.True(t, errors.Is(err, ErrDevicePolicy))
111}
112
113func TestResolveAppDevicesDeterministicUnion(t *testing.T) {
114 policies := map[string]AppDevicePolicy{
115 "b": {Name: "b", CDI: []string{"example.com/gpu=GPU-b"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
116 "a": {Name: "a", CDI: []string{"example.com/gpu=GPU-a"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
117 }
118 ids, err := ResolveAppDevices("demo", "worker", []string{"b", "a"}, policies)
119 require.NoError(t, err)
120 assert.Equal(t, []string{"example.com/gpu=GPU-a", "example.com/gpu=GPU-b"}, ids)
121}
122
123func TestResolveAppDevicesDuplicateIDRejected(t *testing.T) {
124 shared := "example.com/gpu=GPU-shared"
125 policies := map[string]AppDevicePolicy{
126 "a": {Name: "a", CDI: []string{shared}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
127 "b": {Name: "b", CDI: []string{shared}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
128 }
129 _, err := ResolveAppDevices("demo", "worker", []string{"a", "b"}, policies)
130 require.Error(t, err)
131 assert.True(t, errors.Is(err, ErrDevicePolicy))
132 assert.NotContains(t, err.Error(), shared, "duplicate-grant errors must not echo resolved CDI IDs")
133}
134
135func TestResolveAppDevicesEmptyIsNil(t *testing.T) {
136 ids, err := ResolveAppDevices("demo", "worker", nil, nil)
137 require.NoError(t, err)
138 assert.Nil(t, ids)
139}
140
141func TestValidateDeviceName(t *testing.T) {
142 require.NoError(t, ValidateDeviceName("test_gpu"))
143 for _, name := range []string{"", "Bad", "a--b", "/dev/card0"} {
144 require.Error(t, ValidateDeviceName(name), "name %q", name)
145 }
146}
147
148func TestAppServiceDevicesValidation(t *testing.T) {
149 base := AppSpec{Name: "demo", Services: []AppService{{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}}}}
150 valid := base
151 valid.Services[0].Devices = []string{"test_gpu"}
152 require.NoError(t, valid.Validate())
153
154 dup := base
155 dup.Services[0].Devices = []string{"test_gpu", "test_gpu"}
156 err := dup.Validate()
157 require.Error(t, err)
158 assert.True(t, errors.Is(err, ErrInvalidAppSpec))
159 assert.Contains(t, err.Error(), "duplicate device")
160
161 bad := base
162 bad.Services[0].Devices = []string{"Bad!"}
163 err = bad.Validate()
164 require.Error(t, err)
165 assert.True(t, errors.Is(err, ErrInvalidAppSpec))
166}
167
168func TestDiffAppSpecDevices(t *testing.T) {
169 svc := func(devices ...string) AppService {
170 return AppService{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, Devices: devices}
171 }
172 mk := func(services ...AppService) AppSpec { return AppSpec{Name: "demo", Services: services} }
173
174 // Add/remove report service/worker/devices.
175 oldSpec, nextSpec := mk(svc()), mk(svc("test_gpu"))
176 diff := DiffAppSpec(oldSpec, nextSpec)
177 assert.Contains(t, diff.Changed, "service/worker/devices")
178
179 // Reorder-only is a no-op.
180 oldSpec, nextSpec = mk(svc("a", "b")), mk(svc("b", "a"))
181 diff = DiffAppSpec(oldSpec, nextSpec)
182 assert.NotContains(t, diff.Changed, "service/worker/devices")
183
184 // Nil vs empty stays a no-op.
185 oldSpec, nextSpec = mk(svc()), mk(svc([]string{}...))
186 diff = DiffAppSpec(oldSpec, nextSpec)
187 assert.NotContains(t, diff.Changed, "service/worker/devices")
188}