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>
| 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 |
-} |