#!/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 --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 container’s 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]="@orb" [utm.host]="@utm.local" [core.host]="@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///$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[@] "$@"