diff --git a/.icons/python.svg b/.icons/python.svg new file mode 100644 index 000000000..e1b14ec64 --- /dev/null +++ b/.icons/python.svg @@ -0,0 +1 @@ +Python \ No newline at end of file diff --git a/registry/attractivetoad/.images/avatar.png b/registry/attractivetoad/.images/avatar.png new file mode 100644 index 000000000..0fd6e6477 Binary files /dev/null and b/registry/attractivetoad/.images/avatar.png differ diff --git a/registry/attractivetoad/README.md b/registry/attractivetoad/README.md new file mode 100644 index 000000000..8d90583e4 --- /dev/null +++ b/registry/attractivetoad/README.md @@ -0,0 +1,15 @@ +--- +display_name: AttractiveToad +bio: Community modules for Coder workspaces. +github: AttractiveToad +avatar: ./.images/avatar.png +status: community +--- + +# AttractiveToad + +Community modules for Coder workspaces. + +## Modules + +- **python**: Install and manage a configurable Python version with pyenv on Debian/Ubuntu workspaces. diff --git a/registry/attractivetoad/modules/python/README.md b/registry/attractivetoad/modules/python/README.md new file mode 100644 index 000000000..53fa8b2a4 --- /dev/null +++ b/registry/attractivetoad/modules/python/README.md @@ -0,0 +1,49 @@ +--- +display_name: Python +description: Install and manage a configurable Python version with pyenv +icon: ../../../../.icons/python.svg +maintainer_github: AttractiveToad +verified: false +tags: [helper, python] +--- + +# Python + +Installs pyenv and uses it to build and select a configurable Python version on Debian/Ubuntu workspaces. The module installs the required build dependencies with `apt-get` and skips Python builds that already exist. + +```tf +module "python" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/attractivetoad/python/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +Install and globally select a different Python version: + +```tf +module "python" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/attractivetoad/python/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + + python_version = "3.12.11" +} +``` + +Skip the package index update when your image already has a fresh apt cache: + +```tf +module "python" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/attractivetoad/python/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + + update_packages = false +} +``` diff --git a/registry/attractivetoad/modules/python/main.test.ts b/registry/attractivetoad/modules/python/main.test.ts new file mode 100644 index 000000000..65c885a68 --- /dev/null +++ b/registry/attractivetoad/modules/python/main.test.ts @@ -0,0 +1,10 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "~test"; + +describe("python", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); +}); diff --git a/registry/attractivetoad/modules/python/main.tf b/registry/attractivetoad/modules/python/main.tf new file mode 100644 index 000000000..85e6e7b8b --- /dev/null +++ b/registry/attractivetoad/modules/python/main.tf @@ -0,0 +1,96 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "python_version" { + description = "Python version to install and select globally with pyenv." + type = string + default = "3.13.5" + + validation { + condition = can(regex("^\\d+\\.\\d+\\.\\d+$", var.python_version)) + error_message = "python_version must be an exact semantic version such as 3.13.5." + } +} + +variable "build_packages" { + description = "APT packages required to build Python with pyenv." + type = list(string) + default = [ + "build-essential", + "curl", + "git", + "libbz2-dev", + "libffi-dev", + "liblzma-dev", + "libncursesw5-dev", + "libreadline-dev", + "libsqlite3-dev", + "libssl-dev", + "libxml2-dev", + "libxmlsec1-dev", + "tk-dev", + "xz-utils", + "zlib1g-dev", + ] +} + +variable "pyenv_git_ref" { + description = "Git branch or tag of pyenv to install." + type = string + default = "master" + + validation { + condition = can(regex("^[0-9A-Za-z][0-9A-Za-z._/-]*$", var.pyenv_git_ref)) + error_message = "pyenv_git_ref must be a valid Git branch or tag name." + } +} + +variable "icon" { + description = "Icon to use for the Python install scripts." + type = string + default = "/icon/python.svg" +} + +variable "update_packages" { + description = "Run apt-get update before installing missing packages." + type = bool + default = true +} + +locals { + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_BUILD_PACKAGES_B64 = base64encode(join("\n", var.build_packages)) + ARG_PYTHON_VERSION = var.python_version + ARG_PYENV_GIT_REF = var.pyenv_git_ref + ARG_UPDATE_PACKAGES = tostring(var.update_packages) + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/.coder-modules/attractivetoad/python" + display_name_prefix = "Python" + icon = var.icon + install_script = local.install_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names produced by this module, in run order." + value = module.coder_utils.scripts +} diff --git a/registry/attractivetoad/modules/python/main.tftest.hcl b/registry/attractivetoad/modules/python/main.tftest.hcl new file mode 100644 index 000000000..addf9de6e --- /dev/null +++ b/registry/attractivetoad/modules/python/main.tftest.hcl @@ -0,0 +1,44 @@ +mock_provider "coder" {} + +run "plan_with_defaults" { + command = plan + + variables { + agent_id = "example-agent-id" + } + + assert { + condition = var.python_version == "3.13.5" + error_message = "Expected default Python version." + } + + assert { + condition = var.pyenv_git_ref == "master" + error_message = "Expected pyenv to install from its master branch by default." + } + + assert { + condition = var.update_packages == true + error_message = "Expected package index updates to be enabled by default." + } + + assert { + condition = var.icon == "/icon/python.svg" + error_message = "Expected default icon." + } + + assert { + condition = output.scripts == ["attractivetoad-python-install_script"] + error_message = "Expected scripts output to expose only the install script by default." + } + + assert { + condition = strcontains(local.install_script, "run_apt_get update") + error_message = "Expected apt-get update to retry package-manager lock conflicts." + } + + assert { + condition = strcontains(local.install_script, "pyenv install --skip-existing") + error_message = "Expected Python to be installed with pyenv." + } +} diff --git a/registry/attractivetoad/modules/python/scripts/install.sh.tftpl b/registry/attractivetoad/modules/python/scripts/install.sh.tftpl new file mode 100644 index 000000000..522f5e99c --- /dev/null +++ b/registry/attractivetoad/modules/python/scripts/install.sh.tftpl @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARG_BUILD_PACKAGES_B64='${ARG_BUILD_PACKAGES_B64}' +ARG_PYTHON_VERSION='${ARG_PYTHON_VERSION}' +ARG_PYENV_GIT_REF='${ARG_PYENV_GIT_REF}' +ARG_UPDATE_PACKAGES='${ARG_UPDATE_PACKAGES}' + +if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get not found; this module supports Debian/Ubuntu workspaces only." + exit 1 +fi + +if ! command -v dpkg-query >/dev/null 2>&1; then + echo "dpkg-query not found; cannot determine installed packages." + exit 1 +fi + +run_apt_get() { + local attempt=1 + local max_attempts=60 + local output + local status + + while true; do + set +e + output=$(sudo env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 "$@" 2>&1) + status=$? + set -e + printf '%s\n' "$${output}" + + if [ "$${status}" -eq 0 ]; then + return 0 + fi + + if ! grep -Eq "Could not get lock|Unable to (acquire|lock)" <<< "$${output}"; then + return "$${status}" + fi + + if [ "$${attempt}" -ge "$${max_attempts}" ]; then + echo "Timed out waiting for apt locks after $${max_attempts} attempts." + return "$${status}" + fi + + echo "Another process is using apt; retrying in 5 seconds ($${attempt}/$${max_attempts})..." + attempt=$((attempt + 1)) + sleep 5 + done +} + +mapfile -t build_packages < <(echo -n "$${ARG_BUILD_PACKAGES_B64}" | base64 -d) + +missing_packages=() +for package in "$${build_packages[@]}"; do + if ! dpkg-query -W -f='$${Status}' "$${package}" 2>/dev/null | grep -q "install ok installed"; then + missing_packages+=("$${package}") + fi +done + +if [ "$${#missing_packages[@]}" -eq 0 ]; then + echo "All packages required to build Python are already installed." +else + if [ "$${ARG_UPDATE_PACKAGES}" = "true" ]; then + echo "Updating apt package index..." + run_apt_get update + else + echo "Skipping apt-get update because update_packages=false." + fi + + echo "Installing packages required to build Python: $${missing_packages[*]}" + run_apt_get install -y "$${missing_packages[@]}" +fi + +export PYENV_ROOT="$${HOME}/.pyenv" +export PATH="$${PYENV_ROOT}/bin:$${PATH}" + +if [ -d "$${PYENV_ROOT}/.git" ]; then + echo "Updating pyenv..." + git -C "$${PYENV_ROOT}" fetch --tags origin + git -C "$${PYENV_ROOT}" checkout "$${ARG_PYENV_GIT_REF}" + git -C "$${PYENV_ROOT}" pull --ff-only origin "$${ARG_PYENV_GIT_REF}" || true +else + echo "Installing pyenv..." + git clone --branch "$${ARG_PYENV_GIT_REF}" --depth 1 https://github.com/pyenv/pyenv.git "$${PYENV_ROOT}" +fi + +eval "$(pyenv init -)" + +echo "Installing Python $${ARG_PYTHON_VERSION}..." +pyenv install --skip-existing "$${ARG_PYTHON_VERSION}" +pyenv global "$${ARG_PYTHON_VERSION}" + +profile_block=' +export PYENV_ROOT="$HOME/.pyenv" +[[ -d "$PYENV_ROOT/bin" ]] && export PATH="$PYENV_ROOT/bin:$PATH" +eval "$(pyenv init -)" +' + +for profile in "$${HOME}/.profile" "$${HOME}/.bashrc" "$${HOME}/.zshrc"; do + if ! grep -Fq 'export PYENV_ROOT="$HOME/.pyenv"' "$${profile}" 2>/dev/null; then + printf '\n%s\n' "$${profile_block}" >> "$${profile}" + fi +done + +python --version +pip --version