diff --git a/.github/workflows/build-bake.yaml b/.github/workflows/build-bake.yaml index c1c67e11a..3159e343a 100644 --- a/.github/workflows/build-bake.yaml +++ b/.github/workflows/build-bake.yaml @@ -290,6 +290,112 @@ jobs: snyk-org: ${{ secrets.SNYK_ORG }} snyk-token: '${{ secrets.SNYK_TOKEN }}' + workbench-session: + needs: [setup] + name: Workbench Session + runs-on: ubuntu-latest-8x + + concurrency: + group: bake-workbench-session-${{ github.ref }} + cancel-in-progress: true + + env: + target: workbench-session + GIT_SHA: ${{ needs.setup.outputs.GIT_SHA }} + + steps: + - name: Checkout + if: github.event_name == 'schedule' + uses: actions/checkout@v4 + with: + ref: 'main' + + - name: Checkout + if: github.event_name != 'schedule' + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + id: setup-buildx + with: + buildkitd-config: ./share/buildkitd.toml + + - name: Build, Test, and Push + uses: ./.github/actions/bake-test-push + with: + target: ${{ env.target }} + push-image: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + gcp-json: '${{ secrets.GCP_ARTIFACT_REGISTRY_JSON }}' + snyk-org: ${{ secrets.SNYK_ORG }} + snyk-token: '${{ secrets.SNYK_TOKEN }}' + + workbench-session-init: + if: false # TODO: Re-enable this job after next release + needs: [setup] + name: Workbench Session Init + runs-on: ubuntu-latest-8x + + concurrency: + group: bake-workbench-session-init-${{ github.ref }} + cancel-in-progress: true + + env: + target: workbench-session-init + GIT_SHA: ${{ needs.setup.outputs.GIT_SHA }} + + steps: + - name: Checkout + if: github.event_name == 'schedule' + uses: actions/checkout@v4 + with: + ref: 'main' + + - name: Checkout + if: github.event_name != 'schedule' + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + id: setup-buildx + with: + buildkitd-config: ./share/buildkitd.toml + + - name: Set up Just + uses: extractions/setup-just@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: | + pip install requests + + - name: Get Version + id: get-version + run: | + WORKBENCH_SESSION_INIT_VERSION=$(just -f ci.Justfile get-version workbench --type=daily --local) + echo "WORKBENCH_SESSION_INIT_VERSION=$WORKBENCH_SESSION_INIT_VERSION" >> $GITHUB_OUTPUT + + - name: Build, Test, and Push + env: + WORKBENCH_SESSION_INIT_VERSION: ${{ steps.get-version.outputs.WORKBENCH_SESSION_INIT_VERSION }} + uses: ./.github/actions/bake-test-push + with: + target: ${{ env.target }} + push-image: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + gcp-json: '${{ secrets.GCP_ARTIFACT_REGISTRY_JSON }}' + snyk-org: ${{ secrets.SNYK_ORG }} + snyk-token: '${{ secrets.SNYK_TOKEN }}' + workbench: needs: [setup] name: Workbench diff --git a/.github/workflows/build-manual.yaml b/.github/workflows/build-manual.yaml index 5900804b9..e5980f890 100644 --- a/.github/workflows/build-manual.yaml +++ b/.github/workflows/build-manual.yaml @@ -141,6 +141,8 @@ jobs: product="CONNECT" elif [[ "$product" == "package-manager" ]]; then product="PACKAGE_MANAGER" + elif [[ "$product" == "workbench-session-init" ]]; then + product="WORKBENCH_SESSION_INIT" else product="WORKBENCH" fi diff --git a/Justfile b/Justfile index e960025f0..2f858d215 100644 --- a/Justfile +++ b/Justfile @@ -49,13 +49,17 @@ delete-builder: alias build := bake # just bake workbench-images bake target="default": + #!/bin/bash just -f {{justfile()}} create-builder || true + if [ -z "$WORKBENCH_SESSION_INIT_VERSION" ]; then + WORKBENCH_SESSION_INIT_VERSION=$(just -f ci.Justfile get-version workbench --type=daily --local) + fi GIT_SHA=$(git rev-parse --short HEAD) \ + WORKBENCH_SESSION_INIT_VERSION=${WORKBENCH_SESSION_INIT_VERSION} \ docker buildx bake --builder=posit-builder -f docker-bake.hcl {{target}} # just preview-bake workbench-images dev -preview-build: - just preview-bake "default" +alias preview-build := preview-bake preview-bake target branch="$(git branch --show-current)": just -f {{justfile()}} create-builder || true WORKBENCH_DAILY_VERSION=$(just -f ci.Justfile get-version workbench --type=daily --local) \ @@ -89,7 +93,12 @@ preview-plan branch="$(git branch --show-current)": # just test workbench test target="default" file="docker-bake.hcl": + #!/bin/bash + if [ -z "$WORKBENCH_SESSION_INIT_VERSION" ]; then + WORKBENCH_SESSION_INIT_VERSION=$(just -f ci.Justfile get-version workbench --type=daily --local) + fi GIT_SHA=$(git rev-parse --short HEAD) \ + WORKBENCH_SESSION_INIT_VERSION=${WORKBENCH_SESSION_INIT_VERSION} \ python3 {{justfile_directory()}}/tools/test_bake_artifacts.py --target "{{target}}" --file "{{file}}" # just preview-test connect dev diff --git a/docker-bake.hcl b/docker-bake.hcl index 2eb84c844..d38804565 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -11,6 +11,10 @@ variable WORKBENCH_VERSION { default = "2024.09.1+394.pro7" } +variable WORKBENCH_SESSION_INIT_VERSION { + default = "" +} + variable DRIVERS_VERSION { default = "2024.03.0" } @@ -181,6 +185,18 @@ variable WORKBENCH_BUILD_MATRIX { } } +variable WORKBENCH_SESSION_MATRIX { + default = PRO_BUILD_MATRIX +} + +variable WORKBENCH_SESSION_INIT_BUILD_MATRIX { + default = { + builds = [ + {os = "ubuntu2204"}, + ] + } +} + variable WORKBENCH_GOOGLE_CLOUD_WORKSTATION_BUILD_MATRIX { default = { builds = [ @@ -209,6 +225,8 @@ group "default" { "package-manager", "r-session-complete", "workbench", + "workbench-session", + "workbench-session-init", ] } @@ -428,6 +446,31 @@ target "r-session-complete" { } } +target "workbench-session" { + inherits = ["base"] + name = "workbench-session-${builds.os}-r${replace(builds.r_primary, ".", "-")}_${replace(builds.r_alternate, ".", "-")}-py${replace(builds.py_primary, ".", "-")}_${replace(builds.py_alternate, ".", "-")}" + + tags = [ + "ghcr.io/rstudio/workbench-session:${builds.os}-r${builds.r_primary}_${builds.r_alternate}-py${builds.py_primary}_${builds.py_alternate}", + "docker.io/rstudio/workbench-session:${builds.os}-r${builds.r_primary}_${builds.r_alternate}-py${builds.py_primary}_${builds.py_alternate}", + ] + + dockerfile = "Dockerfile.${builds.os}" + context = "workbench-session" + contexts = { + product-base-pro = "target:product-base-pro-${builds.os}-r${replace(builds.r_primary, ".", "-")}_${replace(builds.r_alternate, ".", "-")}-py${replace(builds.py_primary, ".", "-")}_${replace(builds.py_alternate, ".", "-")}" + } + + matrix = WORKBENCH_SESSION_MATRIX + args = { + R_VERSION = builds.r_primary + R_VERSION_ALT = builds.r_alternate + PYTHON_VERSION = builds.py_primary + PYTHON_VERSION_ALT = builds.py_alternate + JUPYTERLAB_VERSION = DEFAULT_JUPYTERLAB_VERSION + } +} + target "workbench" { inherits = ["base"] @@ -453,6 +496,23 @@ target "workbench" { } } +target "workbench-session-init" { + inherits = ["base"] + target = "build" + + name = "workbench-session-init-${builds.os}-${replace(tag_safe_version(WORKBENCH_SESSION_INIT_VERSION), ".", "-")}" + tags = get_tags(builds.os, "workbench-session-init", WORKBENCH_SESSION_INIT_VERSION) + + dockerfile = "Dockerfile.${builds.os}" + context = "workbench-session-init" + + matrix = WORKBENCH_SESSION_INIT_BUILD_MATRIX + + args = { + RSW_VERSION = WORKBENCH_SESSION_INIT_VERSION + } +} + ### Workbench for Google Cloud Workstations targets ### target "workbench-for-google-cloud-workstations" { inherits = ["base"] diff --git a/workbench-session-init/Dockerfile.ubuntu2204 b/workbench-session-init/Dockerfile.ubuntu2204 index 2d0bbc449..7b8ab4f7a 100644 --- a/workbench-session-init/Dockerfile.ubuntu2204 +++ b/workbench-session-init/Dockerfile.ubuntu2204 @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 AS build +FROM ubuntu:22.04 AS builder # Install required tools: # - ca-certificates installs necessary certificates to use cURL with HTTPS websites @@ -9,17 +9,44 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* ARG RSW_VERSION=2024.09.1+394.pro7 +ARG GO_VERSION=1.22.2 +# Download the RStudio Workbench session components and install Go SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN mkdir -p /pwb-staging && \ RSW_VERSION_URL=$(echo -n "${RSW_VERSION}" | sed 's/+/-/g') && \ - echo "Downloading https://s3.amazonaws.com/rstudio-ide-build/session/multi/x86_64/rsp-session-multi-linux-${RSW_VERSION_URL}-x86_64.tar.gz" && \ 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-${RSW_VERSION_URL}-x86_64.tar.gz" && \ mkdir -p /opt/session-components && \ - tar -C /opt/session-components -xf /pwb-staging/rsp-session-multi-linux.tar.gz && \ - chmod -R 755 /opt/session-components && \ + tar -C /opt/session-components -xpf /pwb-staging/rsp-session-multi-linux.tar.gz && \ + chmod 755 /opt/session-components && \ + curl -fsSL -o /pwb-staging/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" && \ + tar -C /usr/local -xf /pwb-staging/go.tar.gz && \ rm -rf /pwb-staging -COPY --chmod=755 run.sh /usr/local/bin/run.sh +# Add Go binary to PATH +ENV PATH="/usr/local/go/bin:$PATH" + +# Set the Go workspace +WORKDIR /workspace + +# Copy the Go source code and download dependencies +COPY entrypoint/go.mod entrypoint/go.sum ./ +RUN go mod download + +# Copy the Go source code and build the binary +COPY entrypoint/main.go . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags '-s -w' -o entrypoint main.go + +# Create the final image +FROM ubuntu:22.04 AS build + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* + +# Copy the compiled Go binary and session components from the builder stage +COPY --from=builder --chmod=755 /workspace/entrypoint /usr/local/bin/entrypoint +COPY --from=builder --chmod=755 /opt/session-components /opt/session-components -ENTRYPOINT ["/usr/local/bin/run.sh"] +ENTRYPOINT ["/usr/local/bin/entrypoint"] diff --git a/workbench-session-init/README.md b/workbench-session-init/README.md index ecae3c923..76c82b74d 100644 --- a/workbench-session-init/README.md +++ b/workbench-session-init/README.md @@ -7,8 +7,8 @@ This directory contains a Dockerfile and script that will create an init contain * Maintained by: [the Posit Docker team](https://github.com/rstudio/rstudio-docker-products) * Where to get help: [our Github Issues page](https://github.com/rstudio/rstudio-docker-products/issues) * Posit Workbench image: [Docker Hub](https://hub.docker.com/r/rstudio/rstudio-workbench) -* RStudio r-session-complete image: [Docker Hub](https://hub.docker.com/r/rstudio/r-session-complete) -* Workbench Session Init image (Daily/Preview): [Docker Hub](https://hub.docker.com/r/rstudio/rstudio-workbench-session-init-preview) +* Posit Workbench session image: [Docker Hub](https://hub.docker.com/r/rstudio/workbench-session) +* Posit Workbench session init image: [Docker Hub](https://hub.docker.com/r/rstudio/workbench-session-init) ## Supported tags and respective Dockerfile links @@ -16,7 +16,13 @@ This directory contains a Dockerfile and script that will create an init contain ## Building -Currently daily builds are supported. To build the image, run: +Just will build an image using a default Connect distribution. + +```console +just build +``` + +Daily builds are also supported. To build the daily image, run: ```console just preview-bake workbench-session-init-daily @@ -28,14 +34,14 @@ You can observe what gets copied by the container: ```console mkdir init -docker run --rm -v $(pwd)/init:/mnt/init rstudio/workbench-session-init-preview:workbench-session-init-jammy-2024.11.0-daily-328.pro3 +docker run --rm -v $(pwd)/init:/mnt/init rstudio/workbench-session-init:jammy-2024.11.0 # The init directory has been populated with the Workbench session runtime components. ``` You can also test using GOSS: ```console -just preview-test workbench-session-init-daily +just test workbench-session-init ``` ## Licensing diff --git a/workbench-session-init/entrypoint/go.mod b/workbench-session-init/entrypoint/go.mod new file mode 100644 index 000000000..fff4e5d37 --- /dev/null +++ b/workbench-session-init/entrypoint/go.mod @@ -0,0 +1,9 @@ +module entrypoint + +go 1.22.2 + +require ( + github.com/otiai10/copy v1.14.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/workbench-session-init/entrypoint/go.sum b/workbench-session-init/entrypoint/go.sum new file mode 100644 index 000000000..21650d9bf --- /dev/null +++ b/workbench-session-init/entrypoint/go.sum @@ -0,0 +1,6 @@ +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/workbench-session-init/entrypoint/main.go b/workbench-session-init/entrypoint/main.go new file mode 100644 index 000000000..5eb6afc06 --- /dev/null +++ b/workbench-session-init/entrypoint/main.go @@ -0,0 +1,170 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + + cp "github.com/otiai10/copy" +) + +const ( + sourceDir = "/opt/session-components" + targetDir = "/mnt/init" +) + +var ( + // Read the PWB_SESSION_TYPE environment variable + sessionType = os.Getenv("PWB_SESSION_TYPE") + + // Set the copy options. + // Preserve permissions, times, and owner. + opt = cp.Options{ + PermissionControl: cp.PerservePermission, + PreserveTimes: true, + PreserveOwner: true, + NumOfWorkers: 20, + } + + // List of dependencies common to all session types + commonDeps = []string{ + "bin/git-credential-pwb", + "bin/focal", + "bin/jammy", + "bin/noble", + "bin/opensuse15", + "bin/postback", + "bin/pwb-supervisor", + "bin/quarto", + "bin/r-ldpath", + "bin/rhel8", + "bin/rhel9", + "bin/shared-run", + "R", + "resources", + "www", + "www-symbolmaps", + } + + // Map of session-specific dependencies + sessionDeps = map[string][]string{ + "jupyter": { + "bin/jupyter-session-run", + "bin/node", + "extras", + }, + "positron": { + "bin/positron-server", + "bin/positron-session-run", + "extras", + }, + "rstudio": { + "bin/node", + "bin/rsession-run", + }, + "vscode": { + "bin/pwb-code-server", + "bin/vscode-session-run", + "extras", + }, + } +) + +func main() { + if sessionType == "" { + fmt.Println("PWB_SESSION_TYPE environment variable is not set") + os.Exit(1) + } + + programStart := time.Now() + defer func() { + elapsed := time.Since(programStart) + fmt.Printf("Program took %s\n", elapsed) + }() + + filesToCopy, err := getFilesToCopy(sessionType) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + err = validateTargetDir(targetDir) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + err = copyFiles(sourceDir, targetDir, filesToCopy) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fmt.Println("Copy operation completed.") +} + +// getFilesToCopy returns the list of files to copy based on the session type. +func getFilesToCopy(sessionType string) ([]string, error) { + files := commonDeps + if deps, ok := sessionDeps[sessionType]; ok { + files = append(files, deps...) + } else { + return nil, fmt.Errorf("unknown session type: %s", sessionType) + } + return files, nil +} + +// validateTargetDir checks if the target directory exists and is empty. +func validateTargetDir(targetDir string) error { + if _, err := os.Stat(targetDir); os.IsNotExist(err) { + return fmt.Errorf("cannot find the copy target %s", targetDir) + } + + isEmpty, err := isDirEmpty(targetDir) + if err != nil { + return fmt.Errorf("error checking if target directory is empty: %v", err) + } + if !isEmpty { + return fmt.Errorf("target directory %s is not empty", targetDir) + } + + return nil +} + +// isDirEmpty checks if a directory is empty. +func isDirEmpty(dir string) (bool, error) { + f, err := os.Open(dir) + if err != nil { + return false, err + } + defer f.Close() + + _, err = f.ReadDir(1) + if err == io.EOF { + return true, nil + } + return false, err +} + +// copyFiles copies the files from the source directory to the target directory. +// It uses the otiai10/copy package to copy files, with options to preserve +// permissions, times, and owner. +func copyFiles(src, dst string, filesToCopy []string) error { + fmt.Printf("Copying files from %s to %s\n", src, dst) + start := time.Now() + + for _, file := range filesToCopy { + srcPath := filepath.Join(src, file) + dstPath := filepath.Join(dst, file) + err := cp.Copy(srcPath, dstPath, opt) + if err != nil { + return fmt.Errorf("error copying %s: %v", srcPath, err) + } + } + + elapsed := time.Since(start) + fmt.Printf("Copy operation took %s\n", elapsed) + + return nil +} diff --git a/workbench-session-init/entrypoint/main_test.go b/workbench-session-init/entrypoint/main_test.go new file mode 100644 index 000000000..b16aa736e --- /dev/null +++ b/workbench-session-init/entrypoint/main_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "syscall" + "testing" +) + +func TestGetFilesToCopy(t *testing.T) { + tests := []struct { + sessionType string + expected []string + expectError bool + }{ + { + sessionType: "jupyter", + expected: append(commonDeps, sessionDeps["jupyter"]...), + expectError: false, + }, + { + sessionType: "positron", + expected: append(commonDeps, sessionDeps["positron"]...), + expectError: false, + }, + { + sessionType: "rstudio", + expected: append(commonDeps, sessionDeps["rstudio"]...), + expectError: false, + }, + { + sessionType: "vscode", + expected: append(commonDeps, sessionDeps["vscode"]...), + expectError: false, + }, + { + sessionType: "unknown", + expected: nil, + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.sessionType, func(t *testing.T) { + files, err := getFilesToCopy(test.sessionType) + if test.expectError { + if err == nil { + t.Errorf("Expected error for session type %s, but got none", test.sessionType) + } + } else { + if err != nil { + t.Errorf("Did not expect error for session type %s, but got: %v", test.sessionType, err) + } + if !reflect.DeepEqual(files, test.expected) { + t.Errorf("Files do not match for session type %s. Expected: %v, Got: %v", test.sessionType, test.expected, files) + } + } + }) + } +} + +func TestCopy(t *testing.T) { + // Create temporary source and destination directories + srcDir, err := os.MkdirTemp("", "src") + if err != nil { + t.Fatalf("Failed to create temporary source directory: %v", err) + } + defer os.RemoveAll(srcDir) + + dstDir, err := os.MkdirTemp("", "dst") + if err != nil { + t.Fatalf("Failed to create temporary destination directory: %v", err) + } + defer os.RemoveAll(dstDir) + + // Create a sample directory structure in the source directory that looks like: + // srcDir + // ├── file1.txt + // └── subdir1 + // ├── file2.txt + // └── subdir2 + // └── file3.txt + // |__ subdir3 + err = os.MkdirAll(filepath.Join(srcDir, "subdir1"), 0755) + if err != nil { + t.Fatalf("Failed to create subdir1: %v", err) + } + err = os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("file1 content"), 0644) + if err != nil { + t.Fatalf("Failed to create file1.txt: %v", err) + } + err = os.WriteFile(filepath.Join(srcDir, "subdir1", "file2.txt"), []byte("file2 content"), 0600) + if err != nil { + t.Fatalf("Failed to create file2.txt: %v", err) + } + err = os.MkdirAll(filepath.Join(srcDir, "subdir1", "subdir2"), 0755) + if err != nil { + t.Fatalf("Failed to create subdir2: %v", err) + } + err = os.WriteFile(filepath.Join(srcDir, "subdir1", "subdir2", "file3.txt"), []byte("file3 content"), 0644) + if err != nil { + t.Fatalf("Failed to create file3.txt: %v", err) + } + err = os.MkdirAll(filepath.Join(srcDir, "subdir3"), 0755) + if err != nil { + t.Fatalf("Failed to create subdir3: %v", err) + } + + // Copy the directory structure from source to destination + // exclude subdir3 + filesToCopy := []string{ + "file1.txt", + "subdir1", + } + err = copyFiles(srcDir, dstDir, filesToCopy) + if err != nil { + t.Fatalf("Failed to copy files: %v", err) + } + + // Verify that the directory structure and files are correctly copied + verifyFile(t, filepath.Join(dstDir, "file1.txt"), 0644, os.Getuid(), os.Getgid()) + verifyFile(t, filepath.Join(dstDir, "subdir1", "file2.txt"), 0600, os.Getuid(), os.Getgid()) + verifyFile(t, filepath.Join(dstDir, "subdir1", "subdir2", "file3.txt"), 0644, os.Getuid(), os.Getgid()) + // Verify that subdir3 is not copied + if _, err := os.Stat(filepath.Join(dstDir, "subdir3")); !os.IsNotExist(err) { + t.Errorf("Directory subdir3 should not have been copied") + } +} + +func verifyFile(t *testing.T, path string, mode os.FileMode, uid, gid int) { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to stat file %s: %v", path, err) + } + + if info.Mode() != mode { + t.Errorf("File %s has incorrect permissions: got %v, want %v", path, info.Mode(), mode) + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("Failed to get file ownership for %s", path) + } + + if int(stat.Uid) != uid || int(stat.Gid) != gid { + t.Errorf("File %s has incorrect ownership: got %d:%d, want %d:%d", path, stat.Uid, stat.Gid, uid, gid) + } +} diff --git a/workbench-session-init/run.sh b/workbench-session-init/run.sh deleted file mode 100644 index 9c29238e5..000000000 --- a/workbench-session-init/run.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail -set -x - -S=/opt/session-components - -# The target should exist and be an empty directory. -T=/mnt/init - -if [ ! -d "${T}" ] ; then - echo "Cannot find the copy target ${T}" - exit 1 -fi - -echo "Copying files from /session-components to /mnt/init" -time cp -r $S/* $T diff --git a/workbench-session-init/test/goss.yaml b/workbench-session-init/test/goss.yaml index 7c9ab9e76..7b384ffdd 100644 --- a/workbench-session-init/test/goss.yaml +++ b/workbench-session-init/test/goss.yaml @@ -7,7 +7,7 @@ file: exists: true mode: "0755" filetype: directory - /usr/local/bin/run.sh: + /usr/local/bin/entrypoint: exists: true filetype: file mode: "0755" diff --git a/workbench-session/.env b/workbench-session/.env new file mode 100644 index 000000000..1c5333d80 --- /dev/null +++ b/workbench-session/.env @@ -0,0 +1,3 @@ +R_VERSION=4.1.0 +PYTHON_VERSION=3.9.5 +DRIVERS_VERSION=2024.03.0-1 diff --git a/workbench-session/Dockerfile.ubuntu2204 b/workbench-session/Dockerfile.ubuntu2204 new file mode 100644 index 000000000..97eb4a85b --- /dev/null +++ b/workbench-session/Dockerfile.ubuntu2204 @@ -0,0 +1,47 @@ +FROM product-base-pro as build + +ARG DEBIAN_FRONTEND=noninteractive +ARG R_VERSION=4.4.0 +ARG R_VERSION_ALT=4.3.3 +ARG PYTHON_VERSION=3.9.17 +ARG PYTHON_VERSION_ALT=3.8.17 +ARG JUPYTERLAB_VERSION=3.6.5 +ARG SCRIPTS_DIR=/opt/positscripts + +ENV WORKBENCH_JUPYTER_PATH=/usr/local/bin/jupyter + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + krb5-user \ + libcurl4-gnutls-dev \ + libuser \ + libuser1-dev \ + libpq-dev \ + rrdtool \ + subversion \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Add Jupyter, Python, and Quarto to the PATH +ENV PATH="/opt/python/jupyter/bin:/opt/python/bin:/usr/lib/rstudio-server/bin/quarto/bin:${PATH}" + +RUN /opt/python/"${PYTHON_VERSION}"/bin/python -m venv /opt/python/jupyter \ + && /opt/python/jupyter/bin/python -m pip install --upgrade pip \ + && /opt/python/jupyter/bin/python -m pip install --upgrade setuptools \ + && /opt/python/jupyter/bin/python -m pip install \ + jupyterlab~=4.2.4 \ + notebook \ + pwb_jupyterlab~=1.0 \ + && ln -s /opt/python/jupyter/bin/jupyter /usr/local/bin/jupyter \ + && /opt/python/${PYTHON_VERSION}/bin/python -m pip install ipykernel \ + && /opt/python/${PYTHON_VERSION_ALT}/bin/python -m pip install ipykernel \ + && /opt/python/${PYTHON_VERSION}/bin/python -m ipykernel install --name py${PYTHON_VERSION} --display-name "Python ${PYTHON_VERSION}" \ + && /opt/python/${PYTHON_VERSION_ALT}/bin/python -m ipykernel install --name py${PYTHON_VERSION_ALT} --display-name "Python ${PYTHON_VERSION_ALT}" \ + && /opt/python/jupyter/bin/python -m pip cache purge + +COPY vscode.extensions.conf /etc/rstudio/vscode.extensions.conf +COPY positron.extensions.conf /etc/rstudio/positron.extensions.conf + +EXPOSE 8788/tcp diff --git a/workbench-session/NEWS.md b/workbench-session/NEWS.md new file mode 100644 index 000000000..e76e114bd --- /dev/null +++ b/workbench-session/NEWS.md @@ -0,0 +1,4 @@ +# 2024-11-15 + +- Add NEWS.md +- Add daily builds diff --git a/workbench-session/README.md b/workbench-session/README.md new file mode 100644 index 000000000..358752111 --- /dev/null +++ b/workbench-session/README.md @@ -0,0 +1,78 @@ +# Quick reference + +* Maintained by: [the Posit Docker team](https://github.com/rstudio/rstudio-docker-products) +* Where to get help: [our Github Issues page](https://github.com/rstudio/rstudio-docker-products/issues) +* Posit Workbench image: [Docker Hub](https://hub.docker.com/r/rstudio/rstudio-workbench) +* Posit Workbench session image: [Docker Hub](https://hub.docker.com/r/rstudio/workbench-session) +* Posit Workbench session init image: [Docker Hub](https://hub.docker.com/r/rstudio/workbench-session-init) + +# Supported tags and respective Dockerfile links + +* [`ubuntu2204-r4.4.1_4.3.3-py3.12.6_3.11.10`, `ubuntu2204-r4.4.1_4.3.3-py3.11.10_3.10.15`, `ubuntu2204-r4.4.0_4.3.3-py3.12.1_3.11.7`](https://github.com/rstudio/rstudio-docker-products/blob/main/workbench-session/Dockerfile.ubuntu2204) + +# What are the r-session-complete images? + +Images for R and Python sessions and jobs to be used RStudio Workbench, Launcher, and Kubernetes. + +# Notice for support + +1. This image may introduce **BREAKING** changes; as such we recommend: + - Avoid using the `{operating-system}` tags to avoid unexpected version changes, and + - Always read through the [NEWS](./NEWS.md) to understand the changes before updating. +1. Outdated images will be removed periodically from DockerHub as product version updates are made. Please make plans to + update at times or use your own build of the images. +1. These images are meant as a starting point for your needs. Consider creating a fork of this repo, where you can + continue to merge in changes we make while having your own security scanning, base OS in use, or other custom + changes. We + provide [instructions for how to build and use](#how-to-use-these-docker-images) + for these cases. +1. **Security Note:** These images are provided AS IS based on the build environment at the time their product version was released/updated. They should be reviewed and updated before production use. If your organization has a specific set of security requirements related to CVE/Vulnerability severity levels, you should plan to use the [instructions for building](https://github.com/rstudio/rstudio-docker-products#instructions-for-building) to clone this repository, and rebuild these images to your specific internal security standards. + +# How to use these images + +The Docker images built from these Dockerfiles are intended to be used for R and +Jupyter sessions and jobs with Posit Workbench (PWB), Launcher, and +Kubernetes. + +Note: These Docker images are not equipped or intended to be used to run Posit +Workbench within a Docker container. Visit the +[rstudio/rstudio-worbench Docker Hub page](https://hub.docker.com/r/rstudio/rstudio-workbench) +for images built for that purpose. + +Note: These images do not include the Posit Workbench Session Components. To use these images with Posit Workbench, the [session init container](https://hub.docker.com/r/rstudio/workbench-session-init) must be enabled within the Posit Workbench configuration. For more information, refer to the [Posit Workbench documentation](https://docs.rstudio.com/ide/server-pro/launcher/). + +For more information about Posit Workbench and Launcher, refer to the +[Launcher Overview](https://solutions.rstudio.com/launcher/overview/) on the +RStudio Solutions website. + +For more information about how to use these images with RStudio Workbench and +Launcher, refer to the RStudio support article on [Using Docker images with +RStudio Workbench, Launcher, and Kubernetes](https://support.rstudio.com/hc/en-us/articles/360019253393-Using-Docker-images-with-RStudio-Server-Pro-Launcher-and-Kubernetes). + +We provide simple ways to extend and build the Dockerfiles. After you have cloned the repo, you can create your own containers fairly simply with the provided Justfile. + +## Overview + +Built images are available from the +[rstudio/workbench-session](https://hub.docker.com/r/rstudio/workbench-session) +repository on Docker Hub. + +These images include the following layers: + +* Base OS +* System packages required for R, R packages, and RStudio Professional Drivers +* Two versions of R +* Two versions of Python +* Jupyter Notebooks, JupyterLab, and RSW/RSC notebook extensions +* RStudio Professional Drivers + +# Licensing + +The license associated with the RStudio Docker Products repository is located [in LICENSE.md](https://github.com/rstudio/rstudio-docker-products/blob/main/LICENSE.md). + +As is the case with all container images, the images themselves also contain other software which may be under other +licenses (i.e. bash, linux, system libraries, etc., along with any other direct or indirect dependencies of the primary +software being contained). + +It is an image user's responsibility to ensure that use of this image (and any of its dependent layers) complies with +all relevant licenses for the software contained in the image. diff --git a/workbench-session/docker-compose.test.yml b/workbench-session/docker-compose.test.yml new file mode 100644 index 000000000..ab22c81a4 --- /dev/null +++ b/workbench-session/docker-compose.test.yml @@ -0,0 +1,15 @@ +version: '2.3' +services: + + sut: + image: $IMAGE_NAME + command: /run_tests.sh + entrypoint: [] + environment: + # uses .env by default + - R_VERSION + - PYTHON_VERSION + volumes: + - "./test/run_tests.sh:/run_tests.sh" + - "./test/goss.yaml:/tmp/goss.yaml" + - "./test/goss_vars.yaml:/tmp/goss_vars.yaml" diff --git a/workbench-session/positron.extensions.conf b/workbench-session/positron.extensions.conf new file mode 100644 index 000000000..42191cc92 --- /dev/null +++ b/workbench-session/positron.extensions.conf @@ -0,0 +1,2 @@ +posit.shiny +posit.publisher diff --git a/workbench-session/test/goss.yaml b/workbench-session/test/goss.yaml new file mode 100644 index 000000000..f831c5ba2 --- /dev/null +++ b/workbench-session/test/goss.yaml @@ -0,0 +1,50 @@ +file: + /usr/local/bin/jupyter: + exists: true + /opt/rstudio-drivers: + exists: true + filetype: directory + /var/lib/rstudio-server/r-versions: + exists: false + /etc/rstudio/vscode.extensions.conf: + exists: true + /etc/rstudio/positron.extensions.conf: + exists: true + +command: + "echo '{ \"cells\": [], \"metadata\": {}, \"nbformat\": 4, \"nbformat_minor\": 2}' | /opt/python/jupyter/bin/jupyter nbconvert --to notebook --stdin --stdout": + title: jupyter_works + timeout: 60000 + exit-status: 0 + +# Ensure correct R version + "/opt/R/{{.Env.R_VERSION}}/bin/R --version": + title: r_version_match + exit-status: 0 + stdout: [ + "{{.Env.R_VERSION}}" + ] + +# Ensure correct python version + "/opt/python/{{.Env.PYTHON_VERSION}}/bin/python3 --version": + title: python_version_matches + exit-status: 0 + stdout: [ + "{{ .Env.PYTHON_VERSION }}" + ] + + "python3 --version": + title: python_in_path_var + exit-status: 0 + stdout: [ + "{{ .Env.PYTHON_VERSION }}" + ] + + "jupyter --version": + title: jupyter_in_path_var + timeout: 60000 + exit-status: 0 + + "pip --version": + title: pip_installed + exit-status: 0 diff --git a/workbench-session/test/run_tests.sh b/workbench-session/test/run_tests.sh new file mode 100755 index 000000000..8aff0f845 --- /dev/null +++ b/workbench-session/test/run_tests.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +GOSS_FILE=${GOSS_FILE:-/test/goss.yaml} +GOSS_VERSION=${GOSS_VERSION:-0.4.6} +GOSS_MAX_CONCURRENT=${GOSS_MAX_CONCURRENT:-50} + +if [ -f /etc/debian_version ]; then + OS="ubuntu" +else + echo "OS not supported. Exiting" + exit 1 +fi + +# install goss to tmp location and make executable +curl -fsSL https://github.com/aelsabbahy/goss/releases/download/v$GOSS_VERSION/goss-linux-amd64 -o /tmp/goss \ + && chmod +x /tmp/goss \ + && GOSS=/tmp/goss + +OS=$OS GOSS_FILE=$GOSS_FILE $GOSS v --format documentation --max-concurrent $GOSS_MAX_CONCURRENT diff --git a/workbench-session/vscode.extensions.conf b/workbench-session/vscode.extensions.conf new file mode 100644 index 000000000..80e04b95c --- /dev/null +++ b/workbench-session/vscode.extensions.conf @@ -0,0 +1,5 @@ +quarto.quarto +REditorSupport.r@2.8.2 +ms-python.python +posit.shiny +ms-toolsai.jupyter