feat(docker): add HTTP port priority and container file extraction
- commit
- 3c89aed024e8a963a38d376a20ab1e6a3fdb9c71
- author
- bnema · · 2026-01-16 20:51 UTC
- parent
- 25b0699
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +141 -0
M internal/adapters/out/docker/runtime.go +141 -0
| @@ -2,11 +2,14 @@ | |||
| 2 | 2 | package docker | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "archive/tar" | |
| 5 | 6 | "context" | |
| 6 | 7 | "encoding/base64" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sort" | |
| 10 | 13 | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | ||
| @@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([] | |||
| 619 | 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 | 629 | log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") | |
| 623 | 630 | 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 | 661 | // GetContainerExposedPorts gets all exposed ports from a running container. | |
| 627 | 662 | func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | |
| 628 | 663 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||
| 1018 | 1053 | log.Info().Msg("container disconnected from network") | |
| 1019 | 1054 | 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 | + | } | |
| @@ -2,11 +2,14 @@ | |||||
| 2 | package docker | 2 | 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, nil | 630 | 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 nil | 1054 | 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 | + | } | |||