Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions internal/command/deploy/deploy_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move os.Remove() to defer? You can check err in defer to see whether the function is successfully finished or not.

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")
}
Expand Down
96 changes: 96 additions & 0 deletions internal/command/deploy/deploy_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package deploy

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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)
})
}
}
Loading