Compare commits
10 Commits
44d9fc0cf1
...
8f19a78844
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f19a78844 | |||
| bdd5acfe21 | |||
| 1905357b64 | |||
| f99651f65e | |||
| ec2aa3401f | |||
| d9d30d2b30 | |||
| e532c6ee9c | |||
| 178826f522 | |||
| 38661de653 | |||
| dde17f2339 |
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Docker Sandbox Build
|
||||
|
||||
This repository contains the non-Git `build-sbx.sh` utility used to create and
|
||||
configure Docker sandboxes. The script is intentionally interactive so that
|
||||
the sandbox name, mounted workspaces, copied data, API key, and cleanup actions
|
||||
are explicit.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Bash
|
||||
- `sbx`
|
||||
- `jq`
|
||||
- `fzf` when copying an existing sandbox
|
||||
|
||||
Run the utility from a shell with access to the `sbx` command:
|
||||
|
||||
```bash
|
||||
./build-sbx.sh
|
||||
```
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
The project is below version 1.0.0, so changes must not intentionally introduce
|
||||
breaking behavior. Every commit uses a type and a scope.
|
||||
|
||||
Supported types:
|
||||
|
||||
- `feat`: adds or extends behavior
|
||||
- `fix`: corrects behavior
|
||||
- `docs`: changes documentation
|
||||
- `style`: changes formatting without changing behavior
|
||||
- `refactor`: changes implementation without changing behavior
|
||||
|
||||
Established scopes:
|
||||
|
||||
- `kit`: sandbox kit, image template, creation, and configuration
|
||||
- `workspace`: workspace selection, naming, and cleanup workflow
|
||||
- `docs`: README and project documentation
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(kit): create configured sandbox image
|
||||
docs(docs): document commit conventions
|
||||
```
|
||||
394
build-sbx.sh
394
build-sbx.sh
@@ -1,27 +1,421 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Build and optionally configure a Docker sandbox interactively.
|
||||
#
|
||||
# The workflow deliberately asks before making choices that affect the new
|
||||
# sandbox. This makes it harder to create a sandbox with the wrong name,
|
||||
# workspace directories, copied data, or credentials.
|
||||
|
||||
# Stop on command failures, unset variables, and failures hidden in pipelines.
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
# These values are readonly because they are configuration, not user input.
|
||||
readonly KIT_PATH="$HOME/.sbx/kits/hypoport.ai"
|
||||
readonly TEMPLATE='127.0.0.1:62735/finmas.de/mux033/opencode-ops:latest'
|
||||
|
||||
# This is intentionally global because the EXIT cleanup function runs after
|
||||
# `main` has returned and therefore cannot access `main`'s local variables.
|
||||
tmp=''
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
local message="$2"
|
||||
|
||||
# `command -v` checks the current PATH without printing anything. The
|
||||
# explicit error is easier to understand than a later command-not-found.
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
printf '%s\n' "$message" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ask_yes_no() {
|
||||
local prompt="$1"
|
||||
local answer
|
||||
|
||||
# Returning success for yes and failure for no lets callers use this
|
||||
# function directly as the condition of an `if` statement.
|
||||
while true; do
|
||||
read -r -p "$prompt [y/n] " answer || exit 0
|
||||
case "$answer" in
|
||||
[Yy]) return 0 ;;
|
||||
[Nn]) return 1 ;;
|
||||
*) printf 'Please answer yes or no.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
prompt_component() {
|
||||
# Bash functions normally cannot assign to a caller's local variable. The
|
||||
# first argument is therefore a variable name, which `printf -v` assigns
|
||||
# to without using unsafe `eval`.
|
||||
local result_var="$1"
|
||||
local label="$2"
|
||||
shift 2
|
||||
# Arrays keep each menu option as one value, even if it contains spaces.
|
||||
# `custom` is a synthetic final option for values not listed in advance.
|
||||
local options=("$@" custom)
|
||||
local choice custom_value
|
||||
|
||||
while true; do
|
||||
printf '%s:\n' "$label"
|
||||
# `select` prints a numbered menu and puts the selected text in
|
||||
# `choice`. Invalid numbers produce an empty choice.
|
||||
select choice in "${options[@]}"; do
|
||||
case "$choice" in
|
||||
custom)
|
||||
read -r -p "Custom $label: " custom_value || exit 0
|
||||
if [[ -n "$custom_value" ]]; then
|
||||
printf -v "$result_var" '%s' "$custom_value"
|
||||
return 0
|
||||
fi
|
||||
printf 'The custom value cannot be empty.\n'
|
||||
;;
|
||||
'') printf 'Please select one of the listed options.\n' ;;
|
||||
*)
|
||||
printf -v "$result_var" '%s' "$choice"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
select_workspaces() {
|
||||
# Namerefs let this helper read and write the caller's arrays by name.
|
||||
# The source array stays unchanged so the displayed menu remains useful.
|
||||
local -n available_workspaces="$1"
|
||||
local -n selected_workspaces="$2"
|
||||
local selection selection_part index
|
||||
local valid_selection
|
||||
declare -A selected_indices=()
|
||||
|
||||
printf 'Existing workspace directories:\n'
|
||||
for index in "${!available_workspaces[@]}"; do
|
||||
printf ' %d) %s\n' "$((index + 1))" "${available_workspaces[index]}"
|
||||
done
|
||||
|
||||
while true; do
|
||||
read -r -p 'Workspaces to reuse (a for all, n for none, or numbers such as 1, 3): ' selection || exit 0
|
||||
selection="${selection//[[:space:]]/}"
|
||||
|
||||
# `a` reuses every path, while `n` deliberately starts with no
|
||||
# inherited paths and leaves manual additions to the next question.
|
||||
if [[ "$selection" == a ]]; then
|
||||
selected_workspaces=("${available_workspaces[@]}")
|
||||
return 0
|
||||
fi
|
||||
if [[ "$selection" == n ]]; then
|
||||
selected_workspaces=()
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Treat the numeric selection as a set so duplicate numbers are safe.
|
||||
selected_indices=()
|
||||
valid_selection=true
|
||||
IFS=',' read -ra selection_parts <<< "$selection"
|
||||
for selection_part in "${selection_parts[@]}"; do
|
||||
if [[ "$selection_part" =~ ^[0-9]+$ ]]; then
|
||||
index=$((10#$selection_part))
|
||||
if ((index < 1 || index > ${#available_workspaces[@]})); then
|
||||
valid_selection=false
|
||||
break
|
||||
fi
|
||||
selected_indices[$index]=1
|
||||
else
|
||||
valid_selection=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$valid_selection" != true || ${#selected_indices[@]} -eq 0 ]]; then
|
||||
printf 'Please enter a, n, or valid workspace numbers from 1 to %d.\n' \
|
||||
"${#available_workspaces[@]}"
|
||||
continue
|
||||
fi
|
||||
|
||||
selected_workspaces=()
|
||||
for ((index = 1; index <= ${#available_workspaces[@]}; index++)); do
|
||||
if [[ ${selected_indices[$index]:-} == 1 ]]; then
|
||||
selected_workspaces+=("${available_workspaces[index - 1]}")
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_temporary_directory() {
|
||||
# EXIT traps run after `main` returns, so a variable declared local inside
|
||||
# `main` is no longer available. `${tmp:-}` also keeps nounset from
|
||||
# turning normal script completion into an error when no copy was made.
|
||||
if [[ -n "${tmp:-}" ]]; then
|
||||
rm -rf -- "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
# Check dependencies before asking questions. A missing tool should not
|
||||
# leave the user halfway through an interactive setup.
|
||||
require_command sbx 'We cannot build a Docker sbx image because sbx does not seem to be installed.'
|
||||
require_command jq 'We cannot list existing Docker sbx sandboxes because jq does not seem to be installed.'
|
||||
|
||||
local build_from_scratch
|
||||
if ask_yes_no 'Build from scratch?'; then
|
||||
build_from_scratch=true
|
||||
else
|
||||
build_from_scratch=false
|
||||
fi
|
||||
|
||||
local sandbox_json
|
||||
# Capture the inventory once. The same snapshot is used for source
|
||||
# selection and cleanup so the cleanup menu cannot unexpectedly include
|
||||
# the sandbox created by this run.
|
||||
if ! sandbox_json=$(sbx ls --json); then
|
||||
printf 'Unable to retrieve the existing Docker sbx sandboxes.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A successful command can still return malformed data. `-s` treats the
|
||||
# input as one stream and `-e` makes jq fail when the validation is false.
|
||||
if ! jq -s -e 'length == 1 and (.[0].sandboxes | type == "array")' \
|
||||
<<< "$sandbox_json" >/dev/null; then
|
||||
printf 'The output from sbx ls --json was not the expected JSON format.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local -a sandbox_names
|
||||
# `mapfile -t` turns one newline-delimited name per line into a Bash array.
|
||||
# `-r` prevents jq from adding JSON quotes around the names.
|
||||
mapfile -t sandbox_names < <(jq -r '.sandboxes[].name' <<< "$sandbox_json")
|
||||
|
||||
local base_sandbox_name next_version selected_sandbox
|
||||
local -a current_workspace_paths=()
|
||||
local -a workspace_paths=()
|
||||
local reused_workspaces=false
|
||||
|
||||
if [[ "$build_from_scratch" == true ]]; then
|
||||
# Keep the three name components separate so common combinations are
|
||||
# quick to select while custom values remain possible.
|
||||
prompt_component sandbox_prefix 'Sandbox prefix' 'finmas-gmbh' 'private'
|
||||
prompt_component sandbox_purpose 'Sandbox purpose' 'git-work' 'generic-research'
|
||||
prompt_component sandbox_suffix 'Sandbox suffix' 'finmas-api' 'private-api'
|
||||
base_sandbox_name="${sandbox_prefix}-${sandbox_purpose}-${sandbox_suffix}"
|
||||
next_version=1
|
||||
else
|
||||
require_command fzf 'We cannot select an existing Docker sbx image because fzf does not seem to be installed.'
|
||||
if ((${#sandbox_names[@]} == 0)); then
|
||||
printf 'No existing Docker sbx sandboxes were found.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# fzf provides a searchable menu and returns a nonzero status when the
|
||||
# user cancels it. Do not create anything after cancellation.
|
||||
if ! selected_sandbox=$(printf '%s\n' "${sandbox_names[@]}" | fzf \
|
||||
--prompt='Select Docker sbx image: ' --height=40% --reverse); then
|
||||
printf 'No Docker sbx image was selected.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use the already captured JSON rather than querying a changing list.
|
||||
# `--arg` passes the selected name as data, not as jq source code.
|
||||
mapfile -t current_workspace_paths < <(
|
||||
jq -r --arg name "$selected_sandbox" \
|
||||
'.sandboxes[] | select(.name == $name) | .workspaces[]?' \
|
||||
<<< "$sandbox_json"
|
||||
)
|
||||
|
||||
if ((${#current_workspace_paths[@]} > 0)); then
|
||||
select_workspaces current_workspace_paths workspace_paths
|
||||
if ((${#workspace_paths[@]} > 0)); then
|
||||
reused_workspaces=true
|
||||
fi
|
||||
else
|
||||
printf 'The selected sandbox has no workspace directories to reuse.\n'
|
||||
fi
|
||||
|
||||
# A name ending in -v<number> is versioned. Strip that suffix and
|
||||
# increment it; an unversioned source starts at version 1.
|
||||
if [[ "$selected_sandbox" =~ ^(.*)-v([0-9]+)$ ]]; then
|
||||
base_sandbox_name="${BASH_REMATCH[1]}"
|
||||
next_version=$((10#${BASH_REMATCH[2]} + 1))
|
||||
else
|
||||
base_sandbox_name="$selected_sandbox"
|
||||
next_version=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Construct the final name in one place for both build modes.
|
||||
local new_sandbox_name="${base_sandbox_name}-v${next_version}"
|
||||
|
||||
# Arrays are important here: each workspace path remains one argument to
|
||||
# `sbx create`, even when a path contains whitespace.
|
||||
if [[ "$build_from_scratch" == true &&
|
||||
( "$sandbox_prefix" != 'finmas-gmbh' || "$sandbox_suffix" == 'private-api' ) ]]; then
|
||||
workspace_paths=("$HOME/docker-sbx/empty")
|
||||
elif [[ "$build_from_scratch" == true ]]; then
|
||||
workspace_paths=("$HOME/Documents/git")
|
||||
fi
|
||||
|
||||
if [[ "$build_from_scratch" == true ]]; then
|
||||
current_workspace_paths=("${workspace_paths[@]}")
|
||||
fi
|
||||
|
||||
printf 'Workspace directories in the current sandbox:\n'
|
||||
if ((${#current_workspace_paths[@]} == 0)); then
|
||||
printf 'No workspace directories were reported.\n'
|
||||
else
|
||||
printf '%s\n' "${current_workspace_paths[@]}"
|
||||
fi
|
||||
|
||||
# Additional paths are appended rather than replacing reused paths.
|
||||
local additional_workspace_prompt
|
||||
if [[ "$reused_workspaces" == true ]]; then
|
||||
additional_workspace_prompt='Would you like other workspaces added?'
|
||||
else
|
||||
additional_workspace_prompt='Do you want to add additional workspace directories?'
|
||||
fi
|
||||
if ask_yes_no "$additional_workspace_prompt"; then
|
||||
local workspace_path
|
||||
while true; do
|
||||
read -r -p 'Additional workspace path (press Enter when finished): ' workspace_path || exit 0
|
||||
[[ -z "$workspace_path" ]] && break
|
||||
workspace_paths+=("$workspace_path")
|
||||
done
|
||||
fi
|
||||
|
||||
local api_key
|
||||
if [[ "$build_from_scratch" == false ]]; then
|
||||
# `sbx cp` needs a host-side intermediary. `mktemp` gives concurrent
|
||||
# runs separate directories, and the EXIT trap removes the copy even
|
||||
# if a later command fails.
|
||||
tmp=$(mktemp -d) || {
|
||||
printf 'Unable to create a temporary directory.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
trap cleanup_temporary_directory EXIT
|
||||
|
||||
# Copy before creating the new sandbox so a failed creation cannot
|
||||
# destroy the only temporary copy of the source data.
|
||||
if ! sbx cp "${selected_sandbox}:/home/agent/.local" "$tmp"; then
|
||||
printf 'Unable to copy /home/agent/.local from Docker sbx image: %s\n' \
|
||||
"$selected_sandbox" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'Creating Docker sbx image: %s\n' "$new_sandbox_name"
|
||||
if ! sbx create opencode \
|
||||
--name "$new_sandbox_name" \
|
||||
--kit "$KIT_PATH" \
|
||||
--template "$TEMPLATE" \
|
||||
"${workspace_paths[@]}"; then
|
||||
printf 'Unable to create Docker sbx image: %s\n' "$new_sandbox_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$build_from_scratch" == false ]]; then
|
||||
if ! sbx cp "$tmp/.local" "${new_sandbox_name}:/home/agent/"; then
|
||||
printf 'Unable to copy the temporary .local directory into Docker sbx image: %s\n' \
|
||||
"$new_sandbox_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# `read -s` prevents the API key from being echoed. The newline afterward
|
||||
# keeps subsequent terminal output readable.
|
||||
if ! read -rsp 'HYPOPORT_API_KEY: ' api_key; then
|
||||
printf '\nUnable to read the HYPOPORT_API_KEY.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\n'
|
||||
|
||||
# Pipe the secret to `sbx exec` instead of placing it in command arguments,
|
||||
# where process inspection could expose it. `%q` writes a shell-escaped
|
||||
# value, and `umask 077` limits the generated file to its owner.
|
||||
if ! printf '%s\n' "$api_key" |
|
||||
sbx exec "$new_sandbox_name" bash -c \
|
||||
'umask 077; read -r api_key; printf "export HYPOPORT_API_KEY=%q\n" "$api_key" > /etc/sandbox-persistent.sh'; then
|
||||
unset api_key
|
||||
printf 'Unable to store the HYPOPORT_API_KEY in Docker sbx image: %s\n' \
|
||||
"$new_sandbox_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset api_key
|
||||
|
||||
# Cleanup is offered only after the new sandbox has been fully configured.
|
||||
# An empty original inventory has nothing to display or remove.
|
||||
if ((${#sandbox_names[@]} == 0)) || ! ask_yes_no 'Do you want to delete existing Docker sbx sandboxes?'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'Existing Docker sbx sandboxes:\n'
|
||||
local index
|
||||
for index in "${!sandbox_names[@]}"; do
|
||||
printf ' %d) %s\n' "$((index + 1))" "${sandbox_names[index]}"
|
||||
done
|
||||
|
||||
local selection selection_part range_start range_end
|
||||
local valid_selection index sandbox_name
|
||||
declare -A selected_indices=()
|
||||
while true; do
|
||||
read -r -p 'Sandboxes to delete (for example, 1-3 or 1, 2, 7; press Enter to cancel): ' selection || return 0
|
||||
[[ -z "$selection" ]] && return 0
|
||||
|
||||
# An associative array is used as a set, so selecting an item twice
|
||||
# still deletes it only once.
|
||||
selected_indices=()
|
||||
valid_selection=true
|
||||
IFS=',' read -ra selection_parts <<< "$selection"
|
||||
for selection_part in "${selection_parts[@]}"; do
|
||||
selection_part="${selection_part//[[:space:]]/}"
|
||||
# Remove spaces around commas and hyphens before parsing. Validate
|
||||
# every part before running the first destructive command.
|
||||
if [[ "$selection_part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
|
||||
range_start=$((10#${BASH_REMATCH[1]}))
|
||||
range_end=$((10#${BASH_REMATCH[2]}))
|
||||
if ((range_start < 1 || range_start > range_end || range_end > ${#sandbox_names[@]})); then
|
||||
valid_selection=false
|
||||
break
|
||||
fi
|
||||
# Expand an inclusive range such as 1-3 into its menu indexes.
|
||||
for ((index = range_start; index <= range_end; index++)); do
|
||||
selected_indices[$index]=1
|
||||
done
|
||||
elif [[ "$selection_part" =~ ^[0-9]+$ ]]; then
|
||||
# Bash arrays are zero-based, but the displayed menu is
|
||||
# intentionally one-based for people entering selections.
|
||||
index=$((10#$selection_part))
|
||||
if ((index < 1 || index > ${#sandbox_names[@]})); then
|
||||
valid_selection=false
|
||||
break
|
||||
fi
|
||||
selected_indices[$index]=1
|
||||
else
|
||||
valid_selection=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$valid_selection" != true || ${#selected_indices[@]} -eq 0 ]]; then
|
||||
printf 'Please enter valid sandbox numbers and ranges from 1 to %d.\n' "${#sandbox_names[@]}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Iterate in menu order instead of associative-array order so output
|
||||
# and deletion operations are predictable.
|
||||
for ((index = 1; index <= ${#sandbox_names[@]}; index++)); do
|
||||
if [[ ${selected_indices[$index]:-} == 1 ]]; then
|
||||
sandbox_name="${sandbox_names[index - 1]}"
|
||||
printf 'Deleting Docker sbx sandbox: %s\n' "$sandbox_name"
|
||||
if ! sbx rm --force "$sandbox_name"; then
|
||||
printf 'Unable to delete Docker sbx sandbox: %s\n' "$sandbox_name" >&2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user