diff --git a/internal/command/deploy/deploy_build.go b/internal/command/deploy/deploy_build.go index 24c98af65e..118c786235 100644 --- a/internal/command/deploy/deploy_build.go +++ b/internal/command/deploy/deploy_build.go @@ -4,7 +4,12 @@ import ( "context" "errors" "fmt" + "io" + "net/http" + "net/url" + "os" "path/filepath" + "strings" "github.com/dustin/go-humanize" "github.com/superfly/flyctl/internal/appconfig" @@ -234,17 +239,73 @@ func determineImage(ctx context.Context, appConfig *appconfig.Config, useWG, rec return } +// downloadDockerfileFromURL downloads a Dockerfile from a URL to a temporary file +func downloadDockerfileFromURL(ctx context.Context, dockerfileURL string) (tempPath string, err error) { + // Create a temporary file for the Dockerfile + tempFile, err := os.CreateTemp("", "dockerfile-*.tmp") + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer tempFile.Close() + + // Download the Dockerfile + resp, err := http.Get(dockerfileURL) + if err != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to download Dockerfile from %s: %w", dockerfileURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to download Dockerfile from %s: HTTP %d", dockerfileURL, resp.StatusCode) + } + + // Copy the content to the temporary file + _, err = io.Copy(tempFile, resp.Body) + if err != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to write Dockerfile to temporary file: %w", err) + } + + return tempFile.Name(), nil +} + +// isURL checks if a string is a valid HTTP or HTTPS URL +func isURL(path string) bool { + if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + return false + } + parsed, err := url.Parse(path) + if err != nil { + return false + } + // Ensure the URL has a host + return parsed.Host != "" +} + // resolveDockerfilePath returns the absolute path to the Dockerfile // if one was specified in the app config or a command line argument func resolveDockerfilePath(ctx context.Context, appConfig *appconfig.Config) (path string, err error) { defer func() { - if err == nil && path != "" { + if err == nil && path != "" && !isURL(path) { path, err = filepath.Abs(path) } }() if path = appConfig.Dockerfile(); path != "" { - path = filepath.Join(filepath.Dir(appConfig.ConfigFilePath()), path) + // Check if the path is a URL + if isURL(path) { + // Download the Dockerfile from the URL + tempPath, downloadErr := downloadDockerfileFromURL(ctx, path) + if downloadErr != nil { + return "", fmt.Errorf("failed to download Dockerfile from URL %s: %w", path, downloadErr) + } + path = tempPath + } else { + // Treat as local path relative to the config file + path = filepath.Join(filepath.Dir(appConfig.ConfigFilePath()), path) + } } else { path = flag.GetString(ctx, "dockerfile") } diff --git a/internal/command/deploy/deploy_build_test.go b/internal/command/deploy/deploy_build_test.go index 5a4aac4f51..d85f40ecff 100644 --- a/internal/command/deploy/deploy_build_test.go +++ b/internal/command/deploy/deploy_build_test.go @@ -2,6 +2,9 @@ package deploy import ( "context" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -37,3 +40,96 @@ func TestMultipleDockerfile(t *testing.T) { err = multipleDockerfile(ctx, cfg) assert.ErrorContains(t, err, "fly.production.toml") } + +func TestResolveDockerfilePath(t *testing.T) { + t.Run("local path", func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "fly.toml") + + cfg := &appconfig.Config{} + cfg.SetConfigFilePath(configPath) + cfg.Build = &appconfig.Build{ + Dockerfile: "Dockerfile.custom", + } + + ctx := context.Background() + path, err := resolveDockerfilePath(ctx, cfg) + + require.NoError(t, err) + expectedPath := filepath.Join(dir, "Dockerfile.custom") + assert.Equal(t, expectedPath, path) + }) + + t.Run("URL path", func(t *testing.T) { + // Create a test server that serves a Dockerfile + dockerfileContent := `FROM alpine:latest +RUN apk add --no-cache curl +ENTRYPOINT ["sh"]` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, dockerfileContent) + })) + defer server.Close() + + cfg := &appconfig.Config{} + cfg.Build = &appconfig.Build{ + Dockerfile: server.URL + "/Dockerfile", + } + + ctx := context.Background() + path, err := resolveDockerfilePath(ctx, cfg) + + require.NoError(t, err) + assert.NotEmpty(t, path) + + // Verify the file exists and contains the expected content + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, dockerfileContent, string(content)) + + // Clean up the temporary file + os.Remove(path) + }) + + t.Run("URL path with 404", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := &appconfig.Config{} + cfg.Build = &appconfig.Build{ + Dockerfile: server.URL + "/nonexistent", + } + + ctx := context.Background() + _, err := resolveDockerfilePath(ctx, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 404") + }) +} + +func TestIsURL(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"https://example.com/Dockerfile", true}, + {"http://example.com/Dockerfile", true}, + {"ftp://example.com/Dockerfile", false}, + {"./Dockerfile", false}, + {"/path/to/Dockerfile", false}, + {"Dockerfile", false}, + {"not-a-url", false}, + {"https://", false}, // invalid URL + } + + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + result := isURL(test.path) + assert.Equal(t, test.expected, result) + }) + } +}