Skip to content

Commit 4f16136

Browse files
authored
Merge pull request #55 from stuartleeks/sl/child-paths
Support running devcontainer exec from child folders of the project
2 parents e835954 + 7a603bf commit 4f16136

File tree

8 files changed

+238
-63
lines changed

8 files changed

+238
-63
lines changed

.devcontainer/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,10 @@ RUN \
6363

6464
# Switch back to dialog for any ad-hoc use of apt-get
6565
ENV DEBIAN_FRONTEND=dialog
66+
67+
# gh
68+
COPY scripts/gh.sh /tmp/
69+
RUN /tmp/gh.sh
70+
71+
# symlink gh config folder
72+
RUN echo 'if [[ ! -d /home/vscode/.config/gh ]]; then mkdir -p /home/vscode/.config; ln -s /config/gh /home/vscode/.config/gh; fi ' >> ~/.bashrc

.devcontainer/devcontainer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,11 @@
6161
"postCreateCommand": "make post-create",
6262

6363
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
64-
"remoteUser": "vscode"
64+
"remoteUser": "vscode",
65+
"mounts": [
66+
// Mounts the .config/gh host folder into the dev container to pick up host gh CLI login details
67+
// NOTE that mounting directly to ~/.config/gh makes ~/.config only root-writable
68+
// Instead monut to another location and symlink in Dockerfile
69+
"type=bind,source=${env:HOME}${env:USERPROFILE}/.config/gh,target=/config/gh",
70+
],
6571
}

.devcontainer/scripts/gh.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/bash
2+
set -e
3+
4+
get_latest_release() {
5+
curl --silent "https://api.github.com/repos/$1/releases/latest" |
6+
grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/'
7+
}
8+
9+
VERSION=${1:-"$(get_latest_release cli/cli)"}
10+
INSTALL_DIR=${2:-"$HOME/.local/bin"}
11+
CMD=gh
12+
NAME="GitHub CLI"
13+
14+
echo -e "\e[34m»»» 📦 \e[32mInstalling \e[33m$NAME \e[35mv$VERSION\e[0m ..."
15+
16+
mkdir -p $INSTALL_DIR
17+
curl -sSL https://github.com/cli/cli/releases/download/v${VERSION}/gh_${VERSION}_linux_amd64.tar.gz -o /tmp/gh.tar.gz
18+
tar -zxvf /tmp/gh.tar.gz --strip-components 2 -C $INSTALL_DIR gh_${VERSION}_linux_amd64/bin/gh > /dev/null
19+
chmod +x $INSTALL_DIR/gh
20+
rm -rf /tmp/gh.tar.gz
21+
22+
echo -e "\n\e[34m»»» 💾 \e[32mInstalled to: \e[33m$(which $CMD)"
23+
echo -e "\e[34m»»» 💡 \e[32mVersion details: \e[39m$($CMD --version)"

cmd/devcontainer/devcontainer.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,33 @@ func createExecCommand() *cobra.Command {
9898
return cmd.Usage()
9999
}
100100

101-
containerIDOrName := ""
101+
// workDir default:
102+
// - devcontainer mount path if name or prompt specified (ExecInDevContainer defaults to this if workDir is "")
103+
// - path if path set
104+
// - current directory if path == "" and neither name or prompt set
105+
workDir := argWorkDir
106+
107+
containerID := ""
102108
devcontainerList, err := devcontainers.ListDevcontainers()
103109
if err != nil {
104110
return err
105111
}
106112
if argDevcontainerName != "" {
107-
containerIDOrName = argDevcontainerName
113+
containerIDOrName := argDevcontainerName
114+
115+
// Get container ID
116+
for _, devcontainer := range devcontainerList {
117+
if devcontainer.ContainerName == containerIDOrName ||
118+
devcontainer.DevcontainerName == containerIDOrName ||
119+
devcontainer.ContainerID == containerIDOrName {
120+
containerID = devcontainer.ContainerID
121+
break
122+
}
123+
}
124+
125+
if containerID == "" {
126+
return fmt.Errorf("Failed to find a matching (running) dev container for %q", containerIDOrName)
127+
}
108128
} else if argPromptForDevcontainer {
109129
// prompt user
110130
fmt.Println("Specify the devcontainer to use:")
@@ -116,16 +136,27 @@ func createExecCommand() *cobra.Command {
116136
if selection < 0 || selection >= len(devcontainerList) {
117137
return fmt.Errorf("Invalid option")
118138
}
119-
containerIDOrName = devcontainerList[selection].ContainerID
139+
containerID = devcontainerList[selection].ContainerID
120140
} else {
121141
devcontainerPath := argDevcontainerPath
122-
containerIDOrName, err = devcontainers.GetContainerIDForPath(devcontainerPath)
142+
// TODO - update to check for devcontainers in the path ancestry
143+
// Can't just check up the path for a .devcontainer folder as the container might
144+
// have been created via repository containers (https://github.com/microsoft/vscode-dev-containers/tree/main/repository-containers)
145+
devcontainer, err := devcontainers.GetClosestPathMatchForPath(devcontainerList, devcontainerPath)
123146
if err != nil {
124147
return err
125148
}
149+
containerID = devcontainer.ContainerID
150+
if workDir == "" {
151+
if devcontainerPath == "" {
152+
workDir = "."
153+
} else {
154+
workDir = devcontainerPath
155+
}
156+
}
126157
}
127158

128-
return devcontainers.ExecInDevContainer(containerIDOrName, argWorkDir, args)
159+
return devcontainers.ExecInDevContainer(containerID, workDir, args)
129160
},
130161
Args: cobra.ArbitraryArgs,
131162
DisableFlagsInUseLine: true,

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ require (
77
github.com/bradford-hamilton/dora v0.1.1
88
github.com/kyoh86/richgo v0.3.6 // indirect
99
github.com/kyoh86/xdg v1.2.0 // indirect
10-
github.com/mattn/go-isatty v0.0.12 // indirect
10+
github.com/mattn/go-isatty v0.0.13 // indirect
1111
github.com/morikuni/aec v1.0.0 // indirect
1212
github.com/nats-io/nats.go v1.8.1
1313
github.com/rhysd/go-github-selfupdate v1.2.2
1414
github.com/spf13/cobra v1.0.0
1515
github.com/spf13/viper v1.4.0
1616
github.com/stretchr/testify v1.7.0
1717
github.com/wacul/ptr v1.0.0 // indirect
18-
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
18+
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b // indirect
1919
gopkg.in/yaml.v2 v2.4.0 // indirect
2020
)
2121

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ github.com/mattn/go-isatty v0.0.0-20170925054904-a5cdd64afdee h1:tUyoJR5V1TdXnTh
8181
github.com/mattn/go-isatty v0.0.0-20170925054904-a5cdd64afdee/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
8282
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
8383
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
84+
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
85+
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
8486
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
8587
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
8688
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
@@ -190,6 +192,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
190192
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
191193
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
192194
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
195+
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b h1:qh4f65QIVFjq9eBURLEYWqaEXmOyqdUyiBSgaXWccWk=
196+
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
193197
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
194198
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
195199
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

internal/pkg/devcontainers/dockerutils.go

Lines changed: 110 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package devcontainers
33
import (
44
"bufio"
55
"bytes"
6+
"encoding/json"
67
"fmt"
78
"os"
89
"os/exec"
910
"path"
1011
"path/filepath"
12+
"sort"
1113
"strings"
1214

1315
"github.com/stuartleeks/devcontainer-cli/internal/pkg/terminal"
@@ -67,10 +69,17 @@ func ListDevcontainers() ([]DevcontainerInfo, error) {
6769
name = name[index+1:]
6870
}
6971
}
72+
localPath := parts[listPartLocalFolder]
73+
if strings.HasPrefix(localPath, "\\\\wsl$") && wsl.IsWsl() {
74+
localPath, err = wsl.ConvertWindowsPathToWslPath(localPath)
75+
if err != nil {
76+
return []DevcontainerInfo{}, fmt.Errorf("error converting path: %s", err)
77+
}
78+
}
7079
devcontainer := DevcontainerInfo{
7180
ContainerID: parts[listPartID],
7281
ContainerName: parts[listPartContainerName],
73-
LocalFolderPath: parts[listPartLocalFolder],
82+
LocalFolderPath: localPath,
7483
DevcontainerName: name,
7584
}
7685
devcontainers = append(devcontainers, devcontainer)
@@ -92,87 +101,91 @@ func GetLocalFolderFromDevContainer(containerIDOrName string) (string, error) {
92101
return strings.TrimSpace(string(output)), nil
93102
}
94103

95-
// GetContainerIDForPath returns the ID of the running container that matches the path
96-
func GetContainerIDForPath(devcontainerPath string) (string, error) {
97-
if devcontainerPath == "" {
98-
devcontainerPath = "."
99-
}
100-
absPath, err := filepath.Abs(devcontainerPath)
104+
// DockerMount represents mount info from Docker output
105+
type DockerMount struct {
106+
Source string `json:"Source"`
107+
Destination string `json:"Destination"`
108+
}
109+
110+
// GetSourceMountFolderFromDevContainer inspects the specified container and returns the DockerMount for the source mount
111+
func GetSourceMountFolderFromDevContainer(containerIDOrName string) (DockerMount, error) {
112+
localPath, err := GetLocalFolderFromDevContainer(containerIDOrName)
101113
if err != nil {
102-
return "", fmt.Errorf("Error handling path %q: %s", devcontainerPath, err)
114+
return DockerMount{}, err
103115
}
104116

105-
windowsPath := absPath
106-
if wsl.IsWsl() {
107-
var err error
108-
windowsPath, err = wsl.ConvertWslPathToWindowsPath(windowsPath)
117+
if strings.HasPrefix(localPath, "\\\\wsl$") && wsl.IsWsl() {
118+
localPath, err = wsl.ConvertWindowsPathToWslPath(localPath)
109119
if err != nil {
110-
return "", err
120+
return DockerMount{}, fmt.Errorf("error converting path: %s", err)
111121
}
112122
}
113123

114-
devcontainerList, err := ListDevcontainers()
124+
cmd := exec.Command("docker", "inspect", containerIDOrName, "--format", fmt.Sprintf("{{ range .Mounts }}{{if eq .Source \"%s\"}}{{json .}}{{end}}{{end}}", localPath))
125+
126+
output, err := cmd.Output()
115127
if err != nil {
116-
return "", fmt.Errorf("Error getting container list: %s", err)
128+
return DockerMount{}, fmt.Errorf("Failed to read docker stdout: %v", err)
117129
}
118130

119-
for _, devcontainer := range devcontainerList {
120-
if devcontainer.LocalFolderPath == windowsPath {
121-
containerID := devcontainer.ContainerID
122-
return containerID, nil
123-
}
131+
var mount DockerMount
132+
err = json.Unmarshal(output, &mount)
133+
if err != nil {
134+
return DockerMount{}, err
124135
}
125-
return "", fmt.Errorf("Could not find running container for path %q", devcontainerPath)
136+
137+
return mount, nil
126138
}
127139

128-
func ExecInDevContainer(containerIDOrName string, workDir string, args []string) error {
140+
type byLocalPathLength []DevcontainerInfo
129141

130-
statusWriter := &terminal.UpdatingStatusWriter{}
142+
func (s byLocalPathLength) Len() int {
143+
return len(s)
144+
}
145+
func (s byLocalPathLength) Swap(i, j int) {
146+
s[i], s[j] = s[j], s[i]
147+
}
148+
func (s byLocalPathLength) Less(i, j int) bool {
149+
return len(s[i].LocalFolderPath) < len(s[j].LocalFolderPath)
150+
}
131151

132-
containerID := ""
133-
devcontainerList, err := ListDevcontainers()
152+
// GetClosestPathMatchForPath returns the dev container with the closes match to the specified path
153+
func GetClosestPathMatchForPath(devContainers []DevcontainerInfo, devcontainerPath string) (DevcontainerInfo, error) {
154+
if devcontainerPath == "" {
155+
devcontainerPath = "."
156+
}
157+
absPath, err := filepath.Abs(devcontainerPath)
134158
if err != nil {
135-
return err
159+
return DevcontainerInfo{}, fmt.Errorf("Error handling path %q: %s", devcontainerPath, err)
136160
}
137161

138-
// Get container ID
139-
for _, devcontainer := range devcontainerList {
140-
if devcontainer.ContainerName == containerIDOrName ||
141-
devcontainer.DevcontainerName == containerIDOrName ||
142-
devcontainer.ContainerID == containerIDOrName {
143-
containerID = devcontainer.ContainerID
144-
break
162+
matchingPaths := byLocalPathLength{}
163+
for _, devcontainer := range devContainers {
164+
// Treat as match if the specified path is within the devcontainer path
165+
if strings.HasPrefix(absPath, devcontainer.LocalFolderPath) {
166+
matchingPaths = append(matchingPaths, devcontainer)
145167
}
146168
}
147-
148-
if containerID == "" {
149-
return fmt.Errorf("Failed to find a matching (running) dev container for %q", containerIDOrName)
169+
if len(matchingPaths) == 0 {
170+
return DevcontainerInfo{}, fmt.Errorf("Could not find running container for path %q", devcontainerPath)
150171
}
172+
// return longest prefix match
173+
sort.Sort(matchingPaths)
174+
return matchingPaths[len(matchingPaths)-1], nil
175+
}
151176

152-
localPath, err := GetLocalFolderFromDevContainer(containerID)
153-
if err != nil {
154-
return err
155-
}
177+
func ExecInDevContainer(containerID string, workDir string, args []string) error {
156178

157-
statusWriter.Printf("Getting mount path")
158-
if workDir == "" {
159-
workDir, err = GetWorkspaceMountPath(localPath)
160-
if err != nil {
161-
return err
162-
}
163-
}
179+
statusWriter := &terminal.UpdatingStatusWriter{}
164180

165-
wslPath := localPath
166-
if strings.HasPrefix(wslPath, "\\\\wsl$") && wsl.IsWsl() {
167-
statusWriter.Printf("Converting to WSL path")
168-
wslPath, err = wsl.ConvertWindowsPathToWslPath(wslPath)
169-
if err != nil {
170-
return fmt.Errorf("error converting path: %s", err)
171-
}
181+
sourceMount, err := GetSourceMountFolderFromDevContainer(containerID)
182+
if err != nil {
183+
return err
172184
}
185+
localPath := sourceMount.Source
173186

174187
statusWriter.Printf("Getting user name")
175-
devcontainerJSONPath := path.Join(wslPath, ".devcontainer/devcontainer.json")
188+
devcontainerJSONPath := path.Join(localPath, ".devcontainer/devcontainer.json")
176189
userName, err := GetDevContainerUserName(devcontainerJSONPath)
177190
if err != nil {
178191
return err
@@ -217,6 +230,35 @@ func ExecInDevContainer(containerIDOrName string, workDir string, args []string)
217230
fmt.Println("Continuing without setting VSCODE_IPC_HOOK_CLI...")
218231
}
219232

233+
mountPath := sourceMount.Destination
234+
if workDir == "" {
235+
workDir = mountPath
236+
} else if !filepath.IsAbs(workDir) {
237+
238+
// Convert to absolute (local) path
239+
// This takes into account current directory (potentially within the dev container path)
240+
// We'll convert local to container path below
241+
workDir, err = filepath.Abs(workDir)
242+
if err != nil {
243+
return err
244+
}
245+
}
246+
247+
statusWriter.Printf("Test container path")
248+
containerPathExists, err := testContainerPathExists(containerID, workDir)
249+
if err != nil {
250+
return fmt.Errorf("error checking container path: %s", err)
251+
}
252+
if !containerPathExists {
253+
// path not found - try converting from local path
254+
// ? Should we check here that the workDir has localPath as a prefix?
255+
devContainerRelativePath, err := filepath.Rel(localPath, workDir)
256+
if err != nil {
257+
return fmt.Errorf("error getting path relative to mount dir: %s", err)
258+
}
259+
workDir = filepath.Join(mountPath, devContainerRelativePath)
260+
}
261+
220262
statusWriter.Printf("Starting exec session\n") // newline to put container shell at start of line
221263
dockerArgs := []string{"exec", "-it", "--workdir", workDir}
222264
if userName != "" {
@@ -306,3 +348,16 @@ func getContainerEnvVar(containerID string, varName string) (string, error) {
306348

307349
return string(buf), nil
308350
}
351+
352+
func testContainerPathExists(containerID string, path string) (bool, error) {
353+
dockerArgs := []string{"exec", containerID, "bash", "-c", fmt.Sprintf("[[ -d %s ]]; echo $?", path)}
354+
dockerCmd := exec.Command("docker", dockerArgs...)
355+
buf, err := dockerCmd.CombinedOutput()
356+
if err != nil {
357+
errMessage := string(buf)
358+
return false, fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage))
359+
}
360+
361+
response := strings.TrimSpace(string(buf))
362+
return response == "0", nil
363+
}

0 commit comments

Comments
 (0)