Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
[submodule "cli/support/bats-mock"]
path = cli/support/bats-mock
url = https://github.com/buildkite-plugins/bats-mock
[submodule "cli/lib/betteropts"]
path = cli/lib/betteropts
url = https://github.com/VirtusLab/betteropts/
22 changes: 21 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,27 @@

Command-line tool for managing sandcat configurations and Docker Compose setups.

Requires `docker` (and `docker compose`) and [`yq`](https://github.com/mikefarah/yq).
Requires Bash >= 4.2, `docker` (and `docker compose`), and [`yq`](https://github.com/mikefarah/yq).
macOS ships Bash 3.2 by default; install a newer one (e.g. `brew install bash`) and make sure it's
first on `PATH`.

Argument parsing is handled by [betteropts](https://github.com/VirtusLab/betteropts), included as a
git submodule at `lib/betteropts`. Every command supports `--help` for usage details.

## Shell Completion

Bash completion (module and command names, plus each command's own flags/arguments) is available
via `--completion`:

```bash
# Try it for the current shell session
source <(sandcat --completion)

# Or add it to your ~/.bashrc for persistence
echo 'source <(sandcat --completion)' >> ~/.bashrc
```

Only bash is supported.

## Modules and Commands

Expand Down
154 changes: 124 additions & 30 deletions cli/bin/sandcat
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,142 @@
# shellcheck disable=2292
if [ -z "${BASH_VERSION-}" ] ||
[[ -z "${BASH_VERSINFO-}" ]] ||
((BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 2)))
((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2)))
then
echo "$0: this program needs to be run by Bash >= 3.2" >&2
echo "$0: this program needs to be run by Bash >= 4.2" >&2
exit 1
fi

set -euo pipefail
shopt -s inherit_errexit lastpipe

sandcat_modules() {
find "$SCT_LIBEXECDIR" \
-mindepth 1 \
-maxdepth 1 \
-type d \
-print |
while IFS= read -r
do
basename -- "$REPLY"
done |
sort
}

sandcat_commands() {
local module="$1"

[[ -d "$SCT_LIBEXECDIR/$module" ]] || return 0

find "$SCT_LIBEXECDIR/$module" \
-mindepth 1 \
-maxdepth 1 \
-type f \
\( -perm -100 -o -perm -010 -o -perm -001 \) \
-print |
while IFS= read -r
do
basename -- "$REPLY"
done |
sort
}

sandcat_compgen() {
local -a words=("$@")
local cur="${words[$((${#words[@]} - 1))]}"

case "${#words[@]}" in
1 | 2)
local -a modules
sandcat_modules |
readarray -t modules
compgen -W "${modules[*]}" -- "$cur"
;;
3)
local module="${words[1]}"
local -a commands
sandcat_commands "$module" |
readarray -t commands
compgen -W "${commands[*]}" -- "$cur"
;;
*)
local module="${words[1]}" command="${words[2]}"
# Delegates to the leaf's own betteropts --__complete implementation
# (the native completion wire protocol), unfiltered - the shell's
# compgen/complete machinery does the prefix filtering.
"$SCT_LIBEXECDIR/$module/$command" --__complete -- "${words[@]:3}" 2>/dev/null || true
;;
esac
}

sandcat_completion_script() {
local target_shell="$1"
if [[ "$target_shell" != "bash" ]]
then
echo "Unsupported shell: $target_shell (only bash is supported)" >&2
return 1
fi
cat <<EOF
_sandcat_completions() {
local line
COMPREPLY=()
while IFS=\$'\n' read -r line
do
COMPREPLY+=("\$line")
done < <("$SCT_BIN" --__complete "\${COMP_WORDS[@]}" 2>/dev/null)
}
complete -F _sandcat_completions -o nosort sandcat
EOF
}

# Prints the shortest name that still invokes $1: its basename if the bare
# name on $PATH resolves back to $1, otherwise $1 unchanged.
sandcat_resolve_bin() {
local bin="$1"
local name_on_path
name_on_path="$(command -v -- "$(basename -- "$bin")" 2>/dev/null)" || true
# shellcheck disable=SC2312
if [[ -n "$name_on_path" ]] &&
[[ "$(readlink -f -- "$name_on_path")" == "$(readlink -f -- "$bin")" ]]
then
basename -- "$bin"
else
echo "$bin"
fi
}

main() {
# shellcheck disable=SC2312
SCT_ROOT="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/.."
export SCT_ROOT
export SCT_LIBDIR="$SCT_ROOT/lib"
export SCT_LIBEXECDIR="$SCT_ROOT/libexec"
export SCT_TEMPLATEDIR="$SCT_ROOT/templates"

# shellcheck source=../lib/compat.bash
source "$SCT_LIBDIR/compat.bash"
SCT_BIN="$(sandcat_resolve_bin "${BASH_SOURCE[0]}")"
export SCT_BIN

if [[ "${1:-}" == "--__complete" ]]
then
shift
sandcat_compgen "$@"
exit 0
fi

if [[ "${1:-}" == "--completion" ]]
then
sandcat_completion_script "${2:-bash}"
exit 0
fi

local -a modules
mapfile -t modules < <(
find "$SCT_LIBEXECDIR" \
-mindepth 1 \
-maxdepth 1 \
-type d \
-print |
while IFS= read -r
do
basename -- "$REPLY"
done |
sort
)
# shellcheck disable=SC2312
mapfile -t modules < <(sandcat_modules)

local -r usage="
Usage: ${BASH_SOURCE[0]} <$(IFS='|'; echo "${modules[*]}")> [command] [args...]
Usage: $SCT_BIN <$(IFS='|'; echo "${modules[*]}")> [command] [args...]

To list available commands use: ${BASH_SOURCE[0]} <module> help
To list available commands use: $SCT_BIN <module> help
"

if [[ $# -lt 1 ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]
Expand Down Expand Up @@ -75,17 +175,8 @@ To list available commands use: ${BASH_SOURCE[0]} <module> help
if [[ "$command" == "help" ]] || [[ "$command" == "--help" ]] || [[ "$command" == "-h" ]]
then
echo "Commands in $module:" >&2
find "$SCT_LIBEXECDIR/$module" \
-maxdepth 1 \
-type f \
-perm -0100 \
-print |
while IFS= read -r
do
echo -n ' '
basename -- "$REPLY"
done |
sort >&2
sandcat_commands "$module" |
sed 's/^/ /' >&2
exit 0
fi

Expand Down Expand Up @@ -113,4 +204,7 @@ To list available commands use: ${BASH_SOURCE[0]} <module> help
exec "$exec" "$@"
}

main "$@"
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
main "$@"
fi
1 change: 1 addition & 0 deletions cli/lib/betteropts
Submodule betteropts added at acd72b
19 changes: 17 additions & 2 deletions cli/libexec/attach/attach
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail

# shellcheck source=../../lib/betteropts/betteropts.sh
source "$SCT_LIBDIR/betteropts/betteropts.sh"
# shellcheck source=../../lib/require.bash
source "$SCT_LIBDIR/require.bash"
# shellcheck source=../../lib/path.bash
source "$SCT_LIBDIR/path.bash"

# shellcheck disable=SC2154 # populated by betteropts_parse
attach() {
summary "Attaches to a running agent container."

description "
Opens an interactive shell in the container by default (bash --login), or
runs the given command instead.
"

argument command passthrough \
help="Command to run inside the agent container (default: bash --login)"

betteropts_parse "$@"

require docker

local compose_file
compose_file="$(find_compose_file)"

if (($# == 0))
if ((${#command[@]} == 0))
then
exec docker compose -f "$compose_file" exec -u vscode agent bash --login
fi

exec docker compose -f "$compose_file" exec -u vscode agent "$@"
exec docker compose -f "$compose_file" exec -u vscode agent "${command[@]}"
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
Expand Down
6 changes: 6 additions & 0 deletions cli/libexec/cache/list
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail

# shellcheck source=../../lib/betteropts/betteropts.sh
source "$SCT_LIBDIR/betteropts/betteropts.sh"
# shellcheck source=../../lib/require.bash
source "$SCT_LIBDIR/require.bash"
# shellcheck source=../../lib/cache.bash
Expand All @@ -9,6 +11,10 @@ source "$SCT_LIBDIR/cache.bash"
# Lists every host-scoped shared-cache volume with its size, file count,
# and any container currently mounting it.
list() {
summary "Lists every host-scoped shared-cache volume with its size, file count, and any container mounting it."

betteropts_parse "$@"

require docker

local -a names
Expand Down
57 changes: 34 additions & 23 deletions cli/libexec/cache/rm
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail

# shellcheck source=../../lib/betteropts/betteropts.sh
source "$SCT_LIBDIR/betteropts/betteropts.sh"
# shellcheck source=../../lib/require.bash
source "$SCT_LIBDIR/require.bash"
# shellcheck source=../../lib/cache.bash
source "$SCT_LIBDIR/cache.bash"
# shellcheck source=../../lib/logging.bash
source "$SCT_LIBDIR/logging.bash"

# Removes one or all shared-cache volumes. Refuses (unless --force) when
# a running container still holds a volume open, because docker rm would
# fail with an unclear error and the user's build would keep behaving as
# if the cache were live.
# Business-logic usage error (missing target, or --all combined with a
# volume name) - distinct from betteropts' own --help, which covers
# unrecognized flags/malformed CLI syntax.
usage() {
cat >&2 <<EOF
Usage: sandcat cache rm <volume>|--all [--force] [--yes]
Expand All @@ -24,27 +25,37 @@ EOF
exit 1
}

# Removes one or all shared-cache volumes. Refuses (unless --force) when
# a running container still holds a volume open, because docker rm would
# fail with an unclear error and the user's build would keep behaving as
# if the cache were live.
# shellcheck disable=SC2154 # populated by betteropts_parse
rm_cmd() {
summary "Removes one or all shared-cache volumes."

flag all --all \
help="Remove every sandcat-shared-cache-labelled volume"

flag force --force \
help="Skip the \"container still using this volume\" check"

flag yes -y --yes \
help="Skip the confirmation prompt"

argument target optional \
help="Full volume name, e.g. sandcat-cache-maven"

betteropts_parse "$@"

require docker

local target=""
local all=false force=false yes=false
while [[ $# -gt 0 ]]; do
case $1 in
--all) all=true ;;
--force) force=true ;;
--yes|-y) yes=true ;;
-h|--help) usage ;;
-*) echo "Unknown flag: $1" >&2; usage ;;
*) [[ -n "$target" ]] && { echo "Only one volume name accepted" >&2; usage; }
target=$1 ;;
esac
shift
done
if "$all" && [[ -n "$target" ]]; then
echo "Use --all OR a volume name, not both" >&2
usage
fi

local -a targets=()
if [[ $all == "true" ]]; then
[[ -n "$target" ]] && { echo "Use --all OR a volume name, not both" >&2; usage; }
if "$all"; then
mapfile -t targets < <(sct_cache_volume_names)
else
[[ -z "$target" ]] && usage
Expand All @@ -58,7 +69,7 @@ rm_cmd() {

# Container-in-use check first — abort BEFORE the confirmation prompt
# so the user isn't asked to confirm a removal that will fail.
if [[ $force != "true" ]]; then
if ! "$force"; then
local name in_use
for name in "${targets[@]}"; do
in_use=$(sct_cache_volume_containers "$name" | paste -sd, -)
Expand All @@ -71,7 +82,7 @@ rm_cmd() {
fi

# Show what will happen + ask, unless --yes given.
if [[ $yes != "true" ]]; then
if ! "$yes"; then
echo "The following shared-cache volumes will be removed from the host:"
local name
for name in "${targets[@]}"; do
Expand All @@ -85,7 +96,7 @@ rm_cmd() {
# --force implies docker rm --force too, so an in-use volume yields
# a docker-native error (rare — we already checked, but belt+braces).
local rm_flags=()
[[ $force == "true" ]] && rm_flags+=(--force)
"$force" && rm_flags+=(--force)

docker volume rm "${rm_flags[@]+"${rm_flags[@]}"}" "${targets[@]}"
}
Expand Down
Loading
Loading