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  }