From 5466f444c7864932d9dbe5506adbe154f7e00eeb Mon Sep 17 00:00:00 2001
From: aapdo
`dguailab/decs:260201` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** 의도되지 않은 MOTD 출력 방지 버그 수정 |
-| `dguailab/decs:251023` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(이전 안정 버전)** Jupyter Notebook 버전 변경으로 인한 오류 해결 버전 |
-| `dguailab/decs:251002` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | TensorFlow 2.18.0 업그레이드, 최신 GPU 환경 지원 |
-| `dguailab/decs:250926` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | **(이전 안정 버전)** TensorFlow 2.13.0 기반의 안정화 버전 |
+| `dguailab/decs:latest`
`dguailab/decs:260501` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** Xfce + TigerVNC + noVNC 기반 브라우저 GUI 접속 지원 추가 |
+| `dguailab/decs:260427` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 기존 사용자 컨테이너 변경 시 `~/.bashrc`의 conda initialize 블록 갱신, 경고 주석 영문화 및 인덴트 수정 |
+| `dguailab/decs:260403` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 계정/권한 검증 강화, config-server 주입 기반 sudo 권한 축소, Jupyter 및 컨테이너 유지 프로세스 비-root 실행, gosu 추가 |
+| `dguailab/decs:260201` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 의도되지 않은 MOTD 출력 방지 버그 수정 |
+| `dguailab/decs:251023` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(이전 안정 버전)** Jupyter Notebook 설정 파일 생성/경로 오류 해결, 랜덤 토큰 저장 방식 반영 |
+| `dguailab/decs:251002` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | TensorFlow 2.18.0 업그레이드, 최신 GPU 환경 지원, Docker Hub 자동 빌드/푸시 워크플로 도입 |
+| `dguailab/decs:250926` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | **(이전 안정 버전)** 사용자 홈 디렉토리 소유권 설정 순서 수정, 이슈/PR 템플릿 추가 |
+| `dguailab/decs:250428` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Jupyter Lab 설정 파일을 사용자 홈의 `.jupyter` 경로에서 생성하고 사용하도록 수정 |
+| `dguailab/decs:250416` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Jupyter Notebook 설정 파일 생성 로직 수정, 10자리 랜덤 토큰 생성 및 파일 저장 추가 |
+| `dguailab/decs:250323` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | `useradd` 옵션 보완, MOTD 공지 추가, TensorFlow ASCII 출력 제거 |
+| `dguailab/decs:250309` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | ldconfig permission denied 오류 방지, MOTD/Slack 공지 반영, Conda 최신화 반영 |
+| `dguailab/decs:250218` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Anaconda3 2024.10-1 설치로 Conda 24.9.2 업그레이드 |
## ⚙️ 사용 방법
필요한 버전의 이미지를 Docker Hub에서 pull 받아 사용합니다.
@@ -28,6 +36,37 @@ docker pull dguailab/decs:251002
docker pull dguailab/decs:250926
```
+### GUI 접속(noVNC)
+`260501` 버전부터 브라우저 기반 GUI 접속을 지원합니다. GUI 접속은 기본 비활성화 상태이며, 컨테이너 생성 시 `ENABLE_VNC=true` 환경변수를 전달한 경우에만 시작됩니다.
+
+- 컨테이너 내부 noVNC 포트: `6080`
+- 컨테이너 내부 VNC 포트: `localhost:5901` 전용
+- 접속 URL: `http://서버주소:외부포트/vnc.html`
+- VNC 비밀번호 저장 위치: `/home/$USER_ID/decs_jupyter_lab/vnc_password.txt`
+
+직접 `docker run`을 사용할 때는 다음처럼 환경변수와 포트를 함께 지정합니다.
+
+```
+-e ENABLE_VNC=true -p 외부포트:6080
+```
+
+운영 스크립트(`~/uid`)에서는 `--enable_vnc true`를 주면 컨테이너 내부 `6080` 포트를 자동으로 추가하고, Docker 실행 환경변수에 `ENABLE_VNC=true`를 함께 전달합니다. FARM2 예시는 외부 포트 범위 `9100~9199` 중 하나가 `6080`에 연결되는 방식입니다.
+
+```
+--enable_vnc true
+```
+
+관련 환경변수:
+
+| 환경변수 | 기본값 | 설명 |
+| :--- | :--- | :--- |
+| `ENABLE_VNC` | `false` | `true`로 설정한 경우에만 VNC/noVNC를 시작합니다. |
+| `VNC_PASSWORD` | 랜덤 8자리 | 지정하지 않으면 자동 생성 후 `vnc_password.txt`에 저장합니다. |
+| `VNC_RESOLUTION` | `1920x1080` | VNC 화면 해상도입니다. |
+| `VNC_DEPTH` | `24` | VNC 색상 깊이입니다. |
+| `VNC_DISPLAY` | `1` | VNC display 번호입니다. 기본 VNC 포트는 `5901`입니다. |
+| `NOVNC_PORT` | `6080` | noVNC가 컨테이너 내부에서 listen할 포트입니다. |
+
# ⚒️ 빌드 자동화
이 Docker 이미지는 GitHub Actions를 통해 자동으로 빌드 및 배포됩니다.
diff --git a/entrypoint.sh b/entrypoint.sh
index b03bcea..cf3d799 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,5 +1,89 @@
#!/bin/bash
+start_novnc() {
+ case "${ENABLE_VNC:-false}" in
+ true|TRUE|1|yes|YES|on|ON) ;;
+ *)
+ echo "VNC/noVNC disabled. Set ENABLE_VNC=true to enable it."
+ return 0
+ ;;
+ esac
+
+ local user_home="/home/$USER_ID"
+ local vnc_dir="$user_home/.vnc"
+ local vnc_display="${VNC_DISPLAY:-1}"
+ local vnc_resolution="${VNC_RESOLUTION:-1920x1080}"
+ local vnc_depth="${VNC_DEPTH:-24}"
+ local novnc_port="${NOVNC_PORT:-6080}"
+ local vnc_password_file="$user_home/decs_jupyter_lab/vnc_password.txt"
+ local vnc_password
+
+ vnc_display="${vnc_display#:}"
+ if ! [[ "$vnc_display" =~ ^[0-9]+$ && "$novnc_port" =~ ^[0-9]+$ && "$vnc_depth" =~ ^[0-9]+$ ]]; then
+ echo "Invalid VNC configuration. Check VNC_DISPLAY, NOVNC_PORT, and VNC_DEPTH."
+ return 1
+ fi
+ local vnc_port=$((5900 + vnc_display))
+
+ if ! command -v vncserver >/dev/null 2>&1 || ! command -v websockify >/dev/null 2>&1; then
+ echo "VNC/noVNC packages are not installed. Skipping GUI startup."
+ return 0
+ fi
+
+ mkdir -p "$vnc_dir" "$user_home/decs_jupyter_lab" /tmp/.X11-unix /tmp/.ICE-unix
+ chown root:root /tmp/.X11-unix /tmp/.ICE-unix
+ chmod 1777 /tmp/.X11-unix /tmp/.ICE-unix
+
+ if [[ -n "${VNC_PASSWORD:-}" ]]; then
+ vnc_password="$VNC_PASSWORD"
+ elif [[ -s "$vnc_password_file" ]]; then
+ vnc_password=$(tr -d '\r\n' < "$vnc_password_file" | head -c 8)
+ else
+ vnc_password=$(tr -dc A-Za-z0-9 "$vnc_password_file"
+ chmod 600 "$vnc_password_file"
+
+ printf "%s\n" "$vnc_password" | vncpasswd -f > "$vnc_dir/passwd"
+ chmod 600 "$vnc_dir/passwd"
+
+ cat > "$vnc_dir/xstartup" <<'EOF'
+#!/bin/sh
+unset SESSION_MANAGER
+unset DBUS_SESSION_BUS_ADDRESS
+export XDG_SESSION_TYPE=x11
+export XKL_XMODMAP_DISABLE=1
+xrdb "$HOME/.Xresources" 2>/dev/null || true
+exec dbus-launch --exit-with-session startxfce4
+EOF
+ chmod +x "$vnc_dir/xstartup"
+ chown -R "$USER_ID:$USER_GROUP" "$vnc_dir" "$vnc_password_file"
+
+ sudo -u "$USER_ID" env HOME="$user_home" USER="$USER_ID" \
+ vncserver -kill ":$vnc_display" >/tmp/vnc-kill.log 2>&1 || true
+
+ echo "trying TigerVNC on localhost:$vnc_port..."
+ if ! sudo -u "$USER_ID" env HOME="$user_home" USER="$USER_ID" \
+ vncserver -localhost yes ":$vnc_display" -geometry "$vnc_resolution" -depth "$vnc_depth" >/tmp/vncserver.log 2>&1; then
+ echo "TigerVNC startup failed. See /tmp/vncserver.log."
+ cat /tmp/vncserver.log
+ return 1
+ fi
+ echo "TigerVNC listening on localhost:$vnc_port"
+
+ pkill -f "websockify.*$novnc_port" >/dev/null 2>&1 || true
+ echo "trying noVNC on 0.0.0.0:$novnc_port..."
+ nohup websockify --web=/usr/share/novnc "0.0.0.0:$novnc_port" "localhost:$vnc_port" >/tmp/novnc.log 2>&1 &
+ echo "noVNC listening on port $novnc_port. VNC password saved to $vnc_password_file"
+}
+
sudo apt update
sudo apt install -y auditd
@@ -129,6 +213,9 @@ echo "trying jupyter lab..."
nohup /opt/anaconda3/bin/jupyter lab --NotebookApp.token=$TOKEN --config=/home/$USER_ID/.jupyter/jupyter_notebook_config.py >/dev/null 2>&1 &
echo "jupyter lab listening!"
+# noVNC 기동. 외부에서는 컨테이너의 6080 포트를 매핑해서 접속합니다.
+start_novnc || echo "VNC/noVNC startup failed."
+
# ldconfig permission 오류 방지
# bash.bashrc에서 ldconfig 명령어 삭제 후 명령어 실행 및 결과 출력
sed -i '/ldconfig/d' /etc/bash.bashrc
From d841a3b403e7677afb61699e99ec6a6523188e6f Mon Sep 17 00:00:00 2001
From: aapdo
`dguailab/decs:260501` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** Xfce + TigerVNC + noVNC 기반 브라우저 GUI 접속 지원 추가 |
+| `dguailab/decs:latest`
`dguailab/decs:260506` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** 컨테이너 생성 시 `USER_PW` 환경변수로 사용자 초기 비밀번호를 주입할 수 있도록 하고, 값이 없으면 기존 기본 비밀번호를 사용하도록 fallback 추가 |
+| `dguailab/decs:260501` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | Xfce + TigerVNC + noVNC 기반 브라우저 GUI 접속 지원 추가 |
| `dguailab/decs:260427` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 기존 사용자 컨테이너 변경 시 `~/.bashrc`의 conda initialize 블록 갱신, 경고 주석 영문화 및 인덴트 수정 |
| `dguailab/decs:260403` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 계정/권한 검증 강화, config-server 주입 기반 sudo 권한 축소, Jupyter 및 컨테이너 유지 프로세스 비-root 실행, gosu 추가 |
| `dguailab/decs:260201` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 의도되지 않은 MOTD 출력 방지 버그 수정 |
diff --git a/entrypoint.sh b/entrypoint.sh
index cf3d799..009ac05 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,5 +1,7 @@
#!/bin/bash
+USER_PW="${USER_PW:-ailab2260}"
+
start_novnc() {
case "${ENABLE_VNC:-false}" in
true|TRUE|1|yes|YES|on|ON) ;;
From 0e96b1c2ca0fac8bef5383870f72d9612d159f47 Mon Sep 17 00:00:00 2001
From: aapdo
`dguailab/decs:260506` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** 컨테이너 생성 시 `USER_PW` 환경변수로 사용자 초기 비밀번호를 주입할 수 있도록 하고, 값이 없으면 기존 기본 비밀번호를 사용하도록 fallback 추가 |
+| `dguailab/decs:latest`
`dguailab/decs:260506` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** `USER_PW` 환경변수로 사용자 초기 비밀번호 주입 지원, VNC 비밀번호를 `/home/$USER_ID/vnc_password.txt`에 저장, noVNC 루트 URL(`http://서버주소:VNC포트`) 접속 지원 |
| `dguailab/decs:260501` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | Xfce + TigerVNC + noVNC 기반 브라우저 GUI 접속 지원 추가 |
| `dguailab/decs:260427` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 기존 사용자 컨테이너 변경 시 `~/.bashrc`의 conda initialize 블록 갱신, 경고 주석 영문화 및 인덴트 수정 |
| `dguailab/decs:260403` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 계정/권한 검증 강화, config-server 주입 기반 sudo 권한 축소, Jupyter 및 컨테이너 유지 프로세스 비-root 실행, gosu 추가 |
@@ -42,7 +42,7 @@ docker pull dguailab/decs:250926
- 컨테이너 내부 noVNC 포트: `6080`
- 컨테이너 내부 VNC 포트: `localhost:5901` 전용
-- 접속 URL: `http://서버주소:외부포트/vnc.html`
+- 접속 URL: `http://서버주소:VNC포트` 또는 `http://서버주소:VNC포트/vnc.html`
- VNC 비밀번호 저장 위치: `/home/$USER_ID/vnc_password.txt`
직접 `docker run`을 사용할 때는 다음처럼 환경변수와 포트를 함께 지정합니다.
@@ -51,7 +51,7 @@ docker pull dguailab/decs:250926
-e ENABLE_VNC=true -p 외부포트:6080
```
-운영 스크립트(`~/uid`)에서는 `--enable_vnc true`를 주면 컨테이너 내부 `6080` 포트를 자동으로 추가하고, Docker 실행 환경변수에 `ENABLE_VNC=true`를 함께 전달합니다. FARM2 예시는 외부 포트 범위 `9100~9199` 중 하나가 `6080`에 연결되는 방식입니다.
+운영 스크립트(`~/uid`)에서는 `--enable_vnc true`를 주면 컨테이너 내부 `6080` 포트를 VNC 포트로 별도 할당하고, Docker 실행 환경변수에 `ENABLE_VNC=true`를 함께 전달합니다. 이 포트는 사용자가 요청한 추가 포트가 아니라 SSH, Jupyter와 같은 기본 접속 포트로 안내됩니다.
```
--enable_vnc true
diff --git a/entrypoint.sh b/entrypoint.sh
index 29dd59e..a060492 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -80,6 +80,10 @@ EOF
fi
echo "TigerVNC listening on localhost:$vnc_port"
+ if [[ -d /usr/share/novnc && -f /usr/share/novnc/vnc.html ]]; then
+ ln -sf /usr/share/novnc/vnc.html /usr/share/novnc/index.html
+ fi
+
pkill -f "websockify.*$novnc_port" >/dev/null 2>&1 || true
echo "trying noVNC on 0.0.0.0:$novnc_port..."
nohup websockify --web=/usr/share/novnc "0.0.0.0:$novnc_port" "localhost:$vnc_port" >/tmp/novnc.log 2>&1 &
From 933bb7473d353cb99f7da3077a1dce82a4994574 Mon Sep 17 00:00:00 2001
From: aapdo
`dguailab/decs:260506` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(최신)** `USER_PW` 환경변수로 사용자 초기 비밀번호 주입 지원, VNC 비밀번호를 `/home/$USER_ID/vnc_password.txt`에 저장, noVNC 루트 URL(`http://서버주소:VNC포트`) 접속 지원 |
-| `dguailab/decs:260501` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | Xfce + TigerVNC + noVNC 기반 브라우저 GUI 접속 지원 추가 |
-| `dguailab/decs:260427` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 기존 사용자 컨테이너 변경 시 `~/.bashrc`의 conda initialize 블록 갱신, 경고 주석 영문화 및 인덴트 수정 |
-| `dguailab/decs:260403` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 계정/권한 검증 강화, config-server 주입 기반 sudo 권한 축소, Jupyter 및 컨테이너 유지 프로세스 비-root 실행, gosu 추가 |
-| `dguailab/decs:260201` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | 의도되지 않은 MOTD 출력 방지 버그 수정 |
-| `dguailab/decs:251023` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | **(이전 안정 버전)** Jupyter Notebook 설정 파일 생성/경로 오류 해결, 랜덤 토큰 저장 방식 반영 |
-| `dguailab/decs:251002` | **2.18.0** | CUDA 12.5
cuDNN 8.9 | Ubuntu 22.04 | TensorFlow 2.18.0 업그레이드, 최신 GPU 환경 지원, Docker Hub 자동 빌드/푸시 워크플로 도입 |
-| `dguailab/decs:250926` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | **(이전 안정 버전)** 사용자 홈 디렉토리 소유권 설정 순서 수정, 이슈/PR 템플릿 추가 |
-| `dguailab/decs:250428` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Jupyter Lab 설정 파일을 사용자 홈의 `.jupyter` 경로에서 생성하고 사용하도록 수정 |
-| `dguailab/decs:250416` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Jupyter Notebook 설정 파일 생성 로직 수정, 10자리 랜덤 토큰 생성 및 파일 저장 추가 |
-| `dguailab/decs:250323` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | `useradd` 옵션 보완, MOTD 공지 추가, TensorFlow ASCII 출력 제거 |
-| `dguailab/decs:250309` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | ldconfig permission denied 오류 방지, MOTD/Slack 공지 반영, Conda 최신화 반영 |
-| `dguailab/decs:250218` | **2.13.0** | CUDA 11.8
cuDNN 8.6 | Ubuntu 20.04 | Anaconda3 2024.10-1 설치로 Conda 24.9.2 업그레이드 |
-
-## ⚙️ 사용 방법
-필요한 버전의 이미지를 Docker Hub에서 pull 받아 사용합니다.
-
-### 최신 버전 사용하기
-latest 태그를 사용하여 항상 최신 버전의 이미지를 받을 수 있습니다.
-```
-docker pull dguailab/decs:latest
-```
+## DECS Docker Images
-### 날짜 태그를 직접 명시하여 받기
-```
-docker pull dguailab/decs:251002
+DECS 이미지는 GPU/driver 호환성을 관리하기 위해 CUDA variant별로 빌드한다. 공통 Dockerfile은 하나만 유지하고, 실제 CUDA/TensorFlow 조합은 `image-variants.json`에서 관리한다.
+
+## Image Variants
+
+| Variant tag | CUDA | TensorFlow | Base image | 최소 NVIDIA driver | 상태 |
+| --- | --- | --- | --- | --- | --- |
+| `cuda11.8-tf2.13-ubuntu22.04-260515` | 11.8 | 2.13.0 | `nvidia/cuda:11.8.0-cudnn8-devel-ubuntu22.04` | 520.61.05 | stable |
+| `cuda12.2-tf2.15-ubuntu22.04-260515` | 12.2 | 2.15.0 | `nvidia/cuda:12.2.2-cudnn8-devel-ubuntu22.04` | 535.104.05 | stable |
+| `cuda12.5-tf2.20-ubuntu22.04-260515` | 12.5 | 2.20.0 | `nvidia/cuda:12.5.1-cudnn-devel-ubuntu22.04` | 555.42.06 | stable |
+| `cuda12.8-tf2.20-ubuntu22.04-260515` | 12.8 | 2.20.0 | `nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04` | 570.124.06 | experimental |
+
+Alias tags:
+
+| Alias | Target |
+| --- | --- |
+| `latest`, `stable`, `cuda12.5-tf2.20` | CUDA 12.5 / TensorFlow 2.20 |
+| `legacy`, `cuda11.8-tf2.13` | CUDA 11.8 / TensorFlow 2.13 |
+| `cuda12.2-tf2.15` | CUDA 12.2 / TensorFlow 2.15 |
+| `cuda12.8-tf2.20`, `h200-experimental` | CUDA 12.8 / TensorFlow 2.20 |
+
+TensorFlow 공식 빌드 구성 기준으로 TensorFlow 2.20.0은 CUDA 12.5/cuDNN 9.3 조합이다. CUDA 12.8 이미지는 H200/LAB10 검증 전까지 experimental로 둔다.
+
+## Included Runtime
+
+모든 variant는 다음을 포함한다.
+
+- CUDA/cuDNN base image
+- TensorFlow
+- system packages: SSH, sudo, auditd, Korean fonts/input, Chrome, Xfce, TigerVNC, noVNC
+- Miniforge under `/opt/conda`
+- micromamba
+- JupyterLab / Notebook / ipywidgets
+
+`entrypoint.sh`는 시작 시 이미지 variant, CUDA/TensorFlow 버전, 요구 driver 버전, 실제 `nvidia-smi` 정보를 출력한다. `STRICT_CUDA_COMPAT=true`를 주면 host driver가 variant의 최소 driver보다 낮을 때 시작을 실패시킨다.
+
+## Build
+
+전체 variant dry-run:
+
+```bash
+python3 scripts/build_variants.py --dry-run
```
-### 특정 구버전 사용하기
-이전 버전의 TensorFlow 환경이 필요한 경우, 해당 날짜 태그를 명시하여 이미지를 받습니다.
+특정 variant build:
+
+```bash
+python3 scripts/build_variants.py --variant cuda12.5-tf2.20-ubuntu22.04
```
-docker pull dguailab/decs:250926
+
+push까지 수행:
+
+```bash
+python3 scripts/build_variants.py --variant cuda12.5-tf2.20-ubuntu22.04 --push
```
-### GUI 접속(noVNC)
-`260501` 버전부터 브라우저 기반 GUI 접속을 지원합니다. GUI 접속은 기본 비활성화 상태이며, 컨테이너 생성 시 `ENABLE_VNC=true` 환경변수를 전달한 경우에만 시작됩니다.
+GitHub Actions는 `main` 대상 PR이 merge되거나 `workflow_dispatch`로 실행될 때 `image-variants.json`을 읽어 matrix build/push를 수행한다.
-- 컨테이너 내부 noVNC 포트: `6080`
-- 컨테이너 내부 VNC 포트: `localhost:5901` 전용
-- 접속 URL: `http://서버주소:VNC포트` 또는 `http://서버주소:VNC포트/vnc.html`
-- VNC 비밀번호 저장 위치: `/home/$USER_ID/vnc_password.txt`
+## Usage
-직접 `docker run`을 사용할 때는 다음처럼 환경변수와 포트를 함께 지정합니다.
+운영 스크립트(`~/uid/script/create_container.sh`)에서는 이미지 이름과 버전을 분리해서 전달한다.
-```
--e ENABLE_VNC=true -p 외부포트:6080
+```bash
+bash ~/uid/script/create_container.sh \
+ --image decs \
+ --version cuda12.5-tf2.20-ubuntu22.04-260515
```
-운영 스크립트(`~/uid`)에서는 `--enable_vnc true`를 주면 컨테이너 내부 `6080` 포트를 VNC 포트로 별도 할당하고, Docker 실행 환경변수에 `ENABLE_VNC=true`를 함께 전달합니다. 이 포트는 사용자가 요청한 추가 포트가 아니라 SSH, Jupyter와 같은 기본 접속 포트로 안내됩니다.
+noVNC는 기존처럼 opt-in이다.
-```
---enable_vnc true
+```bash
+--enable-vnc true
```
관련 환경변수:
| 환경변수 | 기본값 | 설명 |
-| :--- | :--- | :--- |
-| `ENABLE_VNC` | `false` | `true`로 설정한 경우에만 VNC/noVNC를 시작합니다. |
-| `VNC_PASSWORD` | 랜덤 8자리 | 지정하지 않으면 자동 생성 후 `vnc_password.txt`에 저장합니다. |
-| `VNC_RESOLUTION` | `1920x1080` | VNC 화면 해상도입니다. |
-| `VNC_DEPTH` | `24` | VNC 색상 깊이입니다. |
-| `VNC_DISPLAY` | `1` | VNC display 번호입니다. 기본 VNC 포트는 `5901`입니다. |
-| `NOVNC_PORT` | `6080` | noVNC가 컨테이너 내부에서 listen할 포트입니다. |
+| --- | --- | --- |
+| `ENABLE_VNC` | `false` | `true`이면 TigerVNC/noVNC를 시작한다. |
+| `VNC_PASSWORD` | 랜덤 8자리 | 지정하지 않으면 `/home/$USER_ID/vnc_password.txt`에 저장한다. |
+| `VNC_RESOLUTION` | `1920x1080` | VNC 화면 해상도 |
+| `VNC_DEPTH` | `24` | VNC 색상 깊이 |
+| `VNC_DISPLAY` | `1` | VNC display 번호. 기본 VNC 포트는 `5901` |
+| `NOVNC_PORT` | `6080` | noVNC listen 포트 |
+| `STRICT_CUDA_COMPAT` | `false` | 최소 NVIDIA driver 미만이면 startup 실패 |
+## Tests
+
+이 저장소 내부의 테스트 파일만 사용한다. 외부 `~/uid`와 ansible inventory는 호출 대상이다.
+
+로컬 이미지 smoke test:
+
+```bash
+python3 scripts/test_image_variants.py --variant cuda12.5-tf2.20-ubuntu22.04
+python3 scripts/test_image_variants.py --variant cuda12.5-tf2.20-ubuntu22.04 --gpu
+```
-# ⚒️ 빌드 자동화
-이 Docker 이미지는 GitHub Actions를 통해 자동으로 빌드 및 배포됩니다.
-- 실행 조건: decsYYMMDD 형식의 브랜치가 develop 브랜치로 병합(merge)될 때
-- 자동 생성 태그:
-- 브랜치 이름에서 추출한 날짜 태그 (예: `dguailab/decs:251002`)
-- 최신 버전을 가리키는 latest 태그 (`dguailab/decs:latest`)
+`~/uid/script_test/create_container.sh` dry-run 연동 테스트:
+
+```bash
+python3 scripts/test_uid_create_container.py --variant cuda12.5-tf2.20-ubuntu22.04
+```
+
+LAB10 같은 실제 GPU host에서 ansible smoke test:
+
+```bash
+tar -czf /tmp/decs-build-context-260515.tgz Dockerfile entrypoint.sh .dockerignore
+
+ansible-playbook \
+ -i /home/jy/ansible/inventory.ini \
+ tests/ansible/decs_image_build.yml \
+ -e target_hosts=lab10 \
+ -e image_tag=cuda12.8-tf2.20-ubuntu22.04-260515 \
+ -e base_image=nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04 \
+ -e decs_image_variant=cuda12.8-tf2.20-ubuntu22.04 \
+ -e cuda_version=12.8 \
+ -e tensorflow_version=2.20.0 \
+ -e tensorflow_package=tensorflow==2.20.0 \
+ -e min_nvidia_driver=570.124.06
+
+ansible-playbook \
+ -i /home/jy/ansible/inventory.ini \
+ tests/ansible/decs_image_smoke.yml \
+ -e target_hosts=lab10 \
+ -e image_tag=cuda12.8-tf2.20-ubuntu22.04-260515
+```
+
+VNC까지 확인:
+
+```bash
+ansible-playbook \
+ -i /home/jy/ansible/inventory.ini \
+ tests/ansible/decs_image_smoke.yml \
+ -e target_hosts=lab10 \
+ -e image_tag=cuda12.5-tf2.20-ubuntu22.04-260515 \
+ -e enable_vnc=true
+```
+## Admin Notes
-
-### 🔗 관리자용 노션 문서 링크
+관리자용 노션 문서:
https://www.notion.so/DECS-280c7692a263802ca40ff68b38f58dd1?source=copy_link
diff --git a/entrypoint.sh b/entrypoint.sh
index a060492..1089466 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,15 +1,65 @@
#!/bin/bash
+CONDA_DIR="${CONDA_DIR:-/opt/conda}"
+JUPYTER_BIN="${JUPYTER_BIN:-$CONDA_DIR/bin/jupyter}"
USER_PW="${USER_PW:-ailab2260}"
-start_novnc() {
- case "${ENABLE_VNC:-false}" in
- true|TRUE|1|yes|YES|on|ON) ;;
- *)
- echo "VNC/noVNC disabled. Set ENABLE_VNC=true to enable it."
- return 0
- ;;
+is_truthy() {
+ case "${1:-}" in
+ true|TRUE|1|yes|YES|on|ON) return 0 ;;
+ *) return 1 ;;
esac
+}
+
+version_ge() {
+ local current="$1"
+ local required="$2"
+ [[ "$(printf "%s\n%s\n" "$required" "$current" | sort -V | head -n1)" == "$required" ]]
+}
+
+print_image_runtime_info() {
+ echo "DECS image variant: ${DECS_IMAGE_VARIANT:-unknown}"
+ echo "DECS CUDA version: ${DECS_CUDA_VERSION:-unknown}"
+ echo "DECS TensorFlow version: ${DECS_TENSORFLOW_VERSION:-unknown}"
+ echo "DECS minimum NVIDIA driver: ${DECS_MIN_NVIDIA_DRIVER:-unknown}"
+
+ if ! command -v nvidia-smi >/dev/null 2>&1; then
+ echo "nvidia-smi not found. GPU runtime may not be attached."
+ return 0
+ fi
+
+ nvidia-smi --query-gpu=name,driver_version --format=csv,noheader || true
+
+ local required_driver="${DECS_MIN_NVIDIA_DRIVER:-}"
+ if [[ -z "$required_driver" ]]; then
+ return 0
+ fi
+
+ local host_driver
+ host_driver="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -n1 | tr -d '[:space:]')"
+ if [[ -z "$host_driver" ]]; then
+ echo "Could not read NVIDIA driver version from nvidia-smi."
+ return 0
+ fi
+
+ if version_ge "$host_driver" "$required_driver"; then
+ return 0
+ fi
+
+ local message="Host NVIDIA driver $host_driver is lower than required $required_driver for ${DECS_IMAGE_VARIANT:-this image}."
+ if is_truthy "${STRICT_CUDA_COMPAT:-false}"; then
+ echo "ERROR: $message"
+ return 1
+ fi
+
+ echo "WARNING: $message Set STRICT_CUDA_COMPAT=true to fail startup."
+}
+
+start_novnc() {
+ if ! is_truthy "${ENABLE_VNC:-false}"; then
+ echo "VNC/noVNC disabled. Set ENABLE_VNC=true to enable it."
+ return 0
+ fi
local user_home="/home/$USER_ID"
local vnc_dir="$user_home/.vnc"
@@ -27,7 +77,7 @@ start_novnc() {
fi
local vnc_port=$((5900 + vnc_display))
- if ! command -v vncserver >/dev/null 2>&1 || ! command -v websockify >/dev/null 2>&1; then
+ if ! command -v vncserver >/dev/null 2>&1 || ! command -v vncpasswd >/dev/null 2>&1 || ! command -v websockify >/dev/null 2>&1; then
echo "VNC/noVNC packages are not installed. Skipping GUI startup."
return 0
fi
@@ -90,8 +140,7 @@ EOF
echo "noVNC listening on port $novnc_port. VNC password saved to $vnc_password_file"
}
-sudo apt update
-sudo apt install -y auditd
+print_image_runtime_info || exit 1
# /etc/audit/audit.rules 파일에 줄 추가
echo "-a always,exit -F arch=b64 -S unlink -S unlinkat -S rename -S renameat -F auid=$USER_ID -k rm_commands" >> /etc/audit/audit.rules
@@ -199,8 +248,11 @@ mkdir -p /home/$USER_ID/.jupyter/
if [ ! -f /home/$USER_ID/.jupyter/jupyter_notebook_config.py ]; then
echo "jupyter_notebook_config.py not found, generating..."
- /opt/anaconda3/bin/jupyter notebook --generate-config
- cp /root/.jupyter/jupyter_notebook_config.py /home/$USER_ID/.jupyter/
+ if [ -f /jupyter_config/jupyter_notebook_config.py ]; then
+ cp /jupyter_config/jupyter_notebook_config.py /home/$USER_ID/.jupyter/jupyter_notebook_config.py
+ else
+ "$JUPYTER_BIN" notebook --generate-config --config=/home/$USER_ID/.jupyter/jupyter_notebook_config.py
+ fi
else
echo "jupyter_notebook_config.py already exists."
fi
@@ -216,7 +268,7 @@ chown $USER_ID:$USER_ID /home/$USER_ID/decs_jupyter_lab/jupyter_token.txt
# jupyter_lab 기동
echo "trying jupyter lab..."
-nohup /opt/anaconda3/bin/jupyter lab --NotebookApp.token=$TOKEN --config=/home/$USER_ID/.jupyter/jupyter_notebook_config.py >/dev/null 2>&1 &
+nohup "$JUPYTER_BIN" lab --NotebookApp.token=$TOKEN --config=/home/$USER_ID/.jupyter/jupyter_notebook_config.py >/dev/null 2>&1 &
echo "jupyter lab listening!"
# noVNC 기동. 외부에서는 컨테이너의 6080 포트를 매핑해서 접속합니다.
diff --git a/image-variants.json b/image-variants.json
new file mode 100644
index 0000000..9c59ab5
--- /dev/null
+++ b/image-variants.json
@@ -0,0 +1,70 @@
+{
+ "repository": "dguailab/decs",
+ "default_date_tag": "260515",
+ "notes": [
+ "TensorFlow 2.20.0 is officially built against CUDA 12.5 and cuDNN 9.3.",
+ "The CUDA 12.8 variant is provided as experimental until LAB10/H200 smoke tests pass."
+ ],
+ "variants": [
+ {
+ "id": "cuda11.8-tf2.13-ubuntu22.04",
+ "base_image": "nvidia/cuda:11.8.0-cudnn8-devel-ubuntu22.04",
+ "cuda_version": "11.8",
+ "tensorflow_version": "2.13.0",
+ "tensorflow_package": "tensorflow==2.13.0",
+ "python_version": "3.10",
+ "ubuntu_version": "22.04",
+ "min_nvidia_driver": "520.61.05",
+ "support": "stable",
+ "aliases": [
+ "cuda11.8-tf2.13",
+ "legacy"
+ ]
+ },
+ {
+ "id": "cuda12.2-tf2.15-ubuntu22.04",
+ "base_image": "nvidia/cuda:12.2.2-cudnn8-devel-ubuntu22.04",
+ "cuda_version": "12.2",
+ "tensorflow_version": "2.15.0",
+ "tensorflow_package": "tensorflow==2.15.0",
+ "python_version": "3.10",
+ "ubuntu_version": "22.04",
+ "min_nvidia_driver": "535.104.05",
+ "support": "stable",
+ "aliases": [
+ "cuda12.2-tf2.15"
+ ]
+ },
+ {
+ "id": "cuda12.5-tf2.20-ubuntu22.04",
+ "base_image": "nvidia/cuda:12.5.1-cudnn-devel-ubuntu22.04",
+ "cuda_version": "12.5",
+ "tensorflow_version": "2.20.0",
+ "tensorflow_package": "tensorflow==2.20.0",
+ "python_version": "3.10",
+ "ubuntu_version": "22.04",
+ "min_nvidia_driver": "555.42.06",
+ "support": "stable",
+ "aliases": [
+ "cuda12.5-tf2.20",
+ "stable",
+ "latest"
+ ]
+ },
+ {
+ "id": "cuda12.8-tf2.20-ubuntu22.04",
+ "base_image": "nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04",
+ "cuda_version": "12.8",
+ "tensorflow_version": "2.20.0",
+ "tensorflow_package": "tensorflow==2.20.0",
+ "python_version": "3.10",
+ "ubuntu_version": "22.04",
+ "min_nvidia_driver": "570.124.06",
+ "support": "experimental",
+ "aliases": [
+ "cuda12.8-tf2.20",
+ "h200-experimental"
+ ]
+ }
+ ]
+}
diff --git a/scripts/build_variants.py b/scripts/build_variants.py
new file mode 100755
index 0000000..e70c56d
--- /dev/null
+++ b/scripts/build_variants.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import shlex
+import subprocess
+from pathlib import Path
+
+from variant_matrix import build_tags, load_manifest
+
+
+BUILD_ARG_KEYS = [
+ ("BASE_IMAGE", "base_image"),
+ ("DECS_IMAGE_VARIANT", "id"),
+ ("CUDA_VERSION", "cuda_version"),
+ ("TENSORFLOW_VERSION", "tensorflow_version"),
+ ("TENSORFLOW_PACKAGE", "tensorflow_package"),
+ ("PYTHON_VERSION", "python_version"),
+ ("UBUNTU_VERSION", "ubuntu_version"),
+ ("MIN_NVIDIA_DRIVER", "min_nvidia_driver"),
+]
+
+
+def select_variants(manifest, selected):
+ variants = manifest["variants"]
+ if not selected:
+ return variants
+ matches = [variant for variant in variants if variant["id"] == selected]
+ if not matches:
+ raise SystemExit(f"variant not found: {selected}")
+ return matches
+
+
+def build_command(variant, repository, date_tag, no_cache):
+ cmd = ["docker", "build", "-f", "Dockerfile"]
+ if no_cache:
+ cmd.append("--no-cache")
+
+ for docker_arg, key in BUILD_ARG_KEYS:
+ cmd.extend(["--build-arg", f"{docker_arg}={variant[key]}"])
+
+ for tag in build_tags(repository, variant, date_tag):
+ cmd.extend(["-t", tag])
+
+ cmd.append(".")
+ return cmd
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Build DECS Docker image variants.")
+ parser.add_argument("--manifest", default="image-variants.json")
+ parser.add_argument("--repository")
+ parser.add_argument("--date-tag")
+ parser.add_argument("--variant")
+ parser.add_argument("--push", action="store_true")
+ parser.add_argument("--no-cache", action="store_true")
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args()
+
+ repo_root = Path(__file__).resolve().parents[1]
+ manifest = load_manifest(repo_root / args.manifest)
+ repository = args.repository or manifest["repository"]
+ date_tag = args.date_tag or manifest["default_date_tag"]
+
+ for variant in select_variants(manifest, args.variant):
+ tags = build_tags(repository, variant, date_tag)
+ cmd = build_command(variant, repository, date_tag, args.no_cache)
+ print(shlex.join(cmd))
+ if not args.dry_run:
+ subprocess.run(cmd, cwd=repo_root, check=True)
+
+ if args.push:
+ for tag in tags:
+ push_cmd = ["docker", "push", tag]
+ print(shlex.join(push_cmd))
+ if not args.dry_run:
+ subprocess.run(push_cmd, check=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_image_variants.py b/scripts/test_image_variants.py
new file mode 100755
index 0000000..25eb204
--- /dev/null
+++ b/scripts/test_image_variants.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+import argparse
+import shlex
+import subprocess
+from pathlib import Path
+
+from variant_matrix import build_tags, load_manifest
+
+
+CPU_SMOKE = r"""
+set -euo pipefail
+command -v python
+command -v jupyter
+command -v micromamba
+test -x /entrypoint.sh
+python - <<'PY'
+import os
+import tensorflow as tf
+
+expected = os.environ.get("DECS_TENSORFLOW_VERSION")
+actual = tf.__version__.split("+", 1)[0]
+print("tensorflow", tf.__version__)
+print("expected", expected)
+if expected and actual != expected:
+ raise SystemExit(f"TensorFlow version mismatch: {actual} != {expected}")
+PY
+jupyter --version
+micromamba --version
+"""
+
+GPU_SMOKE = r"""
+set -euo pipefail
+nvidia-smi
+python - <<'PY'
+import tensorflow as tf
+
+gpus = tf.config.list_physical_devices("GPU")
+print("tensorflow", tf.__version__)
+print("gpus", gpus)
+if not gpus:
+ raise SystemExit("TensorFlow did not detect a GPU")
+PY
+"""
+
+
+def select_variants(manifest, selected):
+ if not selected:
+ return manifest["variants"]
+ matches = [variant for variant in manifest["variants"] if variant["id"] == selected]
+ if not matches:
+ raise SystemExit(f"variant not found: {selected}")
+ return matches
+
+
+def run(cmd, dry_run):
+ print(shlex.join(cmd))
+ if not dry_run:
+ subprocess.run(cmd, check=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run local smoke tests for DECS image variants.")
+ parser.add_argument("--manifest", default="image-variants.json")
+ parser.add_argument("--repository")
+ parser.add_argument("--date-tag")
+ parser.add_argument("--variant")
+ parser.add_argument("--gpu", action="store_true", help="Require GPU visibility through Docker.")
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args()
+
+ repo_root = Path(__file__).resolve().parents[1]
+ manifest = load_manifest(repo_root / args.manifest)
+ repository = args.repository or manifest["repository"]
+ date_tag = args.date_tag or manifest["default_date_tag"]
+
+ for variant in select_variants(manifest, args.variant):
+ image = build_tags(repository, variant, date_tag)[0]
+ run(["docker", "image", "inspect", image], args.dry_run)
+
+ cmd = ["docker", "run", "--rm", "--entrypoint", "bash"]
+ if args.gpu:
+ cmd.extend(["--gpus", "all"])
+ cmd.extend([image, "-lc", CPU_SMOKE])
+ run(cmd, args.dry_run)
+
+ if args.gpu:
+ run(["docker", "run", "--rm", "--gpus", "all", "--entrypoint", "bash", image, "-lc", GPU_SMOKE], args.dry_run)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_uid_create_container.py b/scripts/test_uid_create_container.py
new file mode 100755
index 0000000..5ec3393
--- /dev/null
+++ b/scripts/test_uid_create_container.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+import argparse
+import re
+import secrets
+import shlex
+import subprocess
+from datetime import date, timedelta
+from pathlib import Path
+
+from variant_matrix import build_tags, load_manifest
+
+
+def select_variants(manifest, selected):
+ if not selected:
+ return manifest["variants"]
+ matches = [variant for variant in manifest["variants"] if variant["id"] == selected]
+ if not matches:
+ raise SystemExit(f"variant not found: {selected}")
+ return matches
+
+
+def safe_name(value):
+ return re.sub(r"[^a-zA-Z0-9]", "", value).lower()[:24]
+
+
+def random_password(length):
+ alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Exercise ~/uid/script_test/create_container.sh with DECS image tags."
+ )
+ parser.add_argument("--manifest", default="image-variants.json")
+ parser.add_argument("--repository", default="dguailab/decs")
+ parser.add_argument("--date-tag")
+ parser.add_argument("--variant")
+ parser.add_argument("--uid-root", default="/home/jy/uid")
+ parser.add_argument("--domain", default="LAB")
+ parser.add_argument("--server-number", default="10")
+ parser.add_argument("--created-by", default="decs-test")
+ parser.add_argument("--email", default="decs-smoke@example.invalid")
+ parser.add_argument("--phone", default="000-0000-0000")
+ parser.add_argument("--enable-vnc", action="store_true")
+ parser.add_argument("--print-only", action="store_true")
+ args = parser.parse_args()
+
+ repo_root = Path(__file__).resolve().parents[1]
+ manifest = load_manifest(repo_root / args.manifest)
+ date_tag = args.date_tag or manifest["default_date_tag"]
+ uid_script = Path(args.uid_root) / "script_test" / "create_container.sh"
+ if not uid_script.exists():
+ raise SystemExit(f"uid dry-run wrapper not found: {uid_script}")
+
+ repository_name = args.repository.rsplit("/", 1)[-1]
+ expiration = (date.today() + timedelta(days=7)).isoformat()
+ user_password = random_password(20)
+ vnc_password = random_password(8)
+
+ for variant in select_variants(manifest, args.variant):
+ tag = build_tags(args.repository, variant, date_tag)[0].split(":", 1)[1]
+ username = f"decs{safe_name(variant['id'])}"
+ cmd = [
+ str(uid_script),
+ "--name",
+ "DECS Smoke Test",
+ "--username",
+ username,
+ "--no-group",
+ "--domain",
+ args.domain,
+ "--server-number",
+ str(args.server_number),
+ "--expiration-date",
+ expiration,
+ "--image",
+ repository_name,
+ "--version",
+ tag,
+ "--no-container-name",
+ "--no-additional-ports",
+ "--created-by",
+ args.created_by,
+ "--email",
+ args.email,
+ "--phone",
+ args.phone,
+ "--note",
+ f"DECS image dry-run smoke for {variant['id']}",
+ "--user-password",
+ user_password,
+ ]
+ if args.enable_vnc:
+ cmd.extend(["--enable-vnc", "true", "--vnc-password", vnc_password])
+
+ print(shlex.join(cmd))
+ if not args.print_only:
+ subprocess.run(cmd, check=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/variant_matrix.py b/scripts/variant_matrix.py
new file mode 100755
index 0000000..4dcf69b
--- /dev/null
+++ b/scripts/variant_matrix.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import os
+from pathlib import Path
+
+
+def load_manifest(path):
+ with Path(path).open(encoding="utf-8") as f:
+ manifest = json.load(f)
+
+ seen = set()
+ for variant in manifest["variants"]:
+ variant_id = variant["id"]
+ if variant_id in seen:
+ raise SystemExit(f"duplicate variant id: {variant_id}")
+ seen.add(variant_id)
+ return manifest
+
+
+def build_tags(repository, variant, date_tag):
+ tags = [f"{repository}:{variant['id']}-{date_tag}"]
+ tags.extend(f"{repository}:{alias}" for alias in variant.get("aliases", []))
+ return tags
+
+
+def build_matrix(manifest, repository, date_tag, selected_variant=None):
+ include = []
+ for variant in manifest["variants"]:
+ if selected_variant and variant["id"] != selected_variant:
+ continue
+
+ item = dict(variant)
+ item["docker_tags"] = "\n".join(build_tags(repository, variant, date_tag))
+ include.append(item)
+
+ if not include:
+ raise SystemExit(f"variant not found: {selected_variant}")
+
+ return {"include": include}
+
+
+def write_github_output(name, value):
+ output_path = os.environ.get("GITHUB_OUTPUT")
+ if not output_path:
+ print(f"{name}={value}")
+ return
+
+ with Path(output_path).open("a", encoding="utf-8") as f:
+ if "\n" in value:
+ f.write(f"{name}<