diff --git a/bin/dnc b/bin/dnc new file mode 100755 index 0000000..33aac84 --- /dev/null +++ b/bin/dnc @@ -0,0 +1,49 @@ +#!/bin/sh +set -e + +IMAGE="${DNC_IMAGE:-ghcr.io/lstmnemodel/dnc:latest}" + +GPU=none +if command -v nvidia-smi >/dev/null 2>&1; then + GPU=nvidia +fi + +DRI="" +[ -d /dev/dri ] && DRI=1 + +KFD="" +[ -e /dev/kfd ] && KFD=1 + +DOCKER_SOCK="/var/run/docker.sock" + +ROOTLESS=$(docker info --format '{{.ClientInfo.Context}}' 2>/dev/null | grep -ci rootless || true) +if [ "$ROOTLESS" -gt 0 ] 2>/dev/null ; then + echo "ERROR: dnc requires rootful Docker. Rootless Docker is not supported." >&2 + exit 1 +fi + +DNC_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/dnc" +mkdir -p "$DNC_CACHE" + +TTY_FLAG="" +[ -t 0 ] && TTY_FLAG="-t" + +exec docker run --rm -i ${TTY_FLAG} \ + -v "$DOCKER_SOCK:/var/run/docker.sock:ro" \ + -v "$PWD:$PWD" -w "$PWD" \ + -v "$DNC_CACHE:/opt/dnc/host:rw" \ + -e "DNC_HOST_GPU=$GPU" \ + -e "DNC_HOST_DRI=$DRI" \ + -e "DNC_HOST_KFD=$KFD" \ + -e "DNC_HOST_UID=$(id -u)" \ + -e "DNC_HOST_GID=$(id -g)" \ + -e "DNC_HOST_USER=$USER" \ + -e "DNC_HOST_HOME=$HOME" \ + -e "DNC_HOST_SHELL=$SHELL" \ + -e "DNC_HOST_GIDS=$(id -G)" \ + -e "DNC_CACHE_HOST=$DNC_CACHE" \ + -e DISPLAY -e WAYLAND_DISPLAY -e XAUTHORITY \ + -e DBUS_SESSION_BUS_ADDRESS -e XDG_RUNTIME_DIR \ + -e PULSE_SERVER -e PIPEWIRE_RUNTIME_DIR \ + -e SSH_AUTH_SOCK -e TERM -e LANG \ + "$IMAGE" "$@" diff --git a/nnm/Containerfile b/nnm/Containerfile deleted file mode 100644 index ec31170..0000000 --- a/nnm/Containerfile +++ /dev/null @@ -1,16 +0,0 @@ -ARG BASE_IMAGE_DIGEST -FROM archlinux@${BASE_IMAGE_DIGEST} - -ARG ALA_DATE - -COPY pacman.conf /etc/pacman.conf -COPY packages.list /tmp/packages.list - -RUN sed "s|\$ala_date|$ALA_DATE|g" -i /etc/pacman.conf && \ - pacman -Syu --noconfirm && \ - pacman -S --noconfirm $(grep -v '^#' /tmp/packages.list | grep -v '^$') && \ - rm -f /tmp/packages.list - -COPY config/ /usr/share/nnm/config/ - -RUN git clone --depth=1 --single-branch https://github.com/ohmyzsh/ohmyzsh.git /usr/share/ohmyzsh diff --git a/nnm/config/kitty/kitty.conf b/nnm/config/kitty/kitty.conf deleted file mode 100644 index 0932b5f..0000000 --- a/nnm/config/kitty/kitty.conf +++ /dev/null @@ -1,46 +0,0 @@ -font_family JetBrainsMono Nerd Font -font_size 13.0 -disable_ligatures never -cursor_shape beam -cursor_blink_interval 0.5 -window_padding_width 6 -hide_window_decorations no -background_opacity 0.97 - -background #001e26 -foreground #9bc1c2 -cursor #f34a00 -selection_background #003747 -color0 #002731 -color8 #006388 -color1 #d01b24 -color9 #f4153b -color2 #6bbe6c -color10 #50ee84 -color3 #a57705 -color11 #b17e28 -color4 #2075c7 -color12 #178dc7 -color5 #c61b6e -color13 #e14d8e -color6 #259185 -color14 #00b29e -color7 #e9e2cb -color15 #fcf4dc -selection_foreground #001e26 - -mouse_hide_wait -3.0 - -map ctrl+grave send_text all \x1b[96;5u - -tab_bar_style powerline -tab_powerline_style slanted -map ctrl+shift+t new_tab_with_cwd - -enabled_layouts splits,stack -map ctrl+shift+\ launch --location=vsplit --cwd=current -map ctrl+shift+- launch --location=hsplit --cwd=current - -scrollback_pager nvim --noplugin -R -c "set nonumber norelativenumber" - - -map ctrl+shift+enter launch --cwd=current --type=window nvim diff --git a/nnm/config/nvim/init.lua b/nnm/config/nvim/init.lua deleted file mode 100644 index 3e0f3c4..0000000 --- a/nnm/config/nvim/init.lua +++ /dev/null @@ -1,38 +0,0 @@ -vim.g.mapleader = " " -vim.g.maplocalleader = " " - -local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" -if not vim.loop.fs_stat(lazypath) then - vim.fn.system({ - "git", - "clone", - "--filter=blob:none", - "https://github.com/folke/lazy.nvim.git", - "--branch=stable", - lazypath, - }) -end -vim.opt.rtp:prepend(lazypath) - -require("lazy").setup({ - spec = { - { "LazyVim/LazyVim", import = "lazyvim.plugins" }, - { import = "plugins" }, - }, - defaults = { - lazy = false, - version = false, - }, - install = { colorscheme = { "tokyonight" } }, - checker = { enabled = true }, - performance = { - cache = { enabled = true }, - rtp = { - disabled_plugins = { - "gzip", "matchit", "matchparen", - "netrwPlugin", "tarPlugin", "tohtml", - "tutor", "zipPlugin", - }, - }, - }, -}) diff --git a/nnm/config/nvim/lua/plugins/colorscheme.lua b/nnm/config/nvim/lua/plugins/colorscheme.lua deleted file mode 100644 index 5db01f2..0000000 --- a/nnm/config/nvim/lua/plugins/colorscheme.lua +++ /dev/null @@ -1,60 +0,0 @@ -return { - { - "LazyVim/LazyVim", - opts = { - colorscheme = "tokyonight", - }, - }, - { - "folke/tokyonight.nvim", - opts = { - style = "night", - on_highlights = function(hl, c) - hl.Normal = { bg = "#001e26", fg = "#9bc1c2" } - hl.NormalFloat = { bg = "#002731" } - hl.LineNr = { fg = "#006388" } - hl.CursorLine = { bg = "#002731" } - hl.CursorLineNr = { fg = "#9bc1c2" } - hl.Visual = { bg = "#003747" } - hl.VisualNOS = { bg = "#003747" } - hl.Search = { bg = "#a57705", fg = "#001e26" } - hl.IncSearch = { bg = "#f34a00", fg = "#001e26" } - hl.Comment = { fg = "#006388", italic = true } - hl.String = { fg = "#6bbe6c" } - hl.Function = { fg = "#2075c7" } - hl.Number = { fg = "#c61b6e" } - hl.Boolean = { fg = "#c61b6e" } - hl.Type = { fg = "#259185" } - hl.Keyword = { fg = "#d01b24" } - hl.Identifier = { fg = "#9bc1c2" } - hl.Constant = { fg = "#a57705" } - hl.Special = { fg = "#e14d8e" } - hl.Title = { fg = "#178dc7", bold = true } - hl.Todo = { bg = "#a57705", fg = "#001e26" } - hl.Error = { fg = "#f4153b" } - hl.WarningMsg = { fg = "#b17e28" } - hl.MoreMsg = { fg = "#50ee84" } - hl.Question = { fg = "#178dc7" } - hl.Pmenu = { bg = "#002731", fg = "#9bc1c2" } - hl.PmenuSel = { bg = "#003747", fg = "#e9e2cb" } - hl.PmenuSbar = { bg = "#002731" } - hl.PmenuThumb = { bg = "#006388" } - hl.StatusLine = { bg = "#003747", fg = "#9bc1c2" } - hl.StatusLineNC = { bg = "#002731", fg = "#006388" } - hl.TabLine = { bg = "#002731", fg = "#006388" } - hl.TabLineSel = { bg = "#003747", fg = "#e9e2cb" } - hl.TabLineFill = { bg = "#001e26" } - hl.NonText = { fg = "#006388" } - hl.SpecialKey = { fg = "#00b29e" } - hl.MatchParen = { bg = "#003747", fg = "#f34a00" } - hl.DiffAdd = { bg = "#1a3a1a", fg = "#50ee84" } - hl.DiffChange = { bg = "#1a2a3a", fg = "#178dc7" } - hl.DiffDelete = { bg = "#3a1a1a", fg = "#f4153b" } - hl.DiffText = { bg = "#1a3a3a", fg = "#00b29e" } - hl.SpellBad = { undercurl = true, sp = "#f4153b" } - hl.SpellCap = { undercurl = true, sp = "#b17e28" } - hl.Whitespace = { fg = "#004455" } - end, - }, - }, -} diff --git a/nnm/config/nvim/lua/plugins/undotree.lua b/nnm/config/nvim/lua/plugins/undotree.lua deleted file mode 100644 index 3ebc390..0000000 --- a/nnm/config/nvim/lua/plugins/undotree.lua +++ /dev/null @@ -1,8 +0,0 @@ -return { - { - "mbbill/undotree", - keys = { - { "u", vim.cmd.UndotreeToggle, desc = "Undotree" }, - }, - }, -} diff --git a/nnm/config/zsh/.zshrc b/nnm/config/zsh/.zshrc deleted file mode 100644 index 1ce89e6..0000000 --- a/nnm/config/zsh/.zshrc +++ /dev/null @@ -1,7 +0,0 @@ -ZSH=/usr/share/ohmyzsh -ZSH_THEME=agnoster -source $ZSH/oh-my-zsh.sh - -export TERM=xterm-256color - -prompt_context() { prompt_segment magenta black "%n" } diff --git a/nnm/install.sh b/nnm/install.sh deleted file mode 100755 index fc91390..0000000 --- a/nnm/install.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -set -euo pipefail - -command -v podman >/dev/null 2>&1 || { echo "Error: podman not found"; exit 1; } -command -v distrobox >/dev/null 2>&1 || { echo "Error: distrobox not found"; exit 1; } - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CONTAINER="nnm" - -ALA_DATE="$(grep '^ala_date' "$SCRIPT_DIR/manifest.toml" | sed 's/.*"\(.*\)"/\1/')" -BASE_DIGEST="$(grep '^base_image_digest' "$SCRIPT_DIR/manifest.toml" | sed 's/.*"\(.*\)"/\1/')" - -TAG_INPUT="$(find "$SCRIPT_DIR/config" "$SCRIPT_DIR/Containerfile" -type f | sort | xargs -d'\n' sha256sum; cat "$SCRIPT_DIR/packages.list" "$SCRIPT_DIR/manifest.toml")" -IMAGE_TAG="nnm:$(echo -n "$TAG_INPUT" | sha256sum | cut -c1-16)" -IMAGE="localhost/$IMAGE_TAG" - -if ! podman image exists "$IMAGE"; then - echo "==> Building image..." - podman build \ - --build-arg "ALA_DATE=$ALA_DATE" \ - --build-arg "BASE_IMAGE_DIGEST=$BASE_DIGEST" \ - -t "$IMAGE" -f "$SCRIPT_DIR/Containerfile" "$SCRIPT_DIR" -fi - -echo "==> Copying configs to ~/.config/nvimnemodel/..." -mkdir -p ~/.config/nvimnemodel -cp -r "$SCRIPT_DIR/config/"* ~/.config/nvimnemodel/ - -if ! distrobox list 2>/dev/null | grep -qw "$CONTAINER"; then - echo "==> Creating distrobox container..." - NVIDIA_FLAG="" - [[ -d /dev/nvidia0 ]] && NVIDIA_FLAG="--nvidia" - distrobox create --name "$CONTAINER" --image "$IMAGE" $NVIDIA_FLAG -else - echo "==> Upgrading container..." - distrobox upgrade "$CONTAINER" -fi - -echo "==> Installing /usr/local/bin/nnm..." -sudo cp "$SCRIPT_DIR/nnm" /usr/local/bin/nnm -sudo chmod +x /usr/local/bin/nnm - -echo "" -echo "==> nnm installed" -echo " Run 'nnm' to open a container terminal" -echo " Run 'nnm init' to create .envrc for direnv" diff --git a/nnm/manifest.toml b/nnm/manifest.toml deleted file mode 100644 index 31420d0..0000000 --- a/nnm/manifest.toml +++ /dev/null @@ -1,2 +0,0 @@ -ala_date = "2026/05/17" -base_image_digest = "sha256:445aaca11be510adf10b9ddcf30259d4ae4c8474530a8d5d71ca93199184ee70" diff --git a/nnm/nnm b/nnm/nnm deleted file mode 100755 index 54a1c28..0000000 --- a/nnm/nnm +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -euo pipefail - -CONTAINER="nnm" -CONFIG_DIR="$HOME/.config/nvimnemodel" - -case "${1:-}" in - init) - DIR="${2:-$PWD}" - ENVRC="$DIR/.envrc" - if [[ -f "$ENVRC" ]]; then - echo ".envrc already exists at $ENVRC" - else - cat > "$ENVRC" <<-ENVEOF -if [ -z "\${NNM_ACTIVE:-}" ]; then - export NNM_ACTIVE=1 - distrobox-enter $CONTAINER -fi -ENVEOF - echo "Created $ENVRC — run 'direnv allow' to activate" - fi - ;; - -) - shift - exec distrobox-enter "$CONTAINER" -- "$@" - ;; - *) - distrobox-enter "$CONTAINER" -- \ - env XDG_CONFIG_HOME="$CONFIG_DIR" \ - ZDOTDIR="$CONFIG_DIR/zsh" \ - kitty --class="nnm" \ - --config "$CONFIG_DIR/kitty/kitty.conf" \ - --directory "$PWD" \ - zsh /tmp/nnm-kitty.log & - disown - ;; -esac diff --git a/nnm/packages.list b/nnm/packages.list deleted file mode 100644 index 0339b86..0000000 --- a/nnm/packages.list +++ /dev/null @@ -1,8 +0,0 @@ -kitty -neovim -zsh -git -base-devel -mesa -vulkan-intel -vulkan-radeon diff --git a/nnm/pacman.conf b/nnm/pacman.conf deleted file mode 100644 index 252247b..0000000 --- a/nnm/pacman.conf +++ /dev/null @@ -1,11 +0,0 @@ -[options] -HoldPkg = pacman glibc -Architecture = auto -SigLevel = Required DatabaseOptional -LocalFileSigLevel = Optional - -[core] -Server = https://archive.archlinux.org/repos/$ala_date/$repo/os/$arch - -[extra] -Server = https://archive.archlinux.org/repos/$ala_date/$repo/os/$arch diff --git a/nnm/uninstall.sh b/nnm/uninstall.sh deleted file mode 100755 index 17a4510..0000000 --- a/nnm/uninstall.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -euo pipefail - -echo "This will remove the nnm container and /usr/local/bin/nnm." -read -rp "Are you sure? [y/N] " confirm -[[ "$confirm" =~ ^[yY] ]] || { echo "Aborted."; exit 0; } - -echo "==> Removing container..." -distrobox rm --force nnm 2>/dev/null || true - -echo "==> Removing image..." -podman image rm localhost/nnm:* 2>/dev/null || true - -echo "==> Removing /usr/local/bin/nnm..." -sudo rm -f /usr/local/bin/nnm - -echo "==> nnm uninstalled." diff --git a/src/Containerfile b/src/Containerfile new file mode 100644 index 0000000..682c588 --- /dev/null +++ b/src/Containerfile @@ -0,0 +1,15 @@ +ARG PYTHON_IMAGE +FROM ${PYTHON_IMAGE} + +RUN pip install --no-cache-dir docker + +ARG DOCKER_VERSION +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz" \ + | tar xz -C /usr/local/bin --strip-components=1 docker/docker \ + && apt-get purge -y curl ca-certificates && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY src/dnc /opt/dnc/lib/dnc +ENV PYTHONPATH=/opt/dnc/lib:$PYTHONPATH +ENTRYPOINT ["python3", "-m", "dnc"] diff --git a/src/dnc/__init__.py b/src/dnc/__init__.py new file mode 100644 index 0000000..6fd2df2 --- /dev/null +++ b/src/dnc/__init__.py @@ -0,0 +1,26 @@ +__version__ = "0.2.0" +DEFAULT_IMAGE = "ubuntu:latest" +DNC_FILE = ".dnc" + +LABEL_CONTAINER = "dnc.container" +LABEL_PROJECT = "dnc.project" +LABEL_VERSION = "dnc.version" +LABEL_IMAGE = "dnc.image" +LABEL_GPU = "dnc.gpu" + +HOST_CACHE = "/opt/dnc/host" +INIT_SRC = "/opt/dnc/lib/dnc/init.sh" +INIT_DST = f"{HOST_CACHE}/init.sh" +ENTRY_SRC = "/opt/dnc/lib/dnc/entry.sh" +ENTRY_DST = f"{HOST_CACHE}/entry.sh" + +HOST_GPU = "DNC_HOST_GPU" +HOST_DRI = "DNC_HOST_DRI" +HOST_KFD = "DNC_HOST_KFD" +HOST_UID = "DNC_HOST_UID" +HOST_GID = "DNC_HOST_GID" +HOST_USER = "DNC_HOST_USER" +HOST_HOME = "DNC_HOST_HOME" +HOST_SHELL = "DNC_HOST_SHELL" +HOST_CACHE_HOST = "DNC_CACHE_HOST" +HOST_GIDS = "DNC_HOST_GIDS" diff --git a/src/dnc/__main__.py b/src/dnc/__main__.py new file mode 100644 index 0000000..32d5c48 --- /dev/null +++ b/src/dnc/__main__.py @@ -0,0 +1,3 @@ +from dnc.cli import main + +main() diff --git a/src/dnc/agents.py b/src/dnc/agents.py new file mode 100644 index 0000000..fec9615 --- /dev/null +++ b/src/dnc/agents.py @@ -0,0 +1,56 @@ +import os + +DNC_MD_CONTENT = """# dnc + +This project uses dnc (dockernecontainer). When `.dnc` is present, +run ALL commands inside the container by prefixing them with `dnc -`: + + dnc - nvim + dnc - npm install + dnc - python manage.py migrate +""" + +OPENCODE_INSTRUCTIONS = """## dnc + +Refer to DNC.md for container usage instructions. +""" + +CURSOR_RULES = """description: dnc container +globs: * +--- +Refer to DNC.md for container usage instructions. +""" + + +def create_dnc_md(project_dir: str): + path = os.path.join(project_dir, "DNC.md") + if os.path.exists(path): + print("DNC.md already exists (use --force to overwrite)") + return + with open(path, "w") as f: + f.write(DNC_MD_CONTENT) + print("Created DNC.md") + + +def create_opencode(project_dir: str): + opencode_dir = os.path.join(project_dir, ".opencode") + os.makedirs(opencode_dir, exist_ok=True) + path = os.path.join(opencode_dir, "instructions.md") + if os.path.exists(path): + print(".opencode/instructions.md already exists (use --force to overwrite)") + return + with open(path, "w") as f: + f.write(OPENCODE_INSTRUCTIONS) + print("Created .opencode/instructions.md") + + +def create_cursor(project_dir: str): + cursor_dir = os.path.join(project_dir, ".cursor", "rules") + os.makedirs(cursor_dir, exist_ok=True) + path = os.path.join(cursor_dir, "dnc.mdc") + if os.path.exists(path): + print(".cursor/rules/dnc.mdc already exists (use --force to overwrite)") + return + with open(path, "w") as f: + f.write(CURSOR_RULES) + print("Created .cursor/rules/dnc.mdc") diff --git a/src/dnc/cli.py b/src/dnc/cli.py new file mode 100644 index 0000000..68b6f72 --- /dev/null +++ b/src/dnc/cli.py @@ -0,0 +1,184 @@ +import argparse +import os +import sys + +from dnc import __version__, DEFAULT_IMAGE, DNC_FILE +from dnc import agents as agents_mod +from dnc import container as ctr + + +def resolve_container_name(): + current = os.getcwd() + while True: + path = os.path.join(current, DNC_FILE) + if os.path.exists(path): + name = _read_field(path, "container") + if name: + return current, name + print(f"Error: {DNC_FILE} at {current} is malformed") + sys.exit(1) + parent = os.path.dirname(current) + if parent == current: + return None, None + current = parent + + +def _read_field(path: str, field: str) -> str | None: + with open(path) as f: + for line in f: + line = line.strip() + if line.startswith(f"{field} = "): + return line.split("=", 1)[1].strip() + return None + + +def _write_dnc(dnc_dir: str, container_name: str, version: str, + image: str, gpu: str): + path = os.path.join(dnc_dir, DNC_FILE) + with open(path, "w") as f: + f.write(f"container = {container_name}\n") + f.write(f"version = {version}\n") + f.write(f"image = {image}\n") + f.write(f"gpu = {gpu}\n") + + +def _show_help(): + print(f"""dnc — dockernecontainer v{__version__} + +Usage: + dnc Enter container + dnc - Run command in container + dnc setup [--image IMG] Create container for this project + dnc rm Remove container + .dnc + dnc list List all dnc containers + dnc info Show container details + dnc agent [--opencode] [--cursor] Create DNC.md + agent rules + dnc version Show version + dnc help Show this help""") + + +def main(): + args = sys.argv[1:] + + if not args: + dnc_dir, name = resolve_container_name() + if name is None: + print("No .dnc found. Run 'dnc setup' first.") + sys.exit(1) + ctr.enter(name) + return + + cmd = args[0] + + if cmd in ("--help", "-h", "help"): + _show_help() + return + + if cmd == "version": + print(f"dnc v{__version__}") + return + + if cmd in ("list", "ls"): + containers = ctr.list_containers() + if not containers: + print("No dnc containers found.") + return + print(f"{'NAME':20} {'PROJECT':30} {'IMAGE':25} {'GPU':10} {'STATUS':10}") + print("-" * 95) + for c in containers: + proj = c["project"] + if len(proj) > 30: + proj = proj[:28] + ".." + print( + f"{c['name']:20} {proj:30} " + f"{c['image'][:23]:25} {c['gpu']:10} {c['status']:10}" + ) + return + + if cmd == "setup": + parser = argparse.ArgumentParser(prog="dnc setup") + parser.add_argument("--image", "-i", default=None) + parsed, _ = parser.parse_known_args(args[1:]) + + project_dir = os.getcwd() + dnc_path = os.path.join(project_dir, DNC_FILE) + if os.path.exists(dnc_path): + print(f"{DNC_FILE} already exists. Run 'dnc rm' first.") + return + + image = parsed.image or DEFAULT_IMAGE + name = ctr._default_container_name(project_dir) + gpu = ctr._gpu() + + ctr._pull_image(image) + + print(f"Creating container '{name}'...") + container = ctr.create(image, name, project_dir) + + _write_dnc(project_dir, name, __version__, image, gpu) + print(f"Created {DNC_FILE} — container starting in background.") + return + + if cmd == "info": + dnc_dir, name = resolve_container_name() + if name is None: + print("No .dnc found.") + sys.exit(1) + info = ctr.inspect(name) + print(f"Container: {info['name']}") + print(f"Status: {info['status']}") + print(f"Image: {info['image']}") + print(f"GPU: {info['gpu']}") + print(f"Project: {info['project']}") + print(f"Created: {info['created']}") + print("\nLabels:") + for k, v in info["labels"].items(): + print(f" {k}: {v}") + print("\nMounts:") + for m in info["mounts"]: + src = m.get("source") or "(anonymous)" + print(f" {src} → {m['destination']} ({m['mode']})") + print("\nEnvironment:") + for k, v in sorted(info["env"].items()): + if not k.startswith("DNC_"): + display = v[:80] + "..." if len(v) > 80 else v + print(f" {k}={display}") + return + + if cmd == "agent": + parser = argparse.ArgumentParser(prog="dnc agent") + parser.add_argument("--opencode", action="store_true") + parser.add_argument("--cursor", action="store_true") + parsed, _ = parser.parse_known_args(args[1:]) + agents_mod.create_dnc_md(os.getcwd()) + if parsed.opencode: + agents_mod.create_opencode(os.getcwd()) + if parsed.cursor: + agents_mod.create_cursor(os.getcwd()) + return + + if cmd == "rm": + dnc_dir, name = resolve_container_name() + if name is None: + print("No .dnc found.") + sys.exit(1) + ctr.remove(name) + os.remove(os.path.join(dnc_dir, DNC_FILE)) + print(f"Removed container '{name}' and {DNC_FILE}.") + return + + if cmd == "-": + dnc_dir, name = resolve_container_name() + if name is None: + print("No .dnc found. Run 'dnc setup' first.") + sys.exit(1) + rest = args[1:] + if not rest: + ctr.enter(name) + else: + interactive = sys.stdin.isatty() + ctr.exec_passthrough(name, rest, interactive=interactive) + return + + _show_help() + sys.exit(1) diff --git a/src/dnc/container.py b/src/dnc/container.py new file mode 100644 index 0000000..6368ecb --- /dev/null +++ b/src/dnc/container.py @@ -0,0 +1,442 @@ +import hashlib +import os +import re +import shutil +import subprocess +import sys +import time + +import docker +from docker.types import DeviceRequest + +from dnc import ( + __version__, + ENTRY_DST, + ENTRY_SRC, + HOST_CACHE, + HOST_CACHE_HOST, + HOST_DRI, + HOST_GID, + HOST_GIDS, + HOST_HOME, + HOST_KFD, + HOST_SHELL, + HOST_UID, + HOST_USER, + HOST_GPU, + INIT_DST, + INIT_SRC, +) + +_FORWARD_ENV = { + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_SESSION_TYPE", + "DBUS_SESSION_BUS_ADDRESS", + "XDG_RUNTIME_DIR", + "PULSE_SERVER", + "PIPEWIRE_RUNTIME_DIR", + "SSH_AUTH_SOCK", + "TERM", + "LANG", + "NVIDIA_VISIBLE_DEVICES", + "NVIDIA_DRIVER_CAPABILITIES", +} + +_SKIP_ENV = { + "HOME", + "PATH", + "PWD", + "SHELL", + "container", + "CONTAINER_ID", + HOST_GPU, + HOST_DRI, + HOST_KFD, + HOST_UID, + HOST_GID, + HOST_GIDS, + HOST_USER, + HOST_HOME, + HOST_SHELL, + HOST_CACHE_HOST, +} + + +def _docker_client(): + try: + return docker.from_env() + except docker.errors.DockerException as e: + print("Error: cannot connect to Docker daemon.", file=sys.stderr) + print(f" {e}", file=sys.stderr) + sys.exit(1) + + +def _host_cache_setup(): + os.makedirs(HOST_CACHE, exist_ok=True) + shutil.copy2(INIT_SRC, INIT_DST) + os.chmod(INIT_DST, 0o755) + shutil.copy2(ENTRY_SRC, ENTRY_DST) + os.chmod(ENTRY_DST, 0o755) + + +def _default_container_name(project_dir: str) -> str: + safe = re.sub(r"[^a-zA-Z0-9_-]", "_", os.path.basename(project_dir)) + h = hashlib.sha256(project_dir.encode()).hexdigest()[:8] + return f"dnc-{safe}-{h}" + + +def _gpu() -> str: + return os.environ.get(HOST_GPU, "none") + + +def _pull_image(image: str): + client = _docker_client() + try: + client.images.get(image) + return + except docker.errors.ImageNotFound: + pass + try: + print(f"Pulling {image}...") + client.images.pull(image) + except docker.errors.ImageNotFound: + print(f"Error: image '{image}' not found.", file=sys.stderr) + print(f" Check the name and try again.", file=sys.stderr) + sys.exit(1) + except docker.errors.APIError as e: + print(f"Error: failed to pull image '{image}': {e}", file=sys.stderr) + sys.exit(1) + + +def _require_container(client, name: str): + try: + return client.containers.get(name) + except docker.errors.NotFound: + print(f"Error: container '{name}' not found.", file=sys.stderr) + print(f" It may have been deleted outside dnc.", file=sys.stderr) + sys.exit(1) + + +def create(image: str, name: str, project_dir: str): + _host_cache_setup() + client = _docker_client() + gpu = _gpu() + + uid = int(os.environ[HOST_UID]) + gid = int(os.environ[HOST_GID]) + host_home = os.environ[HOST_HOME] + shell = os.environ.get(HOST_SHELL, "/bin/bash") + host_cache = os.environ[HOST_CACHE_HOST] + + volumes = { + host_home: {"bind": "/run/host/home", "mode": "ro"}, + project_dir: {"bind": project_dir, "mode": "rw"}, + "/tmp": {"bind": "/tmp", "mode": "rw"}, + f"/run/user/{uid}": {"bind": f"/run/user/{uid}", "mode": "rw"}, + "/tmp/.X11-unix": {"bind": "/tmp/.X11-unix", "mode": "rw"}, + "/etc/hosts": {"bind": "/etc/hosts", "mode": "ro"}, + "/etc/resolv.conf": {"bind": "/etc/resolv.conf", "mode": "ro"}, + f"{host_cache}/init.sh": {"bind": "/usr/bin/dnc-init", "mode": "ro"}, + f"{host_cache}/entry.sh": {"bind": "/usr/bin/dnc-entry", "mode": "ro"}, + } + + if os.path.exists("/etc/hostname"): + volumes["/etc/hostname"] = {"bind": "/etc/hostname", "mode": "ro"} + + host_dri = os.environ.get(HOST_DRI, "") + host_kfd = os.environ.get(HOST_KFD, "") + + device_requests = [] + if gpu == "nvidia": + device_requests = [ + DeviceRequest(count=-1, capabilities=[["gpu"]]) + ] + + if host_dri: + volumes["/dev/dri"] = {"bind": "/dev/dri", "mode": "rw"} + + if host_kfd: + volumes["/dev/kfd"] = {"bind": "/dev/kfd", "mode": "rw"} + + env = { + "HOME": host_home, + "SHELL": os.path.basename(shell), + "container": "docker", + "CONTAINER_ID": name, + HOST_UID: uid, + HOST_GID: gid, + HOST_USER: os.environ[HOST_USER], + HOST_HOME: host_home, + HOST_SHELL: shell, + } + + for key in _FORWARD_ENV: + val = os.environ.get(key) + if val: + env[key] = val + + if gpu == "nvidia": + env["NVIDIA_VISIBLE_DEVICES"] = "all" + env["NVIDIA_DRIVER_CAPABILITIES"] = "all" + + host_gids = os.environ.get(HOST_GIDS, "") + group_add = [f"{g}" for g in host_gids.split() if g] if host_gids else None + + device_cgroup_rules = [] + if host_dri: + device_cgroup_rules.append("c 226:* rwm") + if host_kfd: + device_cgroup_rules.append("c 253:* rwm") + + labels = { + "dnc.container": "true", + "dnc.project": project_dir, + "dnc.version": __version__, + "dnc.image": image, + "dnc.gpu": gpu, + } + + try: + container = client.containers.create( + image, + name=name, + entrypoint=["/usr/bin/dnc-init"], + command=["/bin/bash"], + stdin_open=True, + tty=True, + cap_add=[ + "SYS_PTRACE", + "SYS_ADMIN", + "NET_RAW", + "NET_ADMIN", + "SYS_NICE", + "IPC_LOCK", + ], + security_opt=["label=disable"], + pid_mode="host", + network_mode="host", + ipc_mode="host", + pids_limit=-1, + user="root:root", + group_add=group_add, + device_cgroup_rules=device_cgroup_rules or None, + environment=env, + volumes=volumes, + device_requests=device_requests, + labels=labels, + detach=True, + ) + container.start() + except docker.errors.APIError as e: + print(f"Error: failed to create/start container '{name}'.", file=sys.stderr) + print(f" {e}", file=sys.stderr) + sys.exit(1) + + return container + + +def _shell_needed(cmd: list[str]) -> bool: + metachars = set('|>&;<>$`\'"!#(){}[]*?~ \t') + return any( + any(c in metachars for c in arg) + for arg in cmd + ) + + +def _ensure_executable(cmd: list[str]) -> list[str]: + if _shell_needed(cmd): + return ["sh", "-c", " ".join(cmd)] + return cmd + + +def _wait_init(container): + max_retries = 120 + for _ in range(max_retries): + try: + exit_code, _ = container.exec_run( + "test -f /.dnc-setup-done", tty=False + ) + if exit_code == 0: + return + except docker.errors.APIError: + pass + time.sleep(0.2) + + +def enter(name: str, cmd: list[str] | None = None): + client = _docker_client() + container = _require_container(client, name) + + if container.status != "running": + try: + container.start() + container.reload() + while container.status != "running": + time.sleep(0.1) + container.reload() + except docker.errors.APIError as e: + print(f"Error: failed to start container '{name}'.", file=sys.stderr) + print(f" {e}", file=sys.stderr) + sys.exit(1) + _wait_init(container) + + uid = os.environ[HOST_UID] + gid = os.environ[HOST_GID] + + env_args = [] + for key, val in os.environ.items(): + if key in _SKIP_ENV: + continue + if '"' in val or "`" in val or "$" in val: + continue + env_args += ["-e", f"{key}={val}"] + + fhs = [ + "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", + "/usr/bin", "/sbin", "/bin", + ] + path = os.environ.get("PATH", "") + for p in fhs: + if p not in path: + path = f"{path}:{p}" + env_args += ["-e", f"PATH={path}"] + + workdir = os.getcwd() + + docker_args = [ + "docker", + "exec", + "-i", + "--user", f"{uid}:{gid}", + ] + if sys.stdin.isatty(): + docker_args.append("-t") + docker_args += [ + "--workdir", workdir, + *env_args, + "--detach-keys", "", + name, + ] + if cmd: + docker_args.extend(_ensure_executable(cmd)) + else: + docker_args.append("/usr/bin/dnc-entry") + + try: + os.execvp("docker", docker_args) + except FileNotFoundError: + print("Error: 'docker' executable not found on PATH.", file=sys.stderr) + sys.exit(1) + + +def exec_passthrough(name: str, cmd: list[str], interactive: bool = False): + if interactive and sys.stdin.isatty(): + enter(name, cmd=cmd) + return + + client = _docker_client() + container = _require_container(client, name) + + if container.status != "running": + try: + container.start() + container.reload() + while container.status != "running": + time.sleep(0.1) + container.reload() + except docker.errors.APIError as e: + print(f"Error: failed to start container '{name}'.", file=sys.stderr) + print(f" {e}", file=sys.stderr) + sys.exit(1) + _wait_init(container) + + uid = os.environ[HOST_UID] + gid = os.environ[HOST_GID] + + env = {} + for key, val in os.environ.items(): + if key in _SKIP_ENV: + continue + if '"' in val or "`" in val or "$" in val: + continue + env[key] = val + + exit_code, output = container.exec_run( + _ensure_executable(cmd), + user=f"{uid}:{gid}", + workdir=os.getcwd(), + environment=env, + tty=False, + stream=False, + stdin=True, + ) + + if output: + sys.stdout.buffer.write(output) + sys.stdout.buffer.flush() + + sys.exit(exit_code) + + +def list_containers() -> list[dict]: + client = _docker_client() + results = [] + for c in client.containers.list( + all=True, filters={"label": "dnc.container=true"} + ): + lbl = c.labels + results.append({ + "name": c.name, + "project": lbl.get("dnc.project", "?"), + "image": lbl.get("dnc.image", "?"), + "gpu": lbl.get("dnc.gpu", "?"), + "status": c.status, + "created": c.attrs.get("Created", ""), + }) + results.sort(key=lambda x: x["created"], reverse=True) + return results + + +def remove(name: str): + client = _docker_client() + try: + c = client.containers.get(name) + except docker.errors.NotFound: + print(f"Warning: container '{name}' does not exist (already removed?).", file=sys.stderr) + return + c.remove(force=True, v=True) + + +def inspect(name: str) -> dict: + client = _docker_client() + c = _require_container(client, name) + + raw_env = c.attrs.get("Config", {}).get("Env", []) + parsed_env = {} + for e in raw_env: + if "=" in e: + k, v = e.split("=", 1) + parsed_env[k] = v + + mounts = [ + { + "source": m.get("Source"), + "destination": m.get("Destination"), + "mode": m.get("Mode"), + } + for m in c.attrs.get("Mounts", []) + ] + + return { + "name": c.name, + "status": c.status, + "image": c.image.tags[0] if c.image.tags else "?", + "labels": dict(c.labels), + "created": c.attrs.get("Created"), + "env": parsed_env, + "mounts": mounts, + "gpu": c.labels.get("dnc.gpu", "none"), + "project": c.labels.get("dnc.project", "?"), + } diff --git a/src/dnc/entry.sh b/src/dnc/entry.sh new file mode 100644 index 0000000..af3b66b --- /dev/null +++ b/src/dnc/entry.sh @@ -0,0 +1,14 @@ +#!/bin/sh +if [ -d /run/host/home ]; then + for f in .gitconfig .ssh; do + src="/run/host/home/$f" + dst="$HOME/$f" + if [ -e "$src" ] && [ ! -e "$dst" ]; then + cp -r "$src" "$dst" 2>/dev/null || true + fi + done +fi +if [ -x /opt/dnc/entrypoint.sh ]; then + exec /opt/dnc/entrypoint.sh +fi +exec "${SHELL:-/bin/bash}" -l diff --git a/src/dnc/init.sh b/src/dnc/init.sh new file mode 100644 index 0000000..9e6de1e --- /dev/null +++ b/src/dnc/init.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +HOST_USER="${DNC_HOST_USER:-$(id -un)}" +HOST_UID="${DNC_HOST_UID:-1000}" +HOST_GID="${DNC_HOST_GID:-1000}" +HOST_HOME="${DNC_HOST_HOME:-/home/$HOST_USER}" + +if ! getent group "$HOST_GID" >/dev/null 2>&1; then + groupadd -g "$HOST_GID" "$HOST_USER" 2>/dev/null || \ + printf "%s:x:%s:\n" "$HOST_USER" "$HOST_GID" >> /etc/group +fi + +EXISTING_USER=$(getent passwd "$HOST_UID" 2>/dev/null | cut -d: -f1) +if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$HOST_USER" ]; then + EXISTING_GROUP=$(getent group "$HOST_GID" 2>/dev/null | cut -d: -f1) + if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$HOST_USER" ]; then + groupmod -n "$HOST_USER" "$EXISTING_GROUP" 2>/dev/null || true + fi + usermod -l "$HOST_USER" -s /bin/bash -d "$HOST_HOME" \ + -m "$EXISTING_USER" 2>/dev/null || true +elif [ -z "$EXISTING_USER" ]; then + useradd -M -u "$HOST_UID" -g "$HOST_GID" -s /bin/bash \ + -d "$HOST_HOME" "$HOST_USER" 2>/dev/null || true +fi +mkdir -p "$HOST_HOME" +chown "$HOST_UID:$HOST_GID" "$HOST_HOME" 2>/dev/null || true +SUDO_USER="$HOST_USER" + +if ! command -v sudo >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get update -qq && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo || true + elif command -v dnf >/dev/null 2>&1; then + dnf install -y sudo || true + elif command -v yum >/dev/null 2>&1; then + yum install -y sudo || true + elif command -v apk >/dev/null 2>&1; then + apk add sudo || true + elif command -v zypper >/dev/null 2>&1; then + zypper install -y sudo || true + fi +fi + +if command -v sudo >/dev/null 2>&1; then + mkdir -p /etc/sudoers.d + printf "%s ALL=(ALL) NOPASSWD:ALL\n" "$SUDO_USER" > /etc/sudoers.d/dnc-user + chmod 0440 /etc/sudoers.d/dnc-user + if sudo --version 2>&1 | head -1 | grep -qv sudo-rs; then + printf "Defaults !requiretty\n" >> /etc/sudoers + fi +fi + +touch /.dnc-setup-done + +exec "$@" diff --git a/src/version.sh b/src/version.sh new file mode 100644 index 0000000..ce1dce4 --- /dev/null +++ b/src/version.sh @@ -0,0 +1,2 @@ +PYTHON_IMAGE="python:3.14-slim" +DOCKER_VERSION="29.5.2"