dotfiles/config/shared/bin/dev
2025-11-03 06:08:42 +02:00

457 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -e
source "/home/tomas/.local/bin/barg"
# shellcheck disable=SC2034
SPEC=(
"command;flow;DevFlow CLI - Manage instances and development containers"
"note;Use 'flow <command> --help' for command-specific options"
"command;enter;Connect to a development instance via SSH"
"note;Target format: [user@]namespace@platform (e.g., 'personal@orb' or 'root@personal@orb')"
"argument;user,u;type:option;help:SSH user (overrides user in target)"
"argument;namespace,n;type:option;help:Instance namespace (overrides namespace in target)"
"argument;platform,p;type:option;help:Platform name (overrides platform in target)"
"argument;session,s;type:option;default:default;help:Development session name (default: 'default')"
"argument;no-tmux;type:flag;dest:no_tmux;default:false;help:Skip tmux attachment on connection"
"argument;dry-run,d;type:flag;dest:dry_run;default:false;help:Show SSH command without executing"
"argument;target,t;required;help:Target instance in format [user@]namespace@platform"
"argument;ssh-args;type:rest;dest:ssh_args;help:Additional SSH arguments (after --)"
"end"
"command;create;Create and start a new development container"
"argument;image,i;required;type:option;help:Container image to use (with optional tag)"
"argument;project,p;type:option;help:Path to local project directory"
"argument;name;required;help:Container name"
"end"
"command;exec;Execute a command or open a shell in a container"
"argument;name;required;help:Container name"
"argument;cmd;type:rest;help:Command to execute inside container (after --)"
"end"
"command;connect;Attach or switch to the containers tmux session"
"note;When already inside tmux, switches to the target session instead of reattaching."
"note;New tmux panes or windows in the session automatically start inside the container."
"argument;from,f;type:option;dest:name;help:Optional source container name"
"argument;name;required;help:Target container name"
"end"
"command;list;Display all development containers and their status"
"end"
"command;stop;Stop or kill a running development container"
"argument;from;type:option;dest:name;help:Optional source container name"
"argument;kill;type:flag;help:Use kill instead of graceful stop"
"argument;name;required;help:Target container name"
"end"
"command;remove,rm;Remove a development container"
"argument;from;type:option;dest:name;help:Optional source container name"
"argument;force,f;type:flag;help:Force removal of container"
"argument;name;required;help:Target container name"
"end"
"command;respawn;Restart all tmux panes for a development session"
"argument;from;type:option;dest:name;help:Optional source container name"
"argument;name;required;help:Session or container name"
"end"
"command;test;Verify that the dev script is functioning"
"argument;from;type:option;dest:name;help:Optional source container name"
"argument;name;help:Target container name"
"end"
"end"
)
DEFAULT_REGISTRY="registry.tomastm.com"
DEFAULT_TAG="latest"
PROJECT_DIR="$HOME/projects"
PROJECT_ABBR="p"
fail() {
printf 'Error: %b\n' "$*" >&2
exit 1
}
resolve_path() {
local path="${1:-$(dirname "${BASH_SOURCE[0]}")}"
if command -v realpath >/dev/null 2>&1; then
realpath "$path"
else
echo "$(cd "$(dirname "$path")" && pwd)/$(basename "$path")"
fi
}
# shellcheck disable=SC2178,SC2128
parse_image_ref() {
local input="$1"
local image_ref registry repo tag label
if [[ $input == */* ]]; then
local prefix="${input%%/*}"
if [[ "$prefix" == "docker" ]]; then
input="docker.io/library/${input#*/}"
elif [[ "$prefix" == "tm0" ]]; then
input="${DEFAULT_REGISTRY}/${input#*/}"
fi
registry="${input%%/*}"
input=${input#*/}
else
registry="$DEFAULT_REGISTRY"
fi
if [[ "${input##*/}" == *:* ]]; then
tag="${input##*:}"
input="${input%:*}"
else
tag="$DEFAULT_TAG"
fi
repo="${registry}/${input}"
repo="${repo#*/}"
image_ref="${registry}/${repo}:${tag}"
label="${registry%.*}"
label="${label##*.}/${repo##*/}"
echo "$image_ref $repo $tag $label"
}
# shellcheck disable=SC2154,SC2155
docker_container_exists() {
local cname="$(get_cname)"
docker container ls -a --format '{{.Names}}' | grep -Fqx "$cname"
}
# shellcheck disable=SC2154,SC2155
docker_container_running() {
local cname="$(get_cname)"
docker container ls --format '{{.Names}}' | grep -Fqx "$cname"
}
docker_image_present() {
local ref="$1"
docker image inspect "$ref" >/dev/null 2>&1
}
# shellcheck disable=SC2154,SC2155
get_cname() {
printf "%s" "dev-${name_arg#dev-}"
}
cmd() {
barg_usage
}
# shellcheck disable=SC2154,SC2155
cmd_enter() {
# VARS: user_arg, namespace_arg, platform_arg, target_arg, session_arg, no_tmux_arg, dry_run_arg, ssh_args_arg
# Do not run inside instance
if [[ -n "$DF_NAMESPACE" && -n "$DF_PLATFORM" ]]; then
fail "It is not recommended to run this command inside an instance.\nCurrently inside: $(tput bold)${DF_NAMESPACE}@${DF_PLATFORM}$(tput sgr0)"
fi
local -A CONFIG_HOST=(
[orb.host]="<namespace>@orb"
[utm.host]="<namespace>@utm.local"
[core.host]="<namespace>@core.lan"
)
local df_platform=""
local df_namespace=""
local df_user=""
# Parse target: get user, namespace, platform
if [[ "$target_arg" == "*@*" ]]; then
df_platform="${target_arg##*@}"
target_arg="${target_arg%@*}"
fi
if [[ "$target_arg" == "*@*" ]]; then
df_namespace="${target_arg##*@}"
df_user="${target_arg%@*}"
else
df_namespace="${target_arg}"
df_user="${USER}"
fi
if [[ -n "$platform_arg" ]]; then
df_platform="$platform_arg"
fi
if [[ -n "$namespace_arg" ]]; then
df_namespace="$namespace_arg"
fi
if [[ -n "$user_arg" ]]; then
df_user="$user_arg"
fi
# Resolve host, identity (maybe check what would the host be in order to use .ssh/config)
local host_config="${CONFIG_HOST[${df_platform}.host]}"
local ssh_host="${host_config//<namespace>/$df_namespace}"
if [[ -z "$ssh_host" ]]; then
fail "Invalid platform: ${df_platform}"
fi
# Build ssh cmd: ssh + identity + tmux + envs
local ssh_cmd=(ssh -tt "${df_user}@${ssh_host}")
if [[ "$no_tmux_arg" == "false" ]]; then
# TODO: instead of tmux,maybe use "flow" in order to attach to dev container too
ssh_cmd+=("tmux" "new-session" "-As" "$session_arg"
"-e" "DF_NAMESPACE=$df_namespace"
"-e" "DF_PLATFORM=$df_platform")
fi
# Run or dryrun?
if [[ "$dry_run_arg" == "true" ]]; then
echo "Dry run command:"
printf '%q ' "${ssh_cmd[@]}"
echo
exit 0
fi
exec "${ssh_cmd[@]}"
}
# shellcheck disable=SC2154,SC2155
cmd_create() {
# VARS: name_arg, image_arg, project_arg
# Check if container name already exists
local cname="$(get_cname)"
if docker_container_exists "$cname"; then
printf -v msg 'Container already exists: "%s" (from name "%s")' "$cname" "$name_arg"
fail "$msg"
fi
# Check if project path is valid
local project_path
project_path="$(resolve_path "$project_arg")"
if [[ ! -d "$project_path" ]]; then
fail "Invalid project path: $project_path"
fi
# Check image
IFS=' ' read -r image_ref _ _ _ <<<"$(parse_image_ref "$image_arg")"
if ! docker_image_present "$image_ref"; then
printf -v msg 'Image not found locally.\nTry:\n\t- docker pull %s' "$image_ref"
fail "$msg"
fi
# Run (= create and start container)
cmd=(
docker run -d
--name "$cname"
--label dev=true
--label "dev.name=$name_arg"
--label "dev.project_path=$project_path"
--label "dev.image_ref=$image_ref"
--network host
--init # run tini as PID 1 to handle signals & reap zombies for cleaner container shutdown
-v "$project_path:/workspace"
-v /var/run/docker.sock:/var/run/docker.sock
)
[[ -d "$HOME/.ssh" ]] && cmd+=(-v "$HOME/.ssh:$CONTAINER_HOME/.ssh:ro")
[[ -f "$HOME/.npmrc" ]] && cmd+=(-v "$HOME/.npmrc:$CONTAINER_HOME/.npmrc:ro")
[[ -d "$HOME/.npm" ]] && cmd+=(-v "$HOME/.npm:$CONTAINER_HOME/.npm")
docker_gid="$(getent group docker | cut -d: -f3 || true)"
[[ -n "$docker_gid" ]] && cmd+=(--group-add "$docker_gid")
cmd+=("$image_ref" sleep infinity)
"${cmd[@]}"
printf "Created and started container: %s" "$cname"
}
# shellcheck disable=SC2154,SC2155
cmd_connect() {
# VARS: name_arg
local cname="$(get_cname)"
if ! docker_container_exists "$cname"; then
fail "Container does not exist: ${cname}. Run: dev create ..."
fi
if ! docker_container_running "$cname"; then
docker start "$cname" >/dev/null
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found; falling back to direct exec"
exec "$0" exec "$cname"
fi
local image_ref
image_ref="$(docker container inspect "$cname" --format '{{ .Config.Image }}')"
IFS=' ' read -r _image_ref _ _ image_label <<<"$(parse_image_ref "$image_ref")"
if ! tmux has-session -t "$cname" 2>/dev/null; then
tmux new-session -ds "$cname" \
-e "DF_IMAGE=$image_label" \
-e "DF_NAMESPACE=$DF_NAMESPACE" \
-e "DF_PLATFORM=$DF_PLATFORM" \
"$0 exec \"$name_arg\""
tmux set-option -t "$cname" default-command "$0 exec \"$name_arg\""
fi
if [[ -n "${TMUX-}" ]]; then
tmux switch-client -t "$cname"
else
tmux attach -t "$cname"
fi
}
# shellcheck disable=SC2154,SC2155
cmd_exec() {
# VARS: name_arg, cmd_arg
local cname="$(get_cname)"
if ! docker_container_running "$cname"; then
fail "Container $cname not running"
fi
if [[ -n "$cmd_arg" ]]; then
if [[ -t 0 ]]; then
docker exec -it "$cname" "${cmd_arg}"
else
docker exec "$cname" "${cmd_arg}"
fi
return
fi
# No command provided -> open a shell
docker exec --detach-keys "ctrl-q,ctrl-p" -it "$cname" zsh -l ||
docker exec --detach-keys "ctrl-q,ctrl-p" -it "$cname" bash -l ||
docker exec --detach-keys "ctrl-q,ctrl-p" -it "$cname" sh
}
shorten_project_path() {
local project=$1
local home=${HOME%/}
local projdir=${PROJECT_DIR%/}
# Case 1: under PROJECT_DIR
if [[ -n ${projdir} && $project == "$projdir"/* ]]; then
# shellcheck disable=SC2088
project="~/$PROJECT_ABBR${project#"$projdir"}"
# Case 2: equals HOME
elif [[ $project == "$home" ]]; then
project="~"
# Case 3: under HOME (but not PROJECT_DIR)
elif [[ $project == "$home"/* ]]; then
project="~${project#"$home"}"
fi
printf '%s\n' "$project"
}
# shellcheck disable=SC2154,SC2155
cmd_list() {
# VARS:
{
echo "NAME|IMAGE|PROJECT|STATUS"
docker ps -a --filter "label=dev=true" \
--format '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}'
} | while IFS='|' read -r fname image project status; do
# Shorten registry prefix
image="${image/$REGISTRY\//$REGISTRY_ABBR/}"
# Shorten project path
project="$(shorten_project_path "$project")"
echo "$fname|$image|$project|$status"
done | column -t -s '|'
}
tmux_fallback_to_default_if_in_session() {
# If inside tmux and current session matches the given one,
# switch to or create 'default' before proceeding.
local target_session="$1"
[[ -z "${TMUX-}" ]] && return 0 # not in tmux, nothing to do
local current_session
current_session="$(tmux display-message -p '#S')"
if [[ "$current_session" == "$target_session" ]]; then
if ! tmux has-session -t default 2>/dev/null; then
tmux new-session -ds default
fi
tmux switch-client -t default
fi
}
# shellcheck disable=SC2154,SC2155
cmd_stop() {
# VARS: kill_arg name_arg
local cname
cname="$(get_cname)"
docker_container_exists "$cname" || fail "Container $cname does not exist"
if [[ "$kill_arg" == "true" ]]; then
echo "Killing container $cname..."
docker kill "$cname"
else
echo "Stopping container $cname..."
docker stop "$cname"
fi
tmux_fallback_to_default_if_in_session "$cname"
}
# shellcheck disable=SC2154,SC2155
cmd_remove() {
# VARS: force_arg name_arg
local cname
cname="$(get_cname)"
docker_container_exists "$cname" || fail "Container $cname does not exist"
if [[ "$force_arg" == "true" ]]; then
echo "Removing container $cname (force)..."
docker rm -f "$cname"
else
echo "Removing container $cname..."
docker rm "$cname"
fi
tmux_fallback_to_default_if_in_session "$cname"
}
# shellcheck disable=SC2154,SC2155
cmd_respawn() {
# VARS: name_arg
local cname
cname="$(get_cname)"
panes=$(tmux list-panes -t "$cname" -s -F "#{session_name}:#{window_index}.#{pane_index}")
for pane in $panes; do
echo "Respawning $pane..."
tmux respawn-pane -t "$pane"
done
}
# shellcheck disable=SC2154,SC2155
cmd_test() {
# VARS: name_arg
echo "Script dev is working fine!"
if [[ -n "$name_arg" ]]; then
get_cname
fi
echo
}
barg_run SPEC[@] "$@"