Skip to content

Commit d72c36e

Browse files
authored
Merge pull request #7 from posit-dev/add-workbench-session-init-images
Add `workbench-session-init` images
2 parents 1e72cc2 + b422751 commit d72c36e

File tree

19 files changed

+1523
-0
lines changed

19 files changed

+1523
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
FROM docker.io/library/golang:1.22.2 AS builder
2+
3+
ARG DEBIAN_FRONTEND=noninteractive
4+
ARG PTI_VERSION="0.0.3"
5+
ARG PTI_NO_COLOR="true"
6+
ARG PTI_LOG_FORMAT="json"
7+
ARG WORKBENCH_VERSION="2024.12.0+467.pro1"
8+
9+
### Install Posit Tool Installer ###
10+
# FIXME(ianpittwood): Uncomment when PTI is available as a public repository
11+
#ADD --chmod=0755 \
12+
# https://github.com/posit-dev/pti/releases/download/v${PTI_VERSION}/pti_${PTI_VERSION}_linux_amd64 \
13+
# /usr/local/bin/pti
14+
# FIXME(ianpittwood): This is a shim to get around Github private repo authentication. ADD is a better solution once
15+
# pti is available publicly.
16+
COPY --chmod=0755 ./tools/pti /usr/local/bin/pti
17+
18+
### Initialize PTI in container ###
19+
RUN pti init \
20+
&& pti syspkg install -p curl
21+
22+
# Download the RStudio Workbench session components and install Go
23+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
24+
RUN mkdir -p /pwb-staging && \
25+
WORKBENCH_VERSION_URL=$(echo -n "${WORKBENCH_VERSION}" | sed 's/+/-/g') && \
26+
curl -fsSL -o /pwb-staging/rsp-session-multi-linux.tar.gz "https://s3.amazonaws.com/rstudio-ide-build/session/multi/x86_64/rsp-session-multi-linux-${WORKBENCH_VERSION_URL}-x86_64.tar.gz" && \
27+
mkdir -p /opt/session-components && \
28+
tar -C /opt/session-components -xpf /pwb-staging/rsp-session-multi-linux.tar.gz && \
29+
chmod 755 /opt/session-components && \
30+
rm -rf /pwb-staging
31+
32+
# Set the Go workspace
33+
WORKDIR /workspace
34+
35+
# Copy the Go source code and download dependencies
36+
COPY workbench-session-init/2024.12.0+467.pro1/entrypoint/go.mod workbench-session-init/2024.12.0+467.pro1/entrypoint/go.sum ./
37+
RUN go mod download
38+
39+
# Copy the Go source code and build the binary
40+
COPY workbench-session-init/2024.12.0+467.pro1/entrypoint/main.go .
41+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags '-s -w' -o entrypoint main.go
42+
43+
# Create the final image
44+
FROM docker.io/library/ubuntu:22.04 AS build
45+
LABEL org.opencontainers.image.base.name="docker.io/library/ubuntu:22.04"
46+
47+
ARG DEBIAN_FRONTEND=noninteractive
48+
COPY --from=builder --chmod=755 /usr/local/bin/pti /usr/local/bin/pti
49+
50+
RUN pti init \
51+
&& pti syspkg install -p curl
52+
53+
# Copy the compiled Go binary and session components from the builder stage
54+
COPY --from=builder --chmod=755 /workspace/entrypoint /usr/local/bin/entrypoint
55+
COPY --from=builder --chmod=755 /opt/session-components /opt/session-components
56+
57+
CMD ["/usr/local/bin/entrypoint"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module entrypoint
2+
3+
go 1.22.2
4+
5+
require github.com/otiai10/copy v1.14.0
6+
7+
require (
8+
golang.org/x/sync v0.3.0 // indirect
9+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
10+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
2+
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
3+
github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
4+
github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
5+
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
6+
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
7+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
8+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
cp "github.com/otiai10/copy"
11+
)
12+
13+
const (
14+
sourceDir = "/opt/session-components"
15+
targetDir = "/mnt/init"
16+
)
17+
18+
var (
19+
// Read the PWB_SESSION_TYPE environment variable
20+
sessionType = os.Getenv("PWB_SESSION_TYPE")
21+
22+
// Set the copy options.
23+
// Preserve permissions, times, and owner.
24+
opt = cp.Options{
25+
PermissionControl: cp.PerservePermission,
26+
PreserveTimes: true,
27+
PreserveOwner: true,
28+
NumOfWorkers: 20,
29+
}
30+
31+
// List of dependencies common to all session types
32+
commonDeps = []string{
33+
"bin/git-credential-pwb",
34+
"bin/focal",
35+
"bin/jammy",
36+
"bin/noble",
37+
"bin/opensuse15",
38+
"bin/postback",
39+
"bin/pwb-supervisor",
40+
"bin/quarto",
41+
"bin/r-ldpath",
42+
"bin/rhel8",
43+
"bin/rhel9",
44+
"bin/shared-run",
45+
"R",
46+
"resources",
47+
"www",
48+
"www-symbolmaps",
49+
}
50+
51+
// Map of session-specific dependencies
52+
sessionDeps = map[string][]string{
53+
"jupyter": {
54+
"bin/jupyter-session-run",
55+
"bin/node",
56+
"extras",
57+
},
58+
"positron": {
59+
"bin/positron-server",
60+
"bin/positron-session-run",
61+
"extras",
62+
},
63+
"rstudio": {
64+
"bin/node",
65+
"bin/rsession-run",
66+
},
67+
"vscode": {
68+
"bin/pwb-code-server",
69+
"bin/vscode-session-run",
70+
"extras",
71+
},
72+
}
73+
)
74+
75+
func main() {
76+
if sessionType == "" {
77+
fmt.Println("PWB_SESSION_TYPE environment variable is not set")
78+
os.Exit(1)
79+
}
80+
81+
programStart := time.Now()
82+
defer func() {
83+
elapsed := time.Since(programStart)
84+
fmt.Printf("Program took %s\n", elapsed)
85+
}()
86+
87+
filesToCopy, err := getFilesToCopy(sessionType)
88+
if err != nil {
89+
fmt.Println(err)
90+
os.Exit(1)
91+
}
92+
93+
err = validateTargetDir(targetDir)
94+
if err != nil {
95+
fmt.Println(err)
96+
os.Exit(1)
97+
}
98+
99+
err = copyFiles(sourceDir, targetDir, filesToCopy)
100+
if err != nil {
101+
fmt.Println(err)
102+
os.Exit(1)
103+
}
104+
fmt.Println("Copy operation completed.")
105+
}
106+
107+
// getFilesToCopy returns the list of files to copy based on the session type.
108+
func getFilesToCopy(sessionType string) ([]string, error) {
109+
files := commonDeps
110+
if deps, ok := sessionDeps[sessionType]; ok {
111+
files = append(files, deps...)
112+
} else {
113+
return nil, fmt.Errorf("unknown session type: %s", sessionType)
114+
}
115+
return files, nil
116+
}
117+
118+
// validateTargetDir checks if the target directory exists and is empty.
119+
func validateTargetDir(targetDir string) error {
120+
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
121+
return fmt.Errorf("cannot find the copy target %s", targetDir)
122+
}
123+
124+
isEmpty, err := isDirEmpty(targetDir)
125+
if err != nil {
126+
return fmt.Errorf("error checking if target directory is empty: %v", err)
127+
}
128+
if !isEmpty {
129+
return fmt.Errorf("target directory %s is not empty", targetDir)
130+
}
131+
132+
return nil
133+
}
134+
135+
// isDirEmpty checks if a directory is empty.
136+
func isDirEmpty(dir string) (bool, error) {
137+
f, err := os.Open(dir)
138+
if err != nil {
139+
return false, err
140+
}
141+
defer f.Close()
142+
143+
_, err = f.ReadDir(1)
144+
if err == io.EOF {
145+
return true, nil
146+
}
147+
return false, err
148+
}
149+
150+
// copyFiles copies the files from the source directory to the target directory.
151+
// It uses the otiai10/copy package to copy files, with options to preserve
152+
// permissions, times, and owner.
153+
func copyFiles(src, dst string, filesToCopy []string) error {
154+
fmt.Printf("Copying files from %s to %s\n", src, dst)
155+
start := time.Now()
156+
157+
for _, file := range filesToCopy {
158+
srcPath := filepath.Join(src, file)
159+
dstPath := filepath.Join(dst, file)
160+
err := cp.Copy(srcPath, dstPath, opt)
161+
if err != nil {
162+
return fmt.Errorf("error copying %s: %v", srcPath, err)
163+
}
164+
}
165+
166+
elapsed := time.Since(start)
167+
fmt.Printf("Copy operation took %s\n", elapsed)
168+
169+
return nil
170+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"reflect"
7+
"syscall"
8+
"testing"
9+
)
10+
11+
func TestGetFilesToCopy(t *testing.T) {
12+
tests := []struct {
13+
sessionType string
14+
expected []string
15+
expectError bool
16+
}{
17+
{
18+
sessionType: "jupyter",
19+
expected: append(commonDeps, sessionDeps["jupyter"]...),
20+
expectError: false,
21+
},
22+
{
23+
sessionType: "positron",
24+
expected: append(commonDeps, sessionDeps["positron"]...),
25+
expectError: false,
26+
},
27+
{
28+
sessionType: "rstudio",
29+
expected: append(commonDeps, sessionDeps["rstudio"]...),
30+
expectError: false,
31+
},
32+
{
33+
sessionType: "vscode",
34+
expected: append(commonDeps, sessionDeps["vscode"]...),
35+
expectError: false,
36+
},
37+
{
38+
sessionType: "unknown",
39+
expected: nil,
40+
expectError: true,
41+
},
42+
}
43+
44+
for _, test := range tests {
45+
t.Run(test.sessionType, func(t *testing.T) {
46+
files, err := getFilesToCopy(test.sessionType)
47+
if test.expectError {
48+
if err == nil {
49+
t.Errorf("Expected error for session type %s, but got none", test.sessionType)
50+
}
51+
} else {
52+
if err != nil {
53+
t.Errorf("Did not expect error for session type %s, but got: %v", test.sessionType, err)
54+
}
55+
if !reflect.DeepEqual(files, test.expected) {
56+
t.Errorf("Files do not match for session type %s. Expected: %v, Got: %v", test.sessionType, test.expected, files)
57+
}
58+
}
59+
})
60+
}
61+
}
62+
63+
func TestCopy(t *testing.T) {
64+
// Create temporary source and destination directories
65+
srcDir, err := os.MkdirTemp("", "src")
66+
if err != nil {
67+
t.Fatalf("Failed to create temporary source directory: %v", err)
68+
}
69+
defer os.RemoveAll(srcDir)
70+
71+
dstDir, err := os.MkdirTemp("", "dst")
72+
if err != nil {
73+
t.Fatalf("Failed to create temporary destination directory: %v", err)
74+
}
75+
defer os.RemoveAll(dstDir)
76+
77+
// Create a sample directory structure in the source directory that looks like:
78+
// srcDir
79+
// ├── file1.txt
80+
// └── subdir1
81+
// ├── file2.txt
82+
// └── subdir2
83+
// └── file3.txt
84+
// |__ subdir3
85+
err = os.MkdirAll(filepath.Join(srcDir, "subdir1"), 0755)
86+
if err != nil {
87+
t.Fatalf("Failed to create subdir1: %v", err)
88+
}
89+
err = os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("file1 content"), 0644)
90+
if err != nil {
91+
t.Fatalf("Failed to create file1.txt: %v", err)
92+
}
93+
err = os.WriteFile(filepath.Join(srcDir, "subdir1", "file2.txt"), []byte("file2 content"), 0600)
94+
if err != nil {
95+
t.Fatalf("Failed to create file2.txt: %v", err)
96+
}
97+
err = os.MkdirAll(filepath.Join(srcDir, "subdir1", "subdir2"), 0755)
98+
if err != nil {
99+
t.Fatalf("Failed to create subdir2: %v", err)
100+
}
101+
err = os.WriteFile(filepath.Join(srcDir, "subdir1", "subdir2", "file3.txt"), []byte("file3 content"), 0644)
102+
if err != nil {
103+
t.Fatalf("Failed to create file3.txt: %v", err)
104+
}
105+
err = os.MkdirAll(filepath.Join(srcDir, "subdir3"), 0755)
106+
if err != nil {
107+
t.Fatalf("Failed to create subdir3: %v", err)
108+
}
109+
110+
// Copy the directory structure from source to destination
111+
// exclude subdir3
112+
filesToCopy := []string{
113+
"file1.txt",
114+
"subdir1",
115+
}
116+
err = copyFiles(srcDir, dstDir, filesToCopy)
117+
if err != nil {
118+
t.Fatalf("Failed to copy files: %v", err)
119+
}
120+
121+
// Verify that the directory structure and files are correctly copied
122+
verifyFile(t, filepath.Join(dstDir, "file1.txt"), 0644, os.Getuid(), os.Getgid())
123+
verifyFile(t, filepath.Join(dstDir, "subdir1", "file2.txt"), 0600, os.Getuid(), os.Getgid())
124+
verifyFile(t, filepath.Join(dstDir, "subdir1", "subdir2", "file3.txt"), 0644, os.Getuid(), os.Getgid())
125+
// Verify that subdir3 is not copied
126+
if _, err := os.Stat(filepath.Join(dstDir, "subdir3")); !os.IsNotExist(err) {
127+
t.Errorf("Directory subdir3 should not have been copied")
128+
}
129+
}
130+
131+
func verifyFile(t *testing.T, path string, mode os.FileMode, uid, gid int) {
132+
info, err := os.Stat(path)
133+
if err != nil {
134+
t.Fatalf("Failed to stat file %s: %v", path, err)
135+
}
136+
137+
if info.Mode() != mode {
138+
t.Errorf("File %s has incorrect permissions: got %v, want %v", path, info.Mode(), mode)
139+
}
140+
141+
stat, ok := info.Sys().(*syscall.Stat_t)
142+
if !ok {
143+
t.Fatalf("Failed to get file ownership for %s", path)
144+
}
145+
146+
if int(stat.Uid) != uid || int(stat.Gid) != gid {
147+
t.Errorf("File %s has incorrect ownership: got %d:%d, want %d:%d", path, stat.Uid, stat.Gid, uid, gid)
148+
}
149+
}

0 commit comments

Comments
 (0)