diff --git a/registry/coder/modules/vault-cli/README.md b/registry/coder/modules/vault-cli/README.md new file mode 100644 index 000000000..776ec6bee --- /dev/null +++ b/registry/coder/modules/vault-cli/README.md @@ -0,0 +1,107 @@ +--- +display_name: Vault CLI +description: Installs the Hashicorp Vault CLI and optionally configures token authentication +icon: ../../../../.icons/vault.svg +verified: true +tags: [helper, integration, vault, cli] +--- + +# Vault CLI + +Installs the [Vault](https://www.vaultproject.io/) CLI and optionally configures token authentication. This module focuses on CLI installation and can be used standalone or as a base for other authentication methods. + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" +} +``` + +## Prerequisites + +The following tools are required in the workspace image: + +- **HTTP client**: `curl`, `wget`, or `busybox` (at least one) +- **Archive utility**: `unzip` or `busybox` (at least one) +- **jq**: Optional but recommended for reliable JSON parsing (falls back to sed if not available) + +## With Token Authentication + +If you have a Vault token, you can provide it to automatically configure authentication: + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_token = var.vault_token # Optional +} +``` + +## Examples + +### Basic Installation (CLI Only) + +Install the Vault CLI without any authentication: + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" +} +``` + +### With Specific Version + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_cli_version = "1.15.0" +} +``` + +### Custom Installation Directory + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + install_dir = "/home/coder/bin" +} +``` + +### With Vault Enterprise Namespace + +For Vault Enterprise users who need to specify a namespace: + +```tf +module "vault_cli" { + source = "registry.coder.com/coder/vault-cli/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_token = var.vault_token + vault_namespace = "admin/my-namespace" +} +``` + +## Related Modules + +For more advanced authentication methods, see: + +- [vault-github](https://registry.coder.com/modules/coder/vault-github) - Authenticate with Vault using GitHub tokens +- [vault-jwt](https://registry.coder.com/modules/coder/vault-jwt) - Authenticate with Vault using OIDC/JWT + +For simple token-based authentication, see: + +- [vault-token](https://registry.coder.com/modules/coder/vault-token) - Authenticate with Vault using a token diff --git a/registry/coder/modules/vault-cli/main.tf b/registry/coder/modules/vault-cli/main.tf new file mode 100644 index 000000000..eaacb66b0 --- /dev/null +++ b/registry/coder/modules/vault-cli/main.tf @@ -0,0 +1,90 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_token" { + type = string + description = "The Vault token to use for authentication. If not provided, only the CLI will be installed." + default = "" + sensitive = true +} + +variable "install_dir" { + type = string + description = "The directory to install the Vault CLI to." + default = "/usr/local/bin" +} + +variable "vault_cli_version" { + type = string + description = "The version of the Vault CLI to install." + default = "latest" + validation { + condition = var.vault_cli_version == "latest" || can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.vault_cli_version)) + error_message = "vault_cli_version must be either 'latest' or a semantic version (e.g., '1.15.0')." + } +} + +variable "vault_namespace" { + type = string + description = "The Vault Enterprise namespace to use. If not provided, no namespace will be configured." + default = null +} + +data "coder_workspace" "me" {} + +resource "coder_script" "vault_cli" { + agent_id = var.agent_id + display_name = "Vault CLI" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + VAULT_ADDR = var.vault_addr + VAULT_TOKEN = var.vault_token + INSTALL_DIR = var.install_dir + VAULT_CLI_VERSION = var.vault_cli_version + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +resource "coder_env" "vault_token" { + count = var.vault_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "VAULT_TOKEN" + value = var.vault_token +} + +resource "coder_env" "vault_namespace" { + count = var.vault_namespace != null ? 1 : 0 + agent_id = var.agent_id + name = "VAULT_NAMESPACE" + value = var.vault_namespace +} + +output "vault_cli_version" { + description = "The version of the Vault CLI that was installed." + value = var.vault_cli_version +} diff --git a/registry/coder/modules/vault-cli/main.tftest.hcl b/registry/coder/modules/vault-cli/main.tftest.hcl new file mode 100644 index 000000000..94a9b7aca --- /dev/null +++ b/registry/coder/modules/vault-cli/main.tftest.hcl @@ -0,0 +1,165 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent-id" + vault_addr = "https://vault.example.com" +} + +run "test_vault_cli_without_token" { + assert { + condition = resource.coder_script.vault_cli.display_name == "Vault CLI" + error_message = "Display name should be 'Vault CLI'" + } + + assert { + condition = resource.coder_env.vault_addr.name == "VAULT_ADDR" + error_message = "VAULT_ADDR environment variable should be set" + } + + assert { + condition = resource.coder_env.vault_addr.value == "https://vault.example.com" + error_message = "VAULT_ADDR should match the provided vault_addr" + } + + assert { + condition = length(resource.coder_env.vault_token) == 0 + error_message = "VAULT_TOKEN should not be set when vault_token is not provided" + } + + assert { + condition = length(resource.coder_env.vault_namespace) == 0 + error_message = "VAULT_NAMESPACE should not be set when vault_namespace is not provided" + } +} + +run "test_vault_cli_with_token" { + variables { + vault_token = "test-vault-token" + } + + assert { + condition = resource.coder_script.vault_cli.display_name == "Vault CLI" + error_message = "Display name should be 'Vault CLI'" + } + + assert { + condition = resource.coder_env.vault_addr.name == "VAULT_ADDR" + error_message = "VAULT_ADDR environment variable should be set" + } + + assert { + condition = length(resource.coder_env.vault_token) == 1 + error_message = "VAULT_TOKEN should be set when vault_token is provided" + } + + assert { + condition = resource.coder_env.vault_token[0].name == "VAULT_TOKEN" + error_message = "VAULT_TOKEN environment variable name should be correct" + } + + assert { + condition = resource.coder_env.vault_token[0].value == "test-vault-token" + error_message = "VAULT_TOKEN should match the provided vault_token" + } +} + +run "test_vault_cli_custom_version" { + variables { + vault_cli_version = "1.15.0" + } + + assert { + condition = output.vault_cli_version == "1.15.0" + error_message = "Vault CLI version output should match the provided version" + } +} + +run "test_vault_cli_custom_install_dir" { + variables { + install_dir = "/custom/install/dir" + } + + assert { + condition = resource.coder_script.vault_cli.display_name == "Vault CLI" + error_message = "Display name should be 'Vault CLI'" + } +} + +run "test_vault_cli_invalid_version" { + command = plan + + variables { + vault_cli_version = "invalid-version" + } + + expect_failures = [var.vault_cli_version] +} + +run "test_vault_cli_valid_semver" { + variables { + vault_cli_version = "1.18.3" + } + + assert { + condition = output.vault_cli_version == "1.18.3" + error_message = "Vault CLI version output should match the provided version" + } +} + +run "test_vault_cli_rejects_v_prefix" { + command = plan + + variables { + vault_cli_version = "v1.18.3" + } + + expect_failures = [var.vault_cli_version] +} + +run "test_vault_cli_with_namespace" { + variables { + vault_namespace = "admin/my-namespace" + } + + assert { + condition = length(resource.coder_env.vault_namespace) == 1 + error_message = "VAULT_NAMESPACE should be set when vault_namespace is provided" + } + + assert { + condition = resource.coder_env.vault_namespace[0].name == "VAULT_NAMESPACE" + error_message = "VAULT_NAMESPACE environment variable name should be correct" + } + + assert { + condition = resource.coder_env.vault_namespace[0].value == "admin/my-namespace" + error_message = "VAULT_NAMESPACE should match the provided vault_namespace" + } +} + +run "test_vault_cli_with_token_and_namespace" { + variables { + vault_token = "test-vault-token" + vault_namespace = "admin/my-namespace" + } + + assert { + condition = length(resource.coder_env.vault_token) == 1 + error_message = "VAULT_TOKEN should be set when vault_token is provided" + } + + assert { + condition = length(resource.coder_env.vault_namespace) == 1 + error_message = "VAULT_NAMESPACE should be set when vault_namespace is provided" + } + + assert { + condition = resource.coder_env.vault_token[0].value == "test-vault-token" + error_message = "VAULT_TOKEN should match the provided vault_token" + } + + assert { + condition = resource.coder_env.vault_namespace[0].value == "admin/my-namespace" + error_message = "VAULT_NAMESPACE should match the provided vault_namespace" + } +} diff --git a/registry/coder/modules/vault-cli/run.sh b/registry/coder/modules/vault-cli/run.sh new file mode 100644 index 000000000..a1917f994 --- /dev/null +++ b/registry/coder/modules/vault-cli/run.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +VAULT_ADDR=${VAULT_ADDR} +VAULT_TOKEN=${VAULT_TOKEN} +INSTALL_DIR=${INSTALL_DIR} +VAULT_CLI_VERSION=${VAULT_CLI_VERSION} + +# Fetch URL content. If dest is provided, write to file; otherwise output to stdout. +# Usage: fetch [dest] +fetch() { + url="$1" + dest="$${2:-}" + + # Detect HTTP client on first run + if [ -z "$${HTTP_CLIENT:-}" ]; then + if command -v curl > /dev/null 2>&1; then + HTTP_CLIENT="curl" + elif command -v wget > /dev/null 2>&1; then + HTTP_CLIENT="wget" + elif command -v busybox > /dev/null 2>&1; then + HTTP_CLIENT="busybox" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + return 1 + fi + fi + + if [ -n "$${dest}" ]; then + # shellcheck disable=SC2195 + case "$${HTTP_CLIENT}" in + curl) curl -sSL --fail "$${url}" -o "$${dest}" ;; + wget) wget -O "$${dest}" "$${url}" ;; + busybox) busybox wget -O "$${dest}" "$${url}" ;; + esac + else + # shellcheck disable=SC2195 + case "$${HTTP_CLIENT}" in + curl) curl -sSL --fail "$${url}" ;; + wget) wget -qO- "$${url}" ;; + busybox) busybox wget -qO- "$${url}" ;; + esac + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + return 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: %s\n" "$${ARCH}" + return 1 + fi + + # Determine OS and validate + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + if [ "$${OS}" != "linux" ] && [ "$${OS}" != "darwin" ]; then + printf "Unsupported OS: %s. Only linux and darwin are supported.\n" "$${OS}" + return 1 + fi + + # Fetch release information from HashiCorp API + if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then + API_URL="https://api.releases.hashicorp.com/v1/releases/vault/latest" + else + API_URL="https://api.releases.hashicorp.com/v1/releases/vault/$${VAULT_CLI_VERSION}" + fi + + API_RESPONSE=$(fetch "$${API_URL}") + if [ -z "$${API_RESPONSE}" ]; then + printf "Failed to fetch release information from HashiCorp API.\n" + return 1 + fi + + # Parse version and download URL from API response + if command -v jq > /dev/null 2>&1; then + VAULT_CLI_VERSION=$(printf '%s' "$${API_RESPONSE}" | jq -r '.version') + DOWNLOAD_URL=$(printf '%s' "$${API_RESPONSE}" | jq -r --arg os "$${OS}" --arg arch "$${ARCH}" '.builds[] | select(.os == $os and .arch == $arch) | .url') + else + VAULT_CLI_VERSION=$(printf '%s' "$${API_RESPONSE}" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') + # Fallback: construct URL manually if jq not available + DOWNLOAD_URL="https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_$${OS}_$${ARCH}.zip" + fi + + if [ -z "$${VAULT_CLI_VERSION}" ]; then + printf "Failed to determine Vault version.\n" + return 1 + fi + + if [ -z "$${DOWNLOAD_URL}" ]; then + printf "Failed to determine download URL for Vault %s (%s/%s).\n" "$${VAULT_CLI_VERSION}" "$${OS}" "$${ARCH}" + return 1 + fi + + printf "Vault version: %s\n" "$${VAULT_CLI_VERSION}" + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ "$${installation_needed}" = "1" ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "$${VAULT_CLI_VERSION}" + fi + + # Create temporary directory for download + TEMP_DIR=$(mktemp -d) + cd "$${TEMP_DIR}" || return 1 + + printf "Downloading from %s\n" "$${DOWNLOAD_URL}" + if ! fetch "$${DOWNLOAD_URL}" vault.zip; then + printf "Failed to download Vault.\n" + rm -rf "$${TEMP_DIR}" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + rm -rf "$${TEMP_DIR}" + return 1 + fi + + # Install to the specified directory + if [ -n "$${INSTALL_DIR}" ] && [ -w "$${INSTALL_DIR}" ]; then + mv vault "$${INSTALL_DIR}/vault" + printf "Vault installed to %s successfully!\n\n" "$${INSTALL_DIR}" + elif [ -n "$${INSTALL_DIR}" ] && [ ! -w "$${INSTALL_DIR}" ]; then + # Try with sudo if install dir specified but not writable + if sudo mv vault "$${INSTALL_DIR}/vault" 2> /dev/null; then + printf "Vault installed to %s successfully!\n\n" "$${INSTALL_DIR}" + else + printf "Warning: Cannot write to %s. " "$${INSTALL_DIR}" + mkdir -p ~/.local/bin + if mv vault ~/.local/bin/vault; then + printf "Installed to ~/.local/bin instead.\n" + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + else + printf "Failed to install Vault.\n" + rm -rf "$${TEMP_DIR}" + return 1 + fi + fi + elif sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + rm -rf "$${TEMP_DIR}" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + + # Clean up temp directory + rm -rf "$${TEMP_DIR}" + fi + return 0 +} + +# Run installation +if ! install; then + printf "Failed to install Vault CLI.\n" + exit 1 +fi + +# Indicate token configuration status +if [ -n "$${VAULT_TOKEN}" ]; then + printf "Vault token has been configured via VAULT_TOKEN environment variable.\n" +else + printf "No Vault token provided. Use 'vault login' or set VAULT_TOKEN to authenticate.\n" +fi