github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/cli/compose_test.go (about) 1 package cli 2 3 import ( 4 "archive/tar" 5 "bytes" 6 "compress/gzip" 7 "context" 8 "io" 9 "net/http" 10 "net/http/httptest" 11 "os" 12 "reflect" 13 "strings" 14 "testing" 15 16 "github.com/compose-spec/compose-go/v2/types" 17 "github.com/defang-io/defang/src/pkg/cli/client" 18 "github.com/defang-io/defang/src/pkg/term" 19 defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1" 20 "github.com/sirupsen/logrus" 21 ) 22 23 func TestNormalizeServiceName(t *testing.T) { 24 testCases := []struct { 25 name string 26 expected string 27 }{ 28 {name: "normal", expected: "normal"}, 29 {name: "camelCase", expected: "camelcase"}, 30 {name: "PascalCase", expected: "pascalcase"}, 31 {name: "hyphen-ok", expected: "hyphen-ok"}, 32 {name: "snake_case", expected: "snake-case"}, 33 {name: "$ymb0ls", expected: "-ymb0ls"}, 34 {name: "consecutive--hyphens", expected: "consecutive-hyphens"}, 35 {name: "hyphen-$ymbol", expected: "hyphen-ymbol"}, 36 {name: "_blah", expected: "-blah"}, 37 } 38 for _, tC := range testCases { 39 t.Run(tC.name, func(t *testing.T) { 40 actual := NormalizeServiceName(tC.name) 41 if actual != tC.expected { 42 t.Errorf("NormalizeServiceName() failed: expected %v, got %v", tC.expected, actual) 43 } 44 }) 45 } 46 } 47 48 func TestLoadCompose(t *testing.T) { 49 DoVerbose = true 50 term.DoDebug = true 51 52 t.Run("no project name defaults to tenantID", func(t *testing.T) { 53 loader := ComposeLoader{"../../tests/noprojname/compose.yaml"} 54 p, err := loader.LoadWithProjectName("tenant-id") 55 if err != nil { 56 t.Fatalf("LoadCompose() failed: %v", err) 57 } 58 if p.Name != "tenant-id" { 59 t.Errorf("LoadCompose() failed: expected project name tenant-id, got %q", p.Name) 60 } 61 }) 62 63 t.Run("use project name", func(t *testing.T) { 64 loader := ComposeLoader{"../../tests/testproj/compose.yaml"} 65 p, err := loader.LoadWithProjectName("tests") 66 if err != nil { 67 t.Fatalf("LoadCompose() failed: %v", err) 68 } 69 if p.Name != "tests" { 70 t.Errorf("LoadCompose() failed: expected project name, got %q", p.Name) 71 } 72 }) 73 74 t.Run("fancy project name", func(t *testing.T) { 75 loader := ComposeLoader{"../../tests/noprojname/compose.yaml"} 76 p, err := loader.LoadWithProjectName("Valid-Username") 77 if err != nil { 78 t.Fatalf("LoadCompose() failed: %v", err) 79 } 80 if p.Name != "valid-username" { 81 t.Errorf("LoadCompose() failed: expected project name, got %q", p.Name) 82 } 83 }) 84 85 t.Run("no project name defaults to tenantID", func(t *testing.T) { 86 loader := ComposeLoader{"../../tests/noprojname/compose.yaml"} 87 p, err := loader.LoadWithDefaultProjectName("tenant-id") 88 if err != nil { 89 t.Fatalf("LoadCompose() failed: %v", err) 90 } 91 if p.Name != "tenant-id" { 92 t.Errorf("LoadCompose() failed: expected project name tenant-id, got %q", p.Name) 93 } 94 }) 95 96 t.Run("use project name should not be overriden by tenantID", func(t *testing.T) { 97 loader := ComposeLoader{"../../tests/testproj/compose.yaml"} 98 p, err := loader.LoadWithDefaultProjectName("tenant-id") 99 if err != nil { 100 t.Fatalf("LoadCompose() failed: %v", err) 101 } 102 if p.Name != "tests" { 103 t.Errorf("LoadCompose() failed: expected project name tests, got %q", p.Name) 104 } 105 }) 106 107 t.Run("no project name defaults to tenantID", func(t *testing.T) { 108 loader := ComposeLoader{"../../tests/noprojname/compose.yaml"} 109 p, err := loader.LoadWithDefaultProjectName("tenant-id") 110 if err != nil { 111 t.Fatalf("LoadCompose() failed: %v", err) 112 } 113 if p.Name != "tenant-id" { 114 t.Errorf("LoadCompose() failed: expected project name tenant-id, got %q", p.Name) 115 } 116 }) 117 118 t.Run("load starting from a sub directory", func(t *testing.T) { 119 cwd, _ := os.Getwd() 120 121 // setup 122 setup := func() { 123 os.MkdirAll("../../tests/alttestproj/subdir/subdir2", 0755) 124 os.Chdir("../../tests/alttestproj/subdir/subdir2") 125 } 126 127 //teardown 128 teardown := func() { 129 os.Chdir(cwd) 130 os.RemoveAll("../../tests/alttestproj/subdir") 131 } 132 133 setup() 134 defer teardown() 135 136 // execute test 137 loader := ComposeLoader{} 138 p, err := loader.LoadWithProjectName("tests") 139 if err != nil { 140 t.Fatalf("LoadCompose() failed: %v", err) 141 } 142 if p.Name != "tests" { 143 t.Errorf("LoadCompose() failed: expected project name, got %q", p.Name) 144 } 145 }) 146 147 t.Run("load alternative compose file", func(t *testing.T) { 148 loader := ComposeLoader{"../../tests/alttestproj/altcomp.yaml"} 149 p, err := loader.LoadWithProjectName("tests") 150 if err != nil { 151 t.Fatalf("LoadCompose() failed: %v", err) 152 } 153 if p.Name != "tests" { 154 t.Errorf("LoadCompose() failed: expected project name, got %q", p.Name) 155 } 156 }) 157 } 158 159 func TestConvertPort(t *testing.T) { 160 tests := []struct { 161 name string 162 input types.ServicePortConfig 163 expected *defangv1.Port 164 wantErr string 165 }{ 166 { 167 name: "No target port xfail", 168 input: types.ServicePortConfig{}, 169 wantErr: "port 'target' must be an integer between 1 and 32767", 170 }, 171 { 172 name: "Undefined mode and protocol, target only", 173 input: types.ServicePortConfig{Target: 1234}, 174 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS}, 175 }, 176 { 177 name: "Undefined mode and protocol, published equals target", 178 input: types.ServicePortConfig{Target: 1234, Published: "1234"}, 179 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS}, 180 }, 181 { 182 name: "Undefined mode, udp protocol, target only", 183 input: types.ServicePortConfig{Target: 1234, Protocol: "udp"}, 184 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST, Protocol: defangv1.Protocol_UDP}, // backwards compatibility 185 }, 186 { 187 name: "Undefined mode and published range xfail", 188 input: types.ServicePortConfig{Target: 1234, Published: "1511-2222"}, 189 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS}, 190 }, 191 { 192 name: "Undefined mode and target in published range xfail", 193 input: types.ServicePortConfig{Target: 1234, Published: "1111-2222"}, 194 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS}, 195 }, 196 { 197 name: "Undefined mode and published not equals target; common for local development", 198 input: types.ServicePortConfig{Target: 1234, Published: "12345"}, 199 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS}, 200 }, 201 { 202 name: "Host mode and undefined protocol, target only", 203 input: types.ServicePortConfig{Mode: "host", Target: 1234}, 204 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST}, 205 }, 206 { 207 name: "Host mode and udp protocol, target only", 208 input: types.ServicePortConfig{Mode: "host", Target: 1234, Protocol: "udp"}, 209 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST, Protocol: defangv1.Protocol_UDP}, 210 }, 211 { 212 name: "Host mode and protocol, published equals target", 213 input: types.ServicePortConfig{Mode: "host", Target: 1234, Published: "1234"}, 214 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST}, 215 }, 216 { 217 name: "Host mode and protocol, published range xfail", 218 input: types.ServicePortConfig{Mode: "host", Target: 1234, Published: "1511-2222"}, 219 wantErr: "port 'published' range must include 'target': 1511-2222", 220 }, 221 { 222 name: "Host mode and protocol, published range xfail", 223 input: types.ServicePortConfig{Mode: "host", Target: 1234, Published: "22222"}, 224 wantErr: "port 'published' must be empty or equal to 'target': 22222", 225 }, 226 { 227 name: "Host mode and protocol, target in published range", 228 input: types.ServicePortConfig{Mode: "host", Target: 1234, Published: "1111-2222"}, 229 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST}, 230 }, 231 { 232 name: "(Implied) ingress mode, defined protocol, only target", // - 1234 233 input: types.ServicePortConfig{Mode: "ingress", Protocol: "tcp", Target: 1234}, 234 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS, Protocol: defangv1.Protocol_HTTP}, 235 }, 236 { 237 name: "(Implied) ingress mode, udp protocol, only target", // - 1234/udp 238 input: types.ServicePortConfig{Mode: "ingress", Protocol: "udp", Target: 1234}, 239 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST, Protocol: defangv1.Protocol_UDP}, // backwards compatibility 240 }, 241 { 242 name: "(Implied) ingress mode, defined protocol, published equals target", // - 1234:1234 243 input: types.ServicePortConfig{Mode: "ingress", Protocol: "tcp", Published: "1234", Target: 1234}, 244 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS, Protocol: defangv1.Protocol_HTTP}, 245 }, 246 { 247 name: "(Implied) ingress mode, udp protocol, published equals target", // - 1234:1234/udp 248 input: types.ServicePortConfig{Mode: "ingress", Protocol: "udp", Published: "1234", Target: 1234}, 249 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_HOST, Protocol: defangv1.Protocol_UDP}, // backwards compatibility 250 }, 251 { 252 name: "Localhost IP, unsupported mode and protocol xfail", 253 input: types.ServicePortConfig{Mode: "ingress", HostIP: "127.0.0.1", Protocol: "tcp", Published: "1234", Target: 1234}, 254 wantErr: "port 'host_ip' is not supported", 255 }, 256 { 257 name: "Ingress mode without host IP, single target, published range xfail", // - 1511-2223:1234 258 input: types.ServicePortConfig{Mode: "ingress", Protocol: "tcp", Target: 1234, Published: "1511-2223"}, 259 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS, Protocol: defangv1.Protocol_HTTP}, 260 }, 261 { 262 name: "Ingress mode without host IP, single target, target in published range", // - 1111-2223:1234 263 input: types.ServicePortConfig{Mode: "ingress", Protocol: "tcp", Target: 1234, Published: "1111-2223"}, 264 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS, Protocol: defangv1.Protocol_HTTP}, 265 }, 266 { 267 name: "Ingress mode without host IP, published not equals target; common for local development", // - 12345:1234 268 input: types.ServicePortConfig{Mode: "ingress", Protocol: "tcp", Target: 1234, Published: "12345"}, 269 expected: &defangv1.Port{Target: 1234, Mode: defangv1.Mode_INGRESS, Protocol: defangv1.Protocol_HTTP}, 270 }, 271 } 272 for _, tt := range tests { 273 t.Run(tt.name, func(t *testing.T) { 274 err := validatePort(tt.input) 275 if err != nil { 276 if tt.wantErr == "" { 277 t.Errorf("convertPort() unexpected error: %v", err) 278 } else if !strings.Contains(err.Error(), tt.wantErr) { 279 t.Errorf("convertPort() error = %v, wantErr %v", err, tt.wantErr) 280 } 281 return 282 } 283 if tt.wantErr != "" { 284 t.Errorf("convertPort() expected error: %v", tt.wantErr) 285 } 286 got := convertPort(tt.input) 287 if got.String() != tt.expected.String() { 288 t.Errorf("convertPort() got %v, want %v", got, tt.expected.String()) 289 } 290 }) 291 } 292 } 293 294 func TestUploadTarball(t *testing.T) { 295 const path = "/upload/x/" 296 const digest = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" 297 298 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 299 if r.Method != "PUT" { 300 t.Errorf("Expected PUT request, got %v", r.Method) 301 } 302 if !strings.HasPrefix(r.URL.Path, path) { 303 t.Errorf("Expected prefix %v, got %v", path, r.URL.Path) 304 } 305 if r.Header.Get("Content-Type") != "application/gzip" { 306 t.Errorf("Expected Content-Type: application/gzip, got %v", r.Header.Get("Content-Type")) 307 } 308 w.WriteHeader(200) 309 })) 310 defer server.Close() 311 312 t.Run("upload with digest", func(t *testing.T) { 313 url, err := uploadTarball(context.Background(), client.MockClient{UploadUrl: server.URL + path}, &bytes.Buffer{}, digest) 314 if err != nil { 315 t.Fatalf("uploadTarball() failed: %v", err) 316 } 317 const expectedPath = path + digest 318 if url != server.URL+expectedPath { 319 t.Errorf("Expected %v, got %v", server.URL+expectedPath, url) 320 } 321 }) 322 323 t.Run("force upload without digest", func(t *testing.T) { 324 url, err := uploadTarball(context.Background(), client.MockClient{UploadUrl: server.URL + path}, &bytes.Buffer{}, "") 325 if err != nil { 326 t.Fatalf("uploadTarball() failed: %v", err) 327 } 328 if url != server.URL+path { 329 t.Errorf("Expected %v, got %v", server.URL+path, url) 330 } 331 }) 332 } 333 334 func TestCreateTarballReader(t *testing.T) { 335 t.Run("Default Dockerfile", func(t *testing.T) { 336 buffer, err := createTarball(context.Background(), "../../tests/testproj", "") 337 if err != nil { 338 t.Fatalf("createTarballReader() failed: %v", err) 339 } 340 341 g, err := gzip.NewReader(buffer) 342 if err != nil { 343 t.Fatalf("gzip.NewReader() failed: %v", err) 344 } 345 defer g.Close() 346 347 expected := []string{".dockerignore", "Dockerfile", "fileName.env"} 348 var actual []string 349 ar := tar.NewReader(g) 350 for { 351 h, err := ar.Next() 352 if err != nil { 353 if err == io.EOF { 354 break 355 } 356 t.Fatal(err) 357 } 358 // Ensure the paths are relative 359 if h.Name[0] == '/' { 360 t.Errorf("Path is not relative: %v", h.Name) 361 } 362 if _, err := ar.Read(make([]byte, h.Size)); err != io.EOF { 363 t.Log(err) 364 } 365 actual = append(actual, h.Name) 366 } 367 if !reflect.DeepEqual(actual, expected) { 368 t.Errorf("Expected files: %v, got %v", expected, actual) 369 } 370 }) 371 372 t.Run("Missing Dockerfile", func(t *testing.T) { 373 _, err := createTarball(context.Background(), "../../tests", "Dockerfile.missing") 374 if err == nil { 375 t.Fatal("createTarballReader() should have failed") 376 } 377 }) 378 379 t.Run("Missing Context", func(t *testing.T) { 380 _, err := createTarball(context.Background(), "asdfqwer", "") 381 if err == nil { 382 t.Fatal("createTarballReader() should have failed") 383 } 384 }) 385 } 386 387 type MockClient struct { 388 client.Client 389 } 390 391 func (m MockClient) Deploy(ctx context.Context, req *defangv1.DeployRequest) (*defangv1.DeployResponse, error) { 392 return &defangv1.DeployResponse{}, nil 393 } 394 395 func TestProjectValidationServiceName(t *testing.T) { 396 loader := ComposeLoader{"../../tests/testproj/compose.yaml"} 397 p, err := loader.LoadWithDefaultProjectName("tests") 398 if err != nil { 399 t.Fatalf("LoadCompose() failed: %v", err) 400 } 401 402 if err := validateProject(p); err != nil { 403 t.Fatalf("Project validation failed: %v", err) 404 } 405 406 svc := p.Services["dfnx"] 407 longName := "aVeryLongServiceNameThatIsDefinitelyTooLongThatWillCauseAnError" 408 svc.Name = longName 409 p.Services[longName] = svc 410 411 if err := validateProject(p); err == nil { 412 t.Fatalf("Long project name should be an error") 413 } 414 415 } 416 417 func TestProjectValidationNetworks(t *testing.T) { 418 var warnings bytes.Buffer 419 logrus.SetOutput(&warnings) 420 421 loader := ComposeLoader{"../../tests/testproj/compose.yaml"} 422 p, err := loader.LoadWithDefaultProjectName("tests") 423 if err != nil { 424 t.Fatalf("LoadCompose() failed: %v", err) 425 } 426 427 dfnx := p.Services["dfnx"] 428 dfnx.Networks = map[string]*types.ServiceNetworkConfig{"invalid-network-name": nil} 429 p.Services["dfnx"] = dfnx 430 if err := validateProject(p); err != nil { 431 t.Errorf("Invalid network name should not be an error: %v", err) 432 } 433 if !bytes.Contains(warnings.Bytes(), []byte("network invalid-network-name used by service dfnx is not defined")) { 434 t.Errorf("Invalid network name should trigger a warning") 435 } 436 437 warnings.Reset() 438 dfnx.Networks = map[string]*types.ServiceNetworkConfig{"public": nil} 439 p.Services["dfnx"] = dfnx 440 if err := validateProject(p); err != nil { 441 t.Errorf("public network name should not be an error: %v", err) 442 } 443 if !bytes.Contains(warnings.Bytes(), []byte("network public used by service dfnx is not defined")) { 444 t.Errorf("missing public network in global networks section should trigger a warning") 445 } 446 447 warnings.Reset() 448 p.Networks["public"] = types.NetworkConfig{} 449 if err := validateProject(p); err != nil { 450 t.Errorf("unexpected error: %v", err) 451 } 452 if bytes.Contains(warnings.Bytes(), []byte("network public used by service dfnx is not defined")) { 453 t.Errorf("When public network is defined globally should not trigger a warning when public network is used") 454 } 455 } 456 457 func TestProjectValidationNoDeploy(t *testing.T) { 458 loader := ComposeLoader{"../../tests/testproj/compose.yaml"} 459 p, err := loader.LoadWithDefaultProjectName("tests") 460 if err != nil { 461 t.Fatalf("LoadCompose() failed: %v", err) 462 } 463 464 dfnx := p.Services["dfnx"] 465 dfnx.Deploy = nil 466 p.Services["dfnx"] = dfnx 467 if err := validateProject(p); err != nil { 468 t.Errorf("No deploy section should not be an error: %v", err) 469 } 470 }