Skip to content

Commit

Permalink
easy-init: auto detect port from Dockerfile (#4454)
Browse files Browse the repository at this point in the history
Use the `EXPOSE` directives in a Dockerfile to default the port configuration for the service. 

- When multiple `EXPOSE` ports are present, we will prompt the user for port selection.
- When no Dockerfile or `EXPOSE` is present, the user will be asked as usual to provide a port number.

Completes #4443
  • Loading branch information
rujche authored Oct 23, 2024
1 parent 4182b41 commit aa34833
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 33 deletions.
8 changes: 7 additions & 1 deletion cli/azd/internal/appdetect/appdetect.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,14 @@ func (p *Project) HasWebUIFramework() bool {
return false
}

type Port struct {
Number int
Protocol string
}

type Docker struct {
Path string
Path string
Ports []Port
}

type projectDetector interface {
Expand Down
3 changes: 2 additions & 1 deletion cli/azd/internal/appdetect/appdetect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ func TestDetectDocker(t *testing.T) {
Path: filepath.Join(dir, "dotnet"),
DetectionRule: "Inferred by presence of: dotnettestapp.csproj, Program.cs",
Docker: &Docker{
Path: filepath.Join(dir, "dotnet", "Dockerfile"),
Path: filepath.Join(dir, "dotnet", "Dockerfile"),
Ports: nil,
},
})
}
Expand Down
58 changes: 55 additions & 3 deletions cli/azd/internal/appdetect/docker.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,71 @@
package appdetect

import (
"bufio"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)

func detectDocker(path string, entries []fs.DirEntry) (*Docker, error) {
for _, entry := range entries {
if strings.ToLower(entry.Name()) == "dockerfile" {
return &Docker{
Path: filepath.Join(path, entry.Name()),
}, nil
dockerFilePath := filepath.Join(path, entry.Name())
return detectDockerFromFile(dockerFilePath)
}
}

return nil, nil
}

func detectDockerFromFile(dockerFilePath string) (*Docker, error) {
file, err := os.Open(dockerFilePath)
if err != nil {
return nil, fmt.Errorf("reading Dockerfile at %s: %w", dockerFilePath, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)

var ports []Port
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "EXPOSE") {
parsedPorts, err := parsePortsInLine(line[len("EXPOSE"):])
if err != nil {
log.Printf("parsing Dockerfile at %s: %v", dockerFilePath, err)
}
ports = append(ports, parsedPorts...)
}
}
return &Docker{
Path: dockerFilePath,
Ports: ports,
}, nil
}

func parsePortsInLine(s string) ([]Port, error) {
var ports []Port
portSpecs := strings.Fields(s)
for _, portSpec := range portSpecs {
var portString string
var protocol string
if strings.Contains(portSpec, "/") {
parts := strings.Split(portSpec, "/")
portString = parts[0]
protocol = parts[1]
} else {
portString = portSpec
protocol = "tcp"
}
portNumber, err := strconv.Atoi(portString)
if err != nil {
return nil, fmt.Errorf("parsing port number: %w", err)
}
ports = append(ports, Port{portNumber, protocol})
}
return ports, nil
}
61 changes: 61 additions & 0 deletions cli/azd/internal/appdetect/docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package appdetect

import (
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
"testing"
)

func TestParsePortsInLine(t *testing.T) {
tests := []struct {
portString string
expectedPorts []Port
}{
{"", nil},
{"80", []Port{{80, "tcp"}}},
{"80 3100", []Port{{80, "tcp"}, {3100, "tcp"}}},
{"80 3100/udp", []Port{{80, "tcp"}, {3100, "udp"}}},
{" 80/tcp 3100/udp ", []Port{{80, "tcp"}, {3100, "udp"}}},
}
for _, tt := range tests {
t.Run(tt.portString, func(t *testing.T) {
actual, err := parsePortsInLine(tt.portString)
assert.NoError(t, err)
assert.Equal(t, tt.expectedPorts, actual)
})
}
}

func TestDetectDockerFromFile(t *testing.T) {
tests := []struct {
dockerFileContent string
expectedPorts []Port
}{
{"", nil},
{"# EXPOSE 80", nil},
{"EXPOSE 80", []Port{{80, "tcp"}}},
{"EXPOSE 80 3100", []Port{{80, "tcp"}, {3100, "tcp"}}},
{"EXPOSE 80\nEXPOSE 3100", []Port{{80, "tcp"}, {3100, "tcp"}}},
{"EXPOSE 80/tcp\nEXPOSE 3100/udp", []Port{{80, "tcp"}, {3100, "udp"}}},
{"\n EXPOSE 80/tcp\n EXPOSE 3100/udp", []Port{{80, "tcp"}, {3100, "udp"}}},
}
for _, tt := range tests {
t.Run(tt.dockerFileContent, func(t *testing.T) {
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "Dockerfile")
file, err := os.Create(tempFile)
assert.NoError(t, err)
file.Close()

err = os.WriteFile(tempFile, []byte(tt.dockerFileContent), osutil.PermissionFile)
assert.NoError(t, err)

docker, err := detectDockerFromFile(tempFile)
assert.NoError(t, err)
actual := docker.Ports
assert.Equal(t, tt.expectedPorts, actual)
})
}
}
88 changes: 60 additions & 28 deletions cli/azd/internal/repository/infra_confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ func (i *Initializer) infraSpecFromDetect(
spec.DbCosmosMongo = &scaffold.DatabaseCosmosMongo{
DatabaseName: dbName,
}

break dbPrompt
case appdetect.DbPostgres:
if dbName == "" {
Expand All @@ -103,10 +102,43 @@ func (i *Initializer) infraSpecFromDetect(
if svc.Docker == nil || svc.Docker.Path == "" {
// default builder always specifies port 80
serviceSpec.Port = 80

if svc.Language == appdetect.Java {
serviceSpec.Port = 8080
}
} else {
ports := svc.Docker.Ports
if len(ports) == 0 {
port, err := i.getPortByPrompt(ctx, "What port does '"+serviceSpec.Name+"' listen on?")
if err != nil {
return scaffold.InfraSpec{}, err
}
serviceSpec.Port = port
} else if len(ports) == 1 {
serviceSpec.Port = ports[0].Number
} else {
var portOptions []string
for _, port := range ports {
portOptions = append(portOptions, strconv.Itoa(port.Number))
}
inputAnotherPortOption := "Other"
portOptions = append(portOptions, inputAnotherPortOption)
selection, err := i.console.Select(ctx, input.ConsoleOptions{
Message: "What port does '" + serviceSpec.Name + "' listen on?",
Options: portOptions,
})
if err != nil {
return scaffold.InfraSpec{}, err
}
if selection < len(ports) {
serviceSpec.Port = ports[selection].Number
} else {
port, err := i.getPortByPrompt(ctx, "Provide the port number for '"+serviceSpec.Name+"':")
if err != nil {
return scaffold.InfraSpec{}, err
}
serviceSpec.Port = port
}
}
}

for _, framework := range svc.Dependencies {
Expand Down Expand Up @@ -142,32 +174,6 @@ func (i *Initializer) infraSpecFromDetect(
backends := []scaffold.ServiceReference{}
frontends := []scaffold.ServiceReference{}
for idx := range spec.Services {
if spec.Services[idx].Port == -1 {
var port int
for {
val, err := i.console.Prompt(ctx, input.ConsoleOptions{
Message: "What port does '" + spec.Services[idx].Name + "' listen on?",
})
if err != nil {
return scaffold.InfraSpec{}, err
}

port, err = strconv.Atoi(val)
if err != nil {
i.console.Message(ctx, "Port must be an integer.")
continue
}

if port < 1 || port > 65535 {
i.console.Message(ctx, "Port must be a value between 1 and 65535.")
continue
}

break
}
spec.Services[idx].Port = port
}

if spec.Services[idx].Frontend == nil && spec.Services[idx].Port != 0 {
backends = append(backends, scaffold.ServiceReference{
Name: spec.Services[idx].Name,
Expand All @@ -194,3 +200,29 @@ func (i *Initializer) infraSpecFromDetect(

return spec, nil
}

func (i *Initializer) getPortByPrompt(ctx context.Context, promptMessage string) (int, error) {
var port int
for {
val, err := i.console.Prompt(ctx, input.ConsoleOptions{
Message: promptMessage,
})
if err != nil {
return -1, err
}

port, err = strconv.Atoi(val)
if err != nil {
i.console.Message(ctx, "Port must be an integer.")
continue
}

if port < 1 || port > 65535 {
i.console.Message(ctx, "Port must be a value between 1 and 65535.")
continue
}

break
}
return port, nil
}

0 comments on commit aa34833

Please sign in to comment.