detached 3c89aed

feat(docker): add HTTP port priority and container file extraction

commit
3c89aed024e8a963a38d376a20ab1e6a3fdb9c71
author
bnema · · 2026-01-16 20:51 UTC
parent
25b0699
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +141 -0
M internal/adapters/out/docker/runtime.go +141 -0
@@ -2,11 +2,14 @@
22 package docker
33
44 import (
5+ "archive/tar"
56 "context"
67 "encoding/base64"
78 "encoding/json"
89 "fmt"
910 "io"
11+ "path/filepath"
12+ "sort"
1013 "strconv"
1114 "strings"
1215
@@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]
619622 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
620623 }
621624
625+ // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
626+ // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
627+ sortPortsHTTPFirst(ports)
628+
622629 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
623630 return ports, nil
624631 }
625632
633+// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
634+func sortPortsHTTPFirst(ports []int) {
635+ priority := map[int]int{
636+ 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
637+ }
638+ sort.Slice(ports, func(i, j int) bool {
639+ pi, oki := priority[ports[i]]
640+ pj, okj := priority[ports[j]]
641+ if oki && okj {
642+ return pi < pj
643+ }
644+ if oki {
645+ return true
646+ }
647+ if okj {
648+ return false
649+ }
650+ // Neither is a priority port - sort ascending but push 22 to the end
651+ if ports[i] == 22 {
652+ return false
653+ }
654+ if ports[j] == 22 {
655+ return true
656+ }
657+ return ports[i] < ports[j]
658+ })
659+}
660+
626661 // GetContainerExposedPorts gets all exposed ports from a running container.
627662 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
628663 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
10181053 log.Info().Msg("container disconnected from network")
10191054 return nil
10201055 }
1056+
1057+// CopyFromContainer copies a file from a container to the host.
1058+// Returns the file contents as a byte slice.
1059+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1060+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1061+ zerowrap.FieldLayer: "adapter",
1062+ zerowrap.FieldAdapter: "docker",
1063+ zerowrap.FieldAction: "CopyFromContainer",
1064+ zerowrap.FieldEntityID: containerID,
1065+ "src_path": srcPath,
1066+ })
1067+ log := zerowrap.FromCtx(ctx)
1068+
1069+ reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1070+ if err != nil {
1071+ return nil, log.WrapErr(err, "failed to copy from container")
1072+ }
1073+ defer reader.Close()
1074+
1075+ // The response is a tar archive - we need to extract the file
1076+ data, err := extractFileFromTar(reader, srcPath)
1077+ if err != nil {
1078+ return nil, log.WrapErr(err, "failed to extract file from tar")
1079+ }
1080+
1081+ log.Debug().Int("size", len(data)).Msg("file copied from container")
1082+ return data, nil
1083+}
1084+
1085+// extractFileFromTar extracts a single file from a tar archive.
1086+func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1087+ tr := tar.NewReader(reader)
1088+
1089+ // The path in the tar may be relative or absolute
1090+ // We need to match based on the filename
1091+ targetName := filepath.Base(targetPath)
1092+
1093+ for {
1094+ header, err := tr.Next()
1095+ if err == io.EOF {
1096+ break
1097+ }
1098+ if err != nil {
1099+ return nil, err
1100+ }
1101+
1102+ // Check if this is our target file
1103+ if header.Typeflag == tar.TypeReg {
1104+ headerName := filepath.Base(header.Name)
1105+ if headerName == targetName {
1106+ data, err := io.ReadAll(tr)
1107+ if err != nil {
1108+ return nil, err
1109+ }
1110+ return data, nil
1111+ }
1112+ }
1113+ }
1114+
1115+ return nil, fmt.Errorf("file not found in container: %s", targetPath)
1116+}
1117+
1118+// ExtractEnvFileFromImage extracts an env file from an image.
1119+// This creates a temporary container, copies the file, and removes the container.
1120+func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1121+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1122+ zerowrap.FieldLayer: "adapter",
1123+ zerowrap.FieldAdapter: "docker",
1124+ zerowrap.FieldAction: "ExtractEnvFileFromImage",
1125+ "image": imageRef,
1126+ "env_file": envFilePath,
1127+ })
1128+ log := zerowrap.FromCtx(ctx)
1129+
1130+ log.Info().Msg("extracting env file from image")
1131+
1132+ // Create a temporary container without starting it
1133+ containerConfig := &container.Config{
1134+ Image: imageRef,
1135+ Cmd: []string{"true"}, // Dummy command
1136+ }
1137+
1138+ resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1139+ if err != nil {
1140+ return nil, log.WrapErr(err, "failed to create temporary container")
1141+ }
1142+
1143+ tempContainerID := resp.ID
1144+ log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1145+
1146+ // Ensure cleanup
1147+ defer func() {
1148+ if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1149+ log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1150+ }
1151+ }()
1152+
1153+ // Copy the env file from the container
1154+ data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1155+ if err != nil {
1156+ return nil, err
1157+ }
1158+
1159+ log.Info().Int("size", len(data)).Msg("env file extracted from image")
1160+ return data, nil
1161+}
@@ -2,11 +2,14 @@
2 package docker2 package docker
3 3
4 import (4 import (
5+ "archive/tar"
5 "context"6 "context"
6 "encoding/base64"7 "encoding/base64"
7 "encoding/json"8 "encoding/json"
8 "fmt"9 "fmt"
9 "io"10 "io"
11+ "path/filepath"
12+ "sort"
10 "strconv"13 "strconv"
11 "strings"14 "strings"
12 15
@@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]
619 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)622 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
620 }623 }
621 624
625+ // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
626+ // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
627+ sortPortsHTTPFirst(ports)
628+
622 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")629 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
623 return ports, nil630 return ports, nil
624 }631 }
625 632
633+// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
634+func sortPortsHTTPFirst(ports []int) {
635+ priority := map[int]int{
636+ 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
637+ }
638+ sort.Slice(ports, func(i, j int) bool {
639+ pi, oki := priority[ports[i]]
640+ pj, okj := priority[ports[j]]
641+ if oki && okj {
642+ return pi < pj
643+ }
644+ if oki {
645+ return true
646+ }
647+ if okj {
648+ return false
649+ }
650+ // Neither is a priority port - sort ascending but push 22 to the end
651+ if ports[i] == 22 {
652+ return false
653+ }
654+ if ports[j] == 22 {
655+ return true
656+ }
657+ return ports[i] < ports[j]
658+ })
659+}
660+
626 // GetContainerExposedPorts gets all exposed ports from a running container.661 // GetContainerExposedPorts gets all exposed ports from a running container.
627 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {662 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
628 ctx = zerowrap.CtxWithFields(ctx, map[string]any{663 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
1018 log.Info().Msg("container disconnected from network")1053 log.Info().Msg("container disconnected from network")
1019 return nil1054 return nil
1020 }1055 }
1056+
1057+// CopyFromContainer copies a file from a container to the host.
1058+// Returns the file contents as a byte slice.
1059+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1060+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1061+ zerowrap.FieldLayer: "adapter",
1062+ zerowrap.FieldAdapter: "docker",
1063+ zerowrap.FieldAction: "CopyFromContainer",
1064+ zerowrap.FieldEntityID: containerID,
1065+ "src_path": srcPath,
1066+ })
1067+ log := zerowrap.FromCtx(ctx)
1068+
1069+ reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1070+ if err != nil {
1071+ return nil, log.WrapErr(err, "failed to copy from container")
1072+ }
1073+ defer reader.Close()
1074+
1075+ // The response is a tar archive - we need to extract the file
1076+ data, err := extractFileFromTar(reader, srcPath)
1077+ if err != nil {
1078+ return nil, log.WrapErr(err, "failed to extract file from tar")
1079+ }
1080+
1081+ log.Debug().Int("size", len(data)).Msg("file copied from container")
1082+ return data, nil
1083+}
1084+
1085+// extractFileFromTar extracts a single file from a tar archive.
1086+func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1087+ tr := tar.NewReader(reader)
1088+
1089+ // The path in the tar may be relative or absolute
1090+ // We need to match based on the filename
1091+ targetName := filepath.Base(targetPath)
1092+
1093+ for {
1094+ header, err := tr.Next()
1095+ if err == io.EOF {
1096+ break
1097+ }
1098+ if err != nil {
1099+ return nil, err
1100+ }
1101+
1102+ // Check if this is our target file
1103+ if header.Typeflag == tar.TypeReg {
1104+ headerName := filepath.Base(header.Name)
1105+ if headerName == targetName {
1106+ data, err := io.ReadAll(tr)
1107+ if err != nil {
1108+ return nil, err
1109+ }
1110+ return data, nil
1111+ }
1112+ }
1113+ }
1114+
1115+ return nil, fmt.Errorf("file not found in container: %s", targetPath)
1116+}
1117+
1118+// ExtractEnvFileFromImage extracts an env file from an image.
1119+// This creates a temporary container, copies the file, and removes the container.
1120+func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1121+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1122+ zerowrap.FieldLayer: "adapter",
1123+ zerowrap.FieldAdapter: "docker",
1124+ zerowrap.FieldAction: "ExtractEnvFileFromImage",
1125+ "image": imageRef,
1126+ "env_file": envFilePath,
1127+ })
1128+ log := zerowrap.FromCtx(ctx)
1129+
1130+ log.Info().Msg("extracting env file from image")
1131+
1132+ // Create a temporary container without starting it
1133+ containerConfig := &container.Config{
1134+ Image: imageRef,
1135+ Cmd: []string{"true"}, // Dummy command
1136+ }
1137+
1138+ resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1139+ if err != nil {
1140+ return nil, log.WrapErr(err, "failed to create temporary container")
1141+ }
1142+
1143+ tempContainerID := resp.ID
1144+ log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1145+
1146+ // Ensure cleanup
1147+ defer func() {
1148+ if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1149+ log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1150+ }
1151+ }()
1152+
1153+ // Copy the env file from the container
1154+ data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1155+ if err != nil {
1156+ return nil, err
1157+ }
1158+
1159+ log.Info().Int("size", len(data)).Msg("env file extracted from image")
1160+ return data, nil
1161+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close