Browse code

api/types/system: move `SecurityOpt` type and `DecodeSecurityOptions` to client

This change moves the `system.SecurityOpt` type and `system.DecodeSecurityOptions` function to the client and adds a set of unit tests to capture current implementation. This change also create a set of daemon backend copies for usage.

Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>

Austin Vazquez authored on 2025/08/27 21:58:54
Showing 6 changed files
1 1
deleted file mode 100644
... ...
@@ -1,48 +0,0 @@
1
-package system
2
-
3
-import (
4
-	"errors"
5
-	"fmt"
6
-	"strings"
7
-)
8
-
9
-// SecurityOpt contains the name and options of a security option
10
-type SecurityOpt struct {
11
-	Name    string
12
-	Options []KeyValue
13
-}
14
-
15
-// DecodeSecurityOptions decodes a security options string slice to a
16
-// type-safe [SecurityOpt].
17
-func DecodeSecurityOptions(opts []string) ([]SecurityOpt, error) {
18
-	so := []SecurityOpt{}
19
-	for _, opt := range opts {
20
-		// support output from a < 1.13 docker daemon
21
-		if !strings.Contains(opt, "=") {
22
-			so = append(so, SecurityOpt{Name: opt})
23
-			continue
24
-		}
25
-		secopt := SecurityOpt{}
26
-		for _, s := range strings.Split(opt, ",") {
27
-			k, v, ok := strings.Cut(s, "=")
28
-			if !ok {
29
-				return nil, fmt.Errorf("invalid security option %q", s)
30
-			}
31
-			if k == "" || v == "" {
32
-				return nil, errors.New("invalid empty security option")
33
-			}
34
-			if k == "name" {
35
-				secopt.Name = v
36
-				continue
37
-			}
38
-			secopt.Options = append(secopt.Options, KeyValue{Key: k, Value: v})
39
-		}
40
-		so = append(so, secopt)
41
-	}
42
-	return so, nil
43
-}
44
-
45
-// KeyValue holds a key/value pair.
46
-type KeyValue struct {
47
-	Key, Value string
48
-}
49 1
new file mode 100644
... ...
@@ -0,0 +1,48 @@
0
+package security
1
+
2
+import (
3
+	"errors"
4
+	"fmt"
5
+	"strings"
6
+)
7
+
8
+// Option contains the name and options of a security option
9
+type Option struct {
10
+	Name    string
11
+	Options []KeyValue
12
+}
13
+
14
+// KeyValue holds a key/value pair.
15
+type KeyValue struct {
16
+	Key, Value string
17
+}
18
+
19
+// DecodeOptions decodes a security options string slice to a
20
+// type-safe [Option].
21
+func DecodeOptions(opts []string) ([]Option, error) {
22
+	so := []Option{}
23
+	for _, opt := range opts {
24
+		// support output from a < 1.13 docker daemon
25
+		if !strings.Contains(opt, "=") {
26
+			so = append(so, Option{Name: opt})
27
+			continue
28
+		}
29
+		secopt := Option{}
30
+		for _, s := range strings.Split(opt, ",") {
31
+			k, v, ok := strings.Cut(s, "=")
32
+			if !ok {
33
+				return nil, fmt.Errorf("invalid security option %q", s)
34
+			}
35
+			if k == "" || v == "" {
36
+				return nil, errors.New("invalid empty security option")
37
+			}
38
+			if k == "name" {
39
+				secopt.Name = v
40
+				continue
41
+			}
42
+			secopt.Options = append(secopt.Options, KeyValue{Key: k, Value: v})
43
+		}
44
+		so = append(so, secopt)
45
+	}
46
+	return so, nil
47
+}
0 48
new file mode 100644
... ...
@@ -0,0 +1,240 @@
0
+package security
1
+
2
+import (
3
+	"fmt"
4
+	"testing"
5
+
6
+	"gotest.tools/v3/assert"
7
+	"gotest.tools/v3/assert/cmp"
8
+)
9
+
10
+func TestDecode(t *testing.T) {
11
+	tests := []struct {
12
+		name    string
13
+		opts    []string
14
+		want    []Option
15
+		wantErr string
16
+	}{
17
+		{
18
+			name: "empty options",
19
+			opts: []string{},
20
+			want: []Option{},
21
+		},
22
+		{
23
+			name: "nil options",
24
+			opts: nil,
25
+			want: []Option{},
26
+		},
27
+		{
28
+			name: "legacy format without equals",
29
+			opts: []string{"apparmor:unconfined"},
30
+			want: []Option{
31
+				{Name: "apparmor:unconfined"},
32
+			},
33
+		},
34
+		{
35
+			name: "single option with name only",
36
+			opts: []string{"name=apparmor"},
37
+			want: []Option{
38
+				{Name: "apparmor"},
39
+			},
40
+		},
41
+		{
42
+			name: "single option with name and additional options",
43
+			opts: []string{"name=selinux,type=container_t,level=s0:c1.c2"},
44
+			want: []Option{
45
+				{
46
+					Name: "selinux",
47
+					Options: []KeyValue{
48
+						{Key: "type", Value: "container_t"},
49
+						{Key: "level", Value: "s0:c1.c2"},
50
+					},
51
+				},
52
+			},
53
+		},
54
+		{
55
+			name: "multiple options",
56
+			opts: []string{
57
+				"name=apparmor,profile=docker-default",
58
+				"name=seccomp,profile=unconfined",
59
+			},
60
+			want: []Option{
61
+				{
62
+					Name: "apparmor",
63
+					Options: []KeyValue{
64
+						{Key: "profile", Value: "docker-default"},
65
+					},
66
+				},
67
+				{
68
+					Name: "seccomp",
69
+					Options: []KeyValue{
70
+						{Key: "profile", Value: "unconfined"},
71
+					},
72
+				},
73
+			},
74
+		},
75
+		{
76
+			name: "mixed legacy and new format",
77
+			opts: []string{
78
+				"label:disable",
79
+				"name=apparmor,profile=custom",
80
+			},
81
+			want: []Option{
82
+				{Name: "label:disable"},
83
+				{
84
+					Name: "apparmor",
85
+					Options: []KeyValue{
86
+						{Key: "profile", Value: "custom"},
87
+					},
88
+				},
89
+			},
90
+		},
91
+		{
92
+			name: "option without name key",
93
+			opts: []string{"profile=custom,type=container_t"},
94
+			want: []Option{
95
+				{
96
+					Options: []KeyValue{
97
+						{Key: "profile", Value: "custom"},
98
+						{Key: "type", Value: "container_t"},
99
+					},
100
+				},
101
+			},
102
+		},
103
+		{
104
+			name: "option with equals in value",
105
+			opts: []string{"name=selinux,level=s0:c1=c2"},
106
+			want: []Option{
107
+				{
108
+					Name: "selinux",
109
+					Options: []KeyValue{
110
+						{Key: "level", Value: "s0:c1=c2"},
111
+					},
112
+				},
113
+			},
114
+		},
115
+		{
116
+			name:    "invalid option without equals in comma-separated list",
117
+			opts:    []string{"name=apparmor,invalid"},
118
+			wantErr: `invalid security option "invalid"`,
119
+		},
120
+		{
121
+			name:    "empty key",
122
+			opts:    []string{"=value"},
123
+			wantErr: "invalid empty security option",
124
+		},
125
+		{
126
+			name:    "empty value",
127
+			opts:    []string{"key="},
128
+			wantErr: "invalid empty security option",
129
+		},
130
+		{
131
+			name:    "empty key and value",
132
+			opts:    []string{"="},
133
+			wantErr: "invalid empty security option",
134
+		},
135
+		{
136
+			name:    "empty key in middle",
137
+			opts:    []string{"name=apparmor,=value"},
138
+			wantErr: "invalid empty security option",
139
+		},
140
+		{
141
+			name:    "empty value in middle",
142
+			opts:    []string{"name=apparmor,key="},
143
+			wantErr: "invalid empty security option",
144
+		},
145
+		{
146
+			name: "complex real-world example",
147
+			opts: []string{
148
+				"name=selinux,user=system_u,role=system_r,type=container_t,level=s0:c1.c2",
149
+				"name=apparmor,profile=/usr/bin/docker",
150
+				"name=seccomp,profile=builtin",
151
+			},
152
+			want: []Option{
153
+				{
154
+					Name: "selinux",
155
+					Options: []KeyValue{
156
+						{Key: "user", Value: "system_u"},
157
+						{Key: "role", Value: "system_r"},
158
+						{Key: "type", Value: "container_t"},
159
+						{Key: "level", Value: "s0:c1.c2"},
160
+					},
161
+				},
162
+				{
163
+					Name: "apparmor",
164
+					Options: []KeyValue{
165
+						{Key: "profile", Value: "/usr/bin/docker"},
166
+					},
167
+				},
168
+				{
169
+					Name: "seccomp",
170
+					Options: []KeyValue{
171
+						{Key: "profile", Value: "builtin"},
172
+					},
173
+				},
174
+			},
175
+		},
176
+	}
177
+
178
+	for _, tc := range tests {
179
+		t.Run(tc.name, func(t *testing.T) {
180
+			got, err := DecodeOptions(tc.opts)
181
+
182
+			if tc.wantErr == "" {
183
+				assert.NilError(t, err)
184
+				assert.Check(t, cmp.DeepEqual(got, tc.want))
185
+			} else {
186
+				assert.Check(t, err != nil, "expected error but got none")
187
+				assert.ErrorContains(t, err, tc.wantErr)
188
+			}
189
+		})
190
+	}
191
+}
192
+
193
+func BenchmarkDecode(b *testing.B) {
194
+	opts := []string{
195
+		"name=selinux,user=system_u,role=system_r,type=container_t,level=s0:c1.c2",
196
+		"name=apparmor,profile=/usr/bin/docker",
197
+		"name=seccomp,profile=builtin",
198
+		"legacy:format",
199
+	}
200
+
201
+	b.ResetTimer()
202
+	for i := 0; i < b.N; i++ {
203
+		_, err := DecodeOptions(opts)
204
+		if err != nil {
205
+			b.Fatal(err)
206
+		}
207
+	}
208
+}
209
+
210
+func BenchmarkDecodeLegacy(b *testing.B) {
211
+	opts := []string{
212
+		"apparmor:unconfined",
213
+		"label:disable",
214
+		"seccomp:unconfined",
215
+	}
216
+
217
+	b.ResetTimer()
218
+	for i := 0; i < b.N; i++ {
219
+		_, err := DecodeOptions(opts)
220
+		if err != nil {
221
+			b.Fatal(err)
222
+		}
223
+	}
224
+}
225
+
226
+func BenchmarkDecodeComplex(b *testing.B) {
227
+	opts := make([]string, 100)
228
+	for i := range opts {
229
+		opts[i] = fmt.Sprintf("name=test%d,key1=value1,key2=value2,key3=value3", i)
230
+	}
231
+
232
+	b.ResetTimer()
233
+	for i := 0; i < b.N; i++ {
234
+		_, err := DecodeOptions(opts)
235
+		if err != nil {
236
+			b.Fatal(err)
237
+		}
238
+	}
239
+}
... ...
@@ -5,6 +5,7 @@ import (
5 5
 	"encoding/json"
6 6
 	"fmt"
7 7
 	"net/http"
8
+	"strings"
8 9
 	"time"
9 10
 
10 11
 	"github.com/containerd/log"
... ...
@@ -20,6 +21,7 @@ import (
20 20
 	"github.com/moby/moby/v2/daemon/server/backend"
21 21
 	"github.com/moby/moby/v2/daemon/server/httputils"
22 22
 	"github.com/moby/moby/v2/daemon/server/router/build"
23
+	"github.com/moby/moby/v2/daemon/server/systembackend"
23 24
 	"github.com/moby/moby/v2/pkg/ioutils"
24 25
 	"github.com/pkg/errors"
25 26
 	"golang.org/x/sync/errgroup"
... ...
@@ -74,7 +76,7 @@ func (s *systemRouter) getInfo(ctx context.Context, w http.ResponseWriter, r *ht
74 74
 
75 75
 		if versions.LessThan(version, "1.25") {
76 76
 			// TODO: handle this conversion in engine-api
77
-			kvSecOpts, err := system.DecodeSecurityOptions(info.SecurityOptions)
77
+			kvSecOpts, err := decodeSecurityOptions(info.SecurityOptions)
78 78
 			if err != nil {
79 79
 				info.Warnings = append(info.Warnings, err.Error())
80 80
 			}
... ...
@@ -142,6 +144,36 @@ func (s *systemRouter) getInfo(ctx context.Context, w http.ResponseWriter, r *ht
142 142
 	return httputils.WriteJSON(w, http.StatusOK, info)
143 143
 }
144 144
 
145
+// decodeSecurityOptions decodes a security options string slice to a
146
+// type-safe [systembackend.SecurityOption].
147
+func decodeSecurityOptions(opts []string) ([]systembackend.SecurityOption, error) {
148
+	so := []systembackend.SecurityOption{}
149
+	for _, opt := range opts {
150
+		// support output from a < 1.13 docker daemon
151
+		if !strings.Contains(opt, "=") {
152
+			so = append(so, systembackend.SecurityOption{Name: opt})
153
+			continue
154
+		}
155
+		secopt := systembackend.SecurityOption{}
156
+		for _, s := range strings.Split(opt, ",") {
157
+			k, v, ok := strings.Cut(s, "=")
158
+			if !ok {
159
+				return nil, fmt.Errorf("invalid security option %q", s)
160
+			}
161
+			if k == "" || v == "" {
162
+				return nil, errors.New("invalid empty security option")
163
+			}
164
+			if k == "name" {
165
+				secopt.Name = v
166
+				continue
167
+			}
168
+			secopt.Options = append(secopt.Options, systembackend.KeyValue{Key: k, Value: v})
169
+		}
170
+		so = append(so, secopt)
171
+	}
172
+	return so, nil
173
+}
174
+
145 175
 func (s *systemRouter) getVersion(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
146 176
 	info, err := s.backend.SystemVersion(ctx)
147 177
 	if err != nil {
148 178
new file mode 100644
... ...
@@ -0,0 +1,12 @@
0
+package systembackend
1
+
2
+// SecurityOption contains the name and options of a security option
3
+type SecurityOption struct {
4
+	Name    string
5
+	Options []KeyValue
6
+}
7
+
8
+// KeyValue holds a key/value pair.
9
+type KeyValue struct {
10
+	Key, Value string
11
+}
0 12
deleted file mode 100644
... ...
@@ -1,48 +0,0 @@
1
-package system
2
-
3
-import (
4
-	"errors"
5
-	"fmt"
6
-	"strings"
7
-)
8
-
9
-// SecurityOpt contains the name and options of a security option
10
-type SecurityOpt struct {
11
-	Name    string
12
-	Options []KeyValue
13
-}
14
-
15
-// DecodeSecurityOptions decodes a security options string slice to a
16
-// type-safe [SecurityOpt].
17
-func DecodeSecurityOptions(opts []string) ([]SecurityOpt, error) {
18
-	so := []SecurityOpt{}
19
-	for _, opt := range opts {
20
-		// support output from a < 1.13 docker daemon
21
-		if !strings.Contains(opt, "=") {
22
-			so = append(so, SecurityOpt{Name: opt})
23
-			continue
24
-		}
25
-		secopt := SecurityOpt{}
26
-		for _, s := range strings.Split(opt, ",") {
27
-			k, v, ok := strings.Cut(s, "=")
28
-			if !ok {
29
-				return nil, fmt.Errorf("invalid security option %q", s)
30
-			}
31
-			if k == "" || v == "" {
32
-				return nil, errors.New("invalid empty security option")
33
-			}
34
-			if k == "name" {
35
-				secopt.Name = v
36
-				continue
37
-			}
38
-			secopt.Options = append(secopt.Options, KeyValue{Key: k, Value: v})
39
-		}
40
-		so = append(so, secopt)
41
-	}
42
-	return so, nil
43
-}
44
-
45
-// KeyValue holds a key/value pair.
46
-type KeyValue struct {
47
-	Key, Value string
48
-}