From 6ed0d234d4f977431130e6b752fc8e64706651a1 Mon Sep 17 00:00:00 2001 From: Pallav Vasa Date: Sat, 17 May 2025 15:51:29 +0000 Subject: [PATCH] init: a repo for various pipelines of workspaces --- .bin/gitops | 9 ++ .vscode/tasks.json | 30 ++++ access.yml | 13 ++ gitops_router.sh | 178 ++++++++++++++++++++++ ssh_router.sh | 207 ++++++++++++++++++++++++++ tests/test_validate_command_access.sh | 47 ++++++ validate_command_access.sh | 83 +++++++++++ 7 files changed, 567 insertions(+) create mode 100755 .bin/gitops create mode 100644 .vscode/tasks.json create mode 100644 access.yml create mode 100644 gitops_router.sh create mode 100644 ssh_router.sh create mode 100755 tests/test_validate_command_access.sh create mode 100644 validate_command_access.sh diff --git a/.bin/gitops b/.bin/gitops new file mode 100755 index 0000000..998fbac --- /dev/null +++ b/.bin/gitops @@ -0,0 +1,9 @@ +#!/bin/bash + +ssh -F /dev/null \ + -o HostName=10.88.0.1 \ + -o Port=22 \ + -o User=infilytics \ + -o IdentityFile=~/.ssh/id_ed25519 \ + -o ProxyCommand=none \ + gitops -- "$@" diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1f6193e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "GitOps(Update): ssh_router.sh", + "type": "shell", + "command": ".bin/gitops update ssh_router", + "group": "build", + "problemMatcher": [], + "detail": "Copy ssh_router.sh to $HOME/.local/bin/" + }, + { + "label": "GitOps(Update): gitops_router.sh", + "type": "shell", + "command": ".bin/gitops update gitops_router", + "group": "build", + "problemMatcher": [], + "detail": "Copy gitops_router.sh to $HOME/.local/bin" + }, + { + "label": "GitOps(Update): validate_command_access.sh", + "type": "shell", + "command": ".bin/gitops update validate_command", + "group": "build", + "problemMatcher": [], + "detail": "Copy validate_command_access.sh to $HOME/.local/bin" + } + ], + "inputs": [] +} diff --git a/access.yml b/access.yml new file mode 100644 index 0000000..af492ac --- /dev/null +++ b/access.yml @@ -0,0 +1,13 @@ +pallav: + fixedArgsCommands: + build: + - base + - workspace + - all + clean: + status: + multiArgsCommands: + remove: + - palak + - param + - darshan diff --git a/gitops_router.sh b/gitops_router.sh new file mode 100644 index 0000000..b891769 --- /dev/null +++ b/gitops_router.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +PERSON="${1:?Missing PERSON argument}" +HOST="alps:3222" +PROTOCOL="http" +REPO=("babbarc/workspaces" "babbarc/workspaces-pipelines" "babbarc/workspaces-sec-alps-infilytics") +BRANCH="master" +LOG_FILE="/tmp/.gitops-router-${PERSON}.log" + +# ───────────────────────────────────────────── +# ANSI color codes +readonly C_RESET='\033[0m' +readonly C_INFO='\033[1;34m' # bold blue +readonly C_WARN='\033[1;33m' # bold yellow +readonly C_ERROR='\033[1;31m' # bold red + +# ───────────────────────────────────────────── +# log with emojis +log() { + local lvl="${1^^}" + shift + local icon color + + case "$lvl" in + INFO) icon="ℹ️" color="$C_INFO" ;; + WARN) icon="⚠️" color="$C_WARN" ;; + ERROR) icon="❌" color="$C_ERROR" ;; + *) icon="🔹" color="$C_RESET" ;; + esac + + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + printf '%b%s [%s] [%s] %s%b\n' \ + "$color" "$icon" "$ts" "$lvl" "$*" "$C_RESET" | + tee -a "$LOG_FILE" +} + +# ───────────────────────────────────────────── +# Build the raw URL for fetching files +geturl() { + local repo="$1" type="$2" file="$3" + printf '%s://%s/%s/%s/branch/%s/%s\n' \ + "$PROTOCOL" "$HOST" "${REPO[$repo]}" "$type" "$BRANCH" "$file" +} + +# ───────────────────────────────────────────── +# Run a local script +run() { + local script="$1" + "$HOME/.local/bin/$script" +} + +# ───────────────────────────────────────────── +# Download & install an artifact +# update [] +update() { + local repo="$1" file="$2" dir="$3" mode="$4" type="${5:-raw}" + local url out + + out="$HOME/$dir/$(basename "$file")" + url="$(geturl "$repo" "$type" "$file")" + + [[ -f "$out" ]] && chmod 700 "$out" + + if curl -fsSL "$url" -o "$out"; then + log INFO "Downloaded $url → $out" + chmod "$mode" "$out" + else + log ERROR "Failed to download $url" + return 1 + fi +} + +# ───────────────────────────────────────────── +# Clean up dangling podman images +clean_images() { + local dangling + dangling="$(podman images -f dangling=true -q)" + if [[ -z "$dangling" ]]; then + log INFO "No dangling images to remove." + else + log WARN "Removing dangling images..." + echo "$dangling" | xargs podman rmi + log INFO "Dangling images removed." + fi +} + +# ───────────────────────────────────────────── +# Remove host podman containers +remove_containers() { + local tokens=("$@") + local flags=() patterns=() containers=() + local valid='^[A-Za-z0-9._-]+$' + + # allow unmatched globs to disappear + shopt -s nullglob + + # separate flags (-f, etc.) from name patterns + for tok in "${tokens[@]}"; do + if [[ "$tok" == -* ]]; then + flags+=("$tok") + else + patterns+=("$tok") + fi + done + + # validate & expand each pattern + for pat in "${patterns[@]}"; do + if [[ ! "$pat" =~ $valid ]]; then + log ERROR "Invalid container name: '$pat'" + shopt -u nullglob + return 1 + fi + containers+=("$pat") + done + + shopt -u nullglob + + if ((${#containers[@]} == 0)); then + log WARN "No containers matched: ${patterns[*]}" + return 0 + fi + + # pass flags *then* containers to podman rm + podman rm "${flags[@]}" "${containers[@]}" +} + +# ───────────────────────────────────────────── +# validate_command [ …] +source "$HOME"/.local/bin/validate_command_access.sh + +# ───────────────────────────────────────────── +# Entry & command parsing +if [[ -z "${SSH_ORIGINAL_COMMAND:-}" ]]; then + log ERROR "No SSH_ORIGINAL_COMMAND provided." + exit 1 +fi + +log INFO "SSH_ORIGINAL_COMMAND: $SSH_ORIGINAL_COMMAND" +read -ra parts <<<"$SSH_ORIGINAL_COMMAND" +cmd="${parts[0]}" +args=("${parts[@]:1}") + +validate_command "$PERSON" "$cmd" "${args[@]}" + +# ───────────────────────────────────────────── +# Dispatch +case "$cmd" in +build) + case "${args[0]}" in + base) podman build --target base -t analytics-backend-base . ;; + workspace) podman build --target base -t analytics-backend-base . ;; + all) podman build -t analytics-backend-workspace . ;; + *) log ERROR "build: invalid arg '${args[0]}'" ;; + esac + ;; +update) + case "${args[0]}" in + containerfile) update 0 Containerfile . 500 ;; + access) update 2 access.yml . 400 ;; + authorized_keys) update 2 access.yml . 400 ;; + ssh_router) update 1 ssh_router.sh .local/bin 500 ;; + gitops_router) update 1 gitops_router.sh .local/bin 500 ;; + validate_command) update 1 validate_command_access.sh .local/bin 500 ;; + home_tar) update 0 home.tar.gz . 500 media ;; + gitconfig) update 0 gitconfig.template . 500 ;; + *) log ERROR "update: invalid arg '${args[0]}'" ;; + esac + ;; +clean) clean_images ;; +status) podman images ;; +remove) remove_containers "${args[@]}" ;; +*) + log ERROR "Unknown command: '$cmd'" + exit 127 + ;; +esac diff --git a/ssh_router.sh b/ssh_router.sh new file mode 100644 index 0000000..dc9f1ff --- /dev/null +++ b/ssh_router.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +PERSON="${1:?Usage: $0 }" +WORKSPACE="${SSH_ORIGINAL_COMMAND:-}" +IMAGE="localhost/analytics-backend-workspace:latest" +DEV_USER="devuser" +XDG_RUNTIME_DIR="/run/user/$(id -u)" +LOG_FILE="/tmp/.ssh-router-${PERSON}.log" + +# ───────────────────────────────────────────── +# ANSI colors & emojis +readonly C_RESET='\033[0m' +readonly C_INFO='\033[1;34m' # blue +readonly C_WARN='\033[1;33m' # yellow +readonly C_ERROR='\033[1;31m' # red + +log() { + local level="${1^^}" + shift + local icon color + case "$level" in + INFO) icon="ℹ️" color="$C_INFO" ;; + WARN) icon="⚠️" color="$C_WARN" ;; + ERROR) icon="❌" color="$C_ERROR" ;; + *) icon="🔹" color="$C_RESET" ;; + esac + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + printf '%b%s [%s] %s%b\n' \ + "$color" "$icon" "$ts" "[$level] $*" "$C_RESET" >>"$LOG_FILE" +} + +# ───────────────────────────────────────────── +# Check for interactive TTY +if [[ ! -t 0 ]]; then + log ERROR "No TTY allocated—refusing to run without an interactive terminal" + echo "Error: No TTY. Use 'ssh -t'" >&2 + exit 1 +fi + +# ───────────────────────────────────────────── +# Default WORKSPACE if empty +if [[ -z "$WORKSPACE" ]]; then + WORKSPACE="$PERSON" + log INFO "Defaulted WORKSPACE → $WORKSPACE" +fi +TMUX_SESSION="${WORKSPACE}|analytics-backend" + +# ───────────────────────────────────────────── +# Ensure Podman socket is up +ensure_podman() { + local sock="$XDG_RUNTIME_DIR/podman/podman.sock" + if [[ ! -S "$sock" ]]; then + log INFO "Starting podman.socket for user $(id -un)" + systemctl --user start podman.socket || { + log ERROR "Failed to start podman.socket" + exit 1 + } + sleep 1 + fi + [[ -S "$sock" ]] || { + log ERROR "Podman socket still missing" + exit 1 + } +} +ensure_podman + +# ───────────────────────────────────────────── +# Ensure IMAGE is present +ensure_image() { + if ! podman image exists "$IMAGE"; then + log WARN "Image $IMAGE not found—pulling" + podman pull --tls-verify=false "$IMAGE" || { + log ERROR "Failed to pull $IMAGE" + exit 1 + } + log INFO "Pulled $IMAGE" + fi +} +ensure_image + +# ───────────────────────────────────────────── +# Disallow file transfers +case "$SSH_ORIGINAL_COMMAND" in +*scp* | *sftp* | *rsync* | *tar*) + log ERROR "File transfers are disabled" + exit 1 + ;; +esac + +# ───────────────────────────────────────────── +# Generate per-user gitconfig +generate_gitconfig() { + local access="$HOME/access.yml" + local template="$HOME/gitconfig.template" + local userdir="$HOME/secrets/$PERSON" + local name email + + name=$(yq -r ".\"$PERSON\".name" "$access" 2>/dev/null || echo) + email=$(yq -r ".\"$PERSON\".email" "$access" 2>/dev/null || echo) + + if [[ -z "$name" || -z "$email" ]]; then + log ERROR "Missing name/email for '$PERSON' in $access" + exit 1 + fi + + mkdir -p "$userdir" + GIT_NAME="$name" GIT_EMAIL="$email" \ + envsubst <"$template" >"$userdir/gitconfig" + log INFO ".gitconfig created → $userdir/gitconfig" +} + +# ───────────────────────────────────────────── +# Start container if absent or stopped +start_container_if_needed() { + if ! podman container exists "$WORKSPACE"; then + log INFO "Creating container '$WORKSPACE'" + generate_gitconfig + podman run -dit \ + --name "$WORKSPACE" \ + --userns=keep-id \ + --user "$DEV_USER" \ + --hostname "$WORKSPACE" \ + --label auto-cleanup=true \ + -v "$HOME/data/$WORKSPACE:/app:Z" \ + -v "$HOME/secrets/$WORKSPACE/gitconfig:/home/$DEV_USER/.gitconfig:ro,Z" \ + -v "$HOME/secrets/$WORKSPACE/id_ed25519:/home/$DEV_USER/.ssh/id_ed25519:ro,Z" \ + -v "$HOME/secrets/$WORKSPACE/id_ed25519.pub:/home/$DEV_USER/.ssh/id_ed25519.pub:ro,Z" \ + --entrypoint "/home/$DEV_USER/start.sh" \ + "$IMAGE" "$TMUX_SESSION" + elif ! podman inspect -f '{{.State.Running}}' "$WORKSPACE" | grep -q true; then + log INFO "Starting existing container '$WORKSPACE'" + podman start "$WORKSPACE" >/dev/null + fi + sleep 1 +} + +# ───────────────────────────────────────────── +# Detach logic: stop container when devuser has left +check_devuser_attached() { + local clients + clients=$(podman exec "$WORKSPACE" tmux list-clients -t "$TMUX_SESSION" -F "#{client_user}" 2>/dev/null) + if grep -q "^${DEV_USER}\$" <<<"$clients"; then + log INFO "devuser still attached—keeping container running" + else + log INFO "devuser detached—stopping container" + podman stop "$WORKSPACE" >/dev/null + fi +} + +# ───────────────────────────────────────────── +# Determine access mode (rw|ro) or exit +get_access_mode() { + local yaml="$HOME/access.yml" user="$PERSON" ws="$WORKSPACE" + [[ ! "$ws" =~ ^[A-Za-z0-9._-]+$ ]] && { + log ERROR "Invalid workspace name" + exit 1 + } + if [[ "$user" == "$ws" ]]; then + echo rw + elif yq -e '.["'"$user"'"].rw[]?' "$yaml" | grep -qx "$ws"; then + echo rw + elif yq -e '.["'"$user"'"].ro[]?' "$yaml" | grep -qx "$ws"; then + echo ro + else + log ERROR "$user has no access to $ws" + exit 1 + fi +} + +MODE="$(get_access_mode)" + +# ───────────────────────────────────────────── +# Main dispatch +case "$MODE" in +rw) + start_container_if_needed + + # Ensure tmux session exists + if ! podman exec -it --user "$DEV_USER" "$WORKSPACE" tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + podman exec -it --user "$DEV_USER" "$WORKSPACE" \ + tmux new-session -d -s "$TMUX_SESSION" + fi + + log INFO "$PERSON attaching to workspace '$WORKSPACE'" + podman exec -it -e TERM="$TERM" --user "$DEV_USER" "$WORKSPACE" \ + tmux attach -t "$TMUX_SESSION" + log INFO "$PERSON detached from '$WORKSPACE'" + check_devuser_attached + ;; +ro) + if podman inspect -f '{{.State.Running}}' "$WORKSPACE" 2>/dev/null | grep -q true; then + log INFO "$PERSON viewing workspace '$WORKSPACE'" + podman exec -it -e TERM="$TERM" --user "$DEV_USER" "$WORKSPACE" \ + tmux attach -r -t "$TMUX_SESSION" + log INFO "$PERSON stopped viewing '$WORKSPACE'" + else + log ERROR "Workspace '$WORKSPACE' is not running" + exit 1 + fi + ;; +*) + log ERROR "Unknown access mode: '$MODE'" + exit 1 + ;; +esac diff --git a/tests/test_validate_command_access.sh b/tests/test_validate_command_access.sh new file mode 100755 index 0000000..b5893f8 --- /dev/null +++ b/tests/test_validate_command_access.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -e + +cat >access.yml < exists + local is_fixed + is_fixed="$(yq e ".\"$PERSON\".fixedArgsCommands | has(\"$cmd\")" "$yaml")" + # Check if multiArgsCommands. exists + local is_multi + is_multi="$(yq e ".\"$PERSON\".multiArgsCommands | has(\"$cmd\")" "$yaml")" + + if [[ "$is_fixed" != "true" && "$is_multi" != "true" ]]; then + echo "ERROR: Command '$cmd' not allowed for $PERSON" >&2 + return 1 + fi + + # Exclude flags from positional args + local args=() + for tok in "${tokens[@]}"; do + [[ "$tok" == -* ]] && continue + args+=("$tok") + done + + if [[ "$is_fixed" == "true" ]]; then + mapfile -t allowed < <(yq e ".\"$PERSON\".fixedArgsCommands.\"$cmd\"[]" "$yaml" 2>/dev/null) + local n_allowed="${#allowed[@]}" + if [[ $n_allowed -eq 0 ]]; then + # zero-arg command + if [[ ${#args[@]} -ne 0 ]]; then + echo "ERROR: Command '$cmd' takes no arguments" >&2 + return 1 + fi + else + # depth is 1: only one of the allowed choices must be present + if [[ ${#args[@]} -ne 1 ]]; then + echo "ERROR: Command '$cmd' requires exactly 1 argument: (${allowed[*]})" >&2 + return 1 + fi + local found=0 + for want in "${allowed[@]}"; do + [[ "${args[0]}" == "$want" ]] && found=1 && break + done + if [[ $found -eq 0 ]]; then + echo "ERROR: Invalid argument '${args[0]}' for '$cmd'; allowed: (${allowed[*]})" >&2 + return 1 + fi + fi + return 0 + fi + + if [[ "$is_multi" == "true" ]]; then + mapfile -t allowed < <(yq e ".\"$PERSON\".multiArgsCommands.\"$cmd\"[]" "$yaml" 2>/dev/null) + local n_allowed="${#allowed[@]}" + if [[ ${#args[@]} -lt 1 || ${#args[@]} -gt $n_allowed ]]; then + echo "ERROR: Command '$cmd' requires 1 to $n_allowed arguments: (${allowed[*]})" >&2 + return 1 + fi + + # Order doesn't matter, but all must be unique and from allowed. + # Build a set of allowed args. + declare -A allowed_set=() + for want in "${allowed[@]}"; do allowed_set["$want"]=1; done + + declare -A seen=() + for a in "${args[@]}"; do + [[ -z "${allowed_set[$a]}" ]] && { + echo "ERROR: Invalid argument '$a' for '$cmd'; allowed: (${allowed[*]})" >&2 + return 1 + } + [[ -n "${seen[$a]}" ]] && { + echo "ERROR: Duplicate argument '$a' for '$cmd'" >&2 + return 1 + } + seen["$a"]=1 + done + return 0 + fi +}