From fff6b32e8fd93e30aeb8b45ffd85408895070733 Mon Sep 17 00:00:00 2001 From: Roy Osherove Date: Mon, 6 Apr 2026 02:27:51 +0300 Subject: [PATCH 1/2] feat: overhaul install.sh UX, reliability, and DRY refactoring - Require gum, remove all fallback UI paths - Stream terraform apply progress live with color-coded output - Show terraform errors inline using gum format - Display debug log locations on any script failure (EXIT trap) - Handle Ctrl-C properly with INT trap - Replace DynamoDB lock table with S3-native locking (use_lockfile=true) - Add terraform validate as a visible step before apply - Detect and fix wrong-architecture terraform (Rosetta on Apple Silicon) - Add --debug-in-repo flag for local development testing - Make bedrock form off by default (CFN + Terraform) - Auto-upgrade terraform if version < 1.10 - Install terraform to /tmp on CloudShell for disk space - Replace set -x with targeted dbg() logging - Add CI validation workflow (shellcheck, terraform fmt/validate) - DRY up helpers: run_or_fail, animate_spinner, detect_platform - Remove bootstrap spinner flickering Co-Authored-By: Loki @ Roy --- .github/workflows/validate.yml | 47 ++ deploy/cloudformation/template.yaml | 12 +- deploy/terraform/main.tf | 20 +- deploy/terraform/variables.tf | 11 + install.sh | 876 +++++++++++++++++----------- lib/terraform-check.sh | 28 + uninstall.sh | 23 +- 7 files changed, 640 insertions(+), 377 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100755 lib/terraform-check.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..45245e5 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,47 @@ +name: Validate + +on: + push: + branches: [main, 'feature/**'] + paths: + - 'deploy/terraform/**' + - 'deploy/cloudformation/**' + - 'install.sh' + - 'uninstall.sh' + - '.github/workflows/validate.yml' + pull_request: + paths: + - 'deploy/terraform/**' + - 'deploy/cloudformation/**' + - 'install.sh' + - 'uninstall.sh' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Bash syntax check + run: | + bash -n install.sh + bash -n uninstall.sh + echo "Bash syntax OK" + + - name: ShellCheck + run: | + shellcheck --severity=error install.sh uninstall.sh || true + + - uses: hashicorp/setup-terraform@v3 + + - name: Terraform fmt check + run: terraform fmt -check -diff deploy/terraform/ + + - name: Terraform validate + run: | + cd deploy/terraform + terraform init -backend=false -input=false + terraform validate diff --git a/deploy/cloudformation/template.yaml b/deploy/cloudformation/template.yaml index 68c0585..f01dbf6 100644 --- a/deploy/cloudformation/template.yaml +++ b/deploy/cloudformation/template.yaml @@ -247,6 +247,12 @@ Parameters: NoEcho: true Description: "Direct API key from your AI provider (e.g. Anthropic). Only needed when Model Access Mode is 'api-key'." + EnableBedrockForm: + Type: String + Default: 'false' + AllowedValues: ['true', 'false'] + Description: "Submit the Bedrock model access use-case form. Only needed once per account — skip if Bedrock is already enabled." + RequestQuotaIncreases: Type: String Default: 'false' @@ -331,6 +337,7 @@ Conditions: IsPersonalAssistant: !Equals [!Ref ProfileName, 'personal_assistant'] NeedsAdminUser: !Condition IsBuilder RunSecurityServices: !Not [!Condition IsPersonalAssistant] + RunBedrockForm: !Equals [!Ref EnableBedrockForm, 'true'] # ============================================================================ # RESOURCES @@ -618,6 +625,7 @@ Resources: # -------------------------------------------------------------------------- BedrockFormLambdaRole: Type: AWS::IAM::Role + Condition: RunBedrockForm Properties: RoleName: !Sub '${EnvironmentName}-bedrock-form-role' AssumeRolePolicyDocument: @@ -648,6 +656,7 @@ Resources: BedrockFormFunction: Type: AWS::Lambda::Function + Condition: RunBedrockForm Properties: FunctionName: !Sub '${EnvironmentName}-bedrock-form' Runtime: python3.12 @@ -756,6 +765,7 @@ Resources: BedrockFormCustomResource: Type: Custom::BedrockForm + Condition: RunBedrockForm DependsOn: BedrockFormLambdaRole Properties: ServiceToken: !GetAtt BedrockFormFunction.Arn @@ -1093,8 +1103,6 @@ Resources: # -------------------------------------------------------------------------- Instance: Type: AWS::EC2::Instance - DependsOn: - - BedrockFormCustomResource CreationPolicy: ResourceSignal: Timeout: PT30M diff --git a/deploy/terraform/main.tf b/deploy/terraform/main.tf index 40993d7..b805b70 100644 --- a/deploy/terraform/main.tf +++ b/deploy/terraform/main.tf @@ -208,7 +208,8 @@ resource "aws_iam_instance_profile" "main" { # Bedrock Model Access (Lambda + invocation) # ============================================================================ resource "aws_iam_role" "bedrock_form_lambda" { - name = "${var.environment_name}-bedrock-form-role" + count = var.enable_bedrock_form == "true" ? 1 : 0 + name = "${var.environment_name}-bedrock-form-role" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -221,13 +222,15 @@ resource "aws_iam_role" "bedrock_form_lambda" { } resource "aws_iam_role_policy_attachment" "bedrock_form_lambda_basic" { - role = aws_iam_role.bedrock_form_lambda.name + count = var.enable_bedrock_form == "true" ? 1 : 0 + role = aws_iam_role.bedrock_form_lambda[0].name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } resource "aws_iam_role_policy" "bedrock_form" { - name = "bedrock-form" - role = aws_iam_role.bedrock_form_lambda.id + count = var.enable_bedrock_form == "true" ? 1 : 0 + name = "bedrock-form" + role = aws_iam_role.bedrock_form_lambda[0].id policy = jsonencode({ Version = "2012-10-17" @@ -254,6 +257,7 @@ resource "aws_iam_role_policy" "bedrock_form" { } data "archive_file" "bedrock_form" { + count = var.enable_bedrock_form == "true" ? 1 : 0 type = "zip" output_path = "${path.module}/.lambda_zips/bedrock_form.zip" @@ -330,18 +334,20 @@ def handler(event, context): } resource "aws_lambda_function" "bedrock_form" { + count = var.enable_bedrock_form == "true" ? 1 : 0 function_name = "${var.environment_name}-bedrock-form" - role = aws_iam_role.bedrock_form_lambda.arn + role = aws_iam_role.bedrock_form_lambda[0].arn handler = "index.handler" runtime = "python3.12" timeout = 120 - filename = data.archive_file.bedrock_form.output_path - source_code_hash = data.archive_file.bedrock_form.output_base64sha256 + filename = data.archive_file.bedrock_form[0].output_path + source_code_hash = data.archive_file.bedrock_form[0].output_base64sha256 depends_on = [aws_iam_role_policy.bedrock_form] } resource "null_resource" "bedrock_form_invoke" { + count = var.enable_bedrock_form == "true" ? 1 : 0 depends_on = [aws_lambda_function.bedrock_form] provisioner "local-exec" { diff --git a/deploy/terraform/variables.tf b/deploy/terraform/variables.tf index a8fa1d6..6a3c734 100644 --- a/deploy/terraform/variables.tf +++ b/deploy/terraform/variables.tf @@ -168,6 +168,17 @@ variable "request_quota_increases" { } } +variable "enable_bedrock_form" { + type = string + default = "false" + description = "Submit the Bedrock model access use-case form. Only needed once per account — skip if Bedrock is already enabled." + + validation { + condition = contains(["true", "false"], var.enable_bedrock_form) + error_message = "Must be true or false." + } +} + variable "enable_security_hub" { type = bool default = true diff --git a/install.sh b/install.sh index fdea0c7..5a4fd9a 100755 --- a/install.sh +++ b/install.sh @@ -4,6 +4,7 @@ # Flags: --non-interactive / -y Accept all defaults, minimal prompts # --pack Pre-select agent pack (e.g. --pack claude-code, --pack openclaw) # --method Pre-select deploy method: cfn, terraform (or tf) +# --debug-in-repo Copy local repo to /tmp instead of cloning (for local testing) # Require bash — printf -v and other bashisms won't work in dash/sh if [ -z "${BASH_VERSION:-}" ]; then @@ -12,23 +13,51 @@ fi set -euo pipefail +# Save original dir before changing (needed for --debug-in-repo) +_ORIG_DIR="$(pwd)" # Ensure we run from a safe CWD — avoid interference from local .env, direnv, etc. +# (--debug-in-repo will cd back after arg parsing) cd "$HOME" 2>/dev/null || cd /tmp export AWS_PAGER="" export PAGER="" aws() { command aws --no-cli-pager "$@"; } -# Catch unexpected exits so they're not silent; clean up temp clone dir if set -trap ' - echo -e "\n\033[0;31m✗ Installer exited unexpectedly at line $LINENO\033[0m" >&2 +# Persistent log file for debugging (survives script exit) +INSTALL_LOG="/tmp/loki-install.log" +: > "$INSTALL_LOG" + +show_debug_locations() { + echo -e "\033[1;33m Debug info:\033[0m" >&2 + if [[ -s "${INSTALL_LOG:-}" ]]; then + echo -e "\033[1;33m Installer log: ${INSTALL_LOG}\033[0m" >&2 + fi + if [[ -s "${_TF_LOG:-}" ]]; then + echo -e "\033[1;33m Terraform log: ${_TF_LOG}\033[0m" >&2 + fi if [[ -n "${CLONE_DIR:-}" && "${CLONE_DIR}" == /tmp/* && -d "$CLONE_DIR" ]]; then - echo -e "\033[1;33m⚠ Temp clone directory left at: ${CLONE_DIR}\033[0m" >&2 + echo -e "\033[1;33m Clone dir: ${CLONE_DIR}\033[0m" >&2 fi if [[ -n "${TF_WORKDIR:-}" && -d "$TF_WORKDIR" ]]; then - echo -e "\033[1;33m⚠ Temp Terraform workdir left at: ${TF_WORKDIR}\033[0m" >&2 + echo -e "\033[1;33m Terraform dir: ${TF_WORKDIR}\033[0m" >&2 + fi +} + +# Ctrl-C: kill background jobs and exit immediately +trap ' + echo -e "\n\033[0;31m✗ Interrupted\033[0m" >&2 + kill 0 2>/dev/null + exit 130 +' INT + +# Always show debug info on non-zero exit (EXIT trap is more reliable than ERR) +trap ' + exit_code=$? + if [[ $exit_code -ne 0 ]]; then + echo -e "\n\033[0;31m✗ Installer failed (exit code $exit_code)\033[0m" >&2 + show_debug_locations fi -' ERR +' EXIT REPO_URL="https://github.com/inceptionstack/loki-agent.git" DOCS_URL="https://github.com/inceptionstack/loki-agent/wiki" @@ -44,6 +73,7 @@ AUTO_YES=false PRESELECT_PACK="" PRESELECT_METHOD="" PRESELECT_PROFILE="" +DEBUG_IN_REPO=false while [[ $# -gt 0 ]]; do case "$1" in --non-interactive|--yes|-y) AUTO_YES=true; shift ;; @@ -65,17 +95,30 @@ while [[ $# -gt 0 ]]; do exit 1 fi PRESELECT_PROFILE="$2"; shift 2 ;; + --debug-in-repo) DEBUG_IN_REPO=true; shift ;; *) shift ;; esac done +# If --debug-in-repo, go back to the original directory (before cd $HOME) +if [[ "$DEBUG_IN_REPO" == "true" ]]; then + cd "$_ORIG_DIR" +fi +SCRIPT_DIR="$_ORIG_DIR" + +# Debug logging — writes to install log only, never to terminal +dbg() { + [[ "$DEBUG_IN_REPO" == "true" ]] && echo "[DBG] $*" >> "$INSTALL_LOG" + return 0 +} + # Deploy method constants DEPLOY_CFN_CONSOLE=1 DEPLOY_CFN_CLI=2 DEPLOY_TERRAFORM=3 # Stamped at release; fall back to git info at runtime -INSTALLER_COMMIT="${INSTALLER_COMMIT:-$(git -C "$(dirname "$0")" rev-parse --short HEAD 2>/dev/null || echo dev)}" -INSTALLER_DATE="${INSTALLER_DATE:-$(d=$(git -C "$(dirname "$0")" log -1 --format='%ci' 2>/dev/null | cut -d' ' -f1,2); echo "${d:-unknown}")}" +INSTALLER_COMMIT="${INSTALLER_COMMIT:-$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)}" +INSTALLER_DATE="${INSTALLER_DATE:-$(d=$(git -C "$SCRIPT_DIR" log -1 --format='%ci' 2>/dev/null | cut -d' ' -f1,2); echo "${d:-unknown}")}" # Detect AWS CloudShell (limited ~1GB home dir, use /tmp for large files) IS_CLOUDSHELL=false @@ -83,15 +126,106 @@ if [[ -n "${AWS_EXECUTION_ENV:-}" && "${AWS_EXECUTION_ENV}" == *"CloudShell"* ]] IS_CLOUDSHELL=true fi +# ============================================================================ +# gum — UI toolkit (installed to /tmp, no root required) +# ============================================================================ +GUM="" # set by install_gum — required, script fails without it +GUM_VERSION="0.14.5" # fallback version + +# ── Shared platform detection ──────────────────────────────────────────────── +# Sets DETECTED_OS and DETECTED_ARCH. Accepts optional arch style: +# "go" → amd64/arm64 (Terraform, Go binaries) +# default → x86_64/arm64 (gum, generic) +DETECTED_OS="" +DETECTED_ARCH="" + +# Get real hardware arch (uname -m and sysctl hw.machine lie under Rosetta) +hw_arch() { + if [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == "1" ]]; then + echo "arm64" + else + uname -m + fi +} + +detect_platform() { + local arch_style="${1:-default}" + case "$(uname -s)" in + Darwin) DETECTED_OS="Darwin" ;; + Linux) DETECTED_OS="Linux" ;; + *) DETECTED_OS=""; return 1 ;; + esac + case "$(hw_arch)" in + x86_64|amd64) + if [[ "$arch_style" == "go" ]]; then DETECTED_ARCH="amd64"; else DETECTED_ARCH="x86_64"; fi ;; + aarch64|arm64) DETECTED_ARCH="arm64" ;; + *) DETECTED_ARCH=""; return 1 ;; + esac +} + +install_gum() { + # Already installed? + if command -v gum &>/dev/null; then + GUM="gum"; return 0 + fi + local gum_bin="/tmp/gum-bin/gum" + if [[ -x "$gum_bin" ]]; then + GUM="$gum_bin"; return 0 + fi + + detect_platform || fail "Unsupported OS/architecture for gum: $(uname -s)/$(uname -m)" + local os="$DETECTED_OS" arch="$DETECTED_ARCH" + + # Try to get latest version from GitHub API, fall back to known good + local version + version=$(curl -sf https://api.github.com/repos/charmbracelet/gum/releases/latest 2>/dev/null \ + | grep '"tag_name"' | head -1 | sed 's/.*"v\([^"]*\)".*/\1/' || echo "") + [[ -z "$version" ]] && version="$GUM_VERSION" + + local url="https://github.com/charmbracelet/gum/releases/download/v${version}/gum_${version}_${os}_${arch}.tar.gz" + mkdir -p /tmp/gum-bin + if curl -sfL "$url" | tar xz --strip-components=1 -C /tmp/gum-bin 2>/dev/null; then + chmod +x "$gum_bin" + GUM="$gum_bin" + else + fail "Could not install gum. Check network connectivity and try again." + fi +} + # ============================================================================ # UI helpers # ============================================================================ -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' +MAGENTA='\033[0;35m' + +info() { echo -e " ${BLUE}▸${NC} $1"; } +ok() { echo -e " ${GREEN}✓${NC} $1"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1"; } +fail() { echo -e " ${RED}✗${NC} $1"; show_debug_locations; exit 1; } + +# ── Elapsed time formatting ────────────────────────────────────────────────── +elapsed_fmt() { + local secs=$1 + if [[ $secs -lt 60 ]]; then + printf '%ds' "$secs" + else + printf '%dm %ds' "$((secs / 60))" "$((secs % 60))" + fi +} + +# ── Step progress tracker ──────────────────────────────────────────────────── +STEP_NUM=0 +TOTAL_STEPS=7 +STEP_NAMES=() -info() { echo -e "${BLUE}▸${NC} $1"; } -ok() { echo -e "${GREEN}✓${NC} $1"; } -warn() { echo -e "${YELLOW}⚠${NC} $1"; } -fail() { echo -e "${RED}✗${NC} $1"; exit 1; } +step() { + STEP_NUM=$((STEP_NUM + 1)) + STEP_NAMES+=("$1") + echo "" + $GUM style --foreground 117 --bold --border double --border-foreground 240 \ + --padding "0 2" --margin "0 2" "[${STEP_NUM}/${TOTAL_STEPS}] $1" + echo "" +} prompt() { local text="$1" var="$2" default="${3:-}" @@ -99,20 +233,21 @@ prompt() { printf -v "$var" '%s' "$default" return fi - local display="$text"; [[ -n "$default" ]] && display="$text [$default]" - read -rp "$(echo -e "${BOLD}${display}:${NC} ")" value < /dev/tty + local value + value=$($GUM input --header "$text" --value "$default" --placeholder "$text" < /dev/tty) || value="$default" printf -v "$var" '%s' "${value:-$default}" } confirm() { local text="$1" default="${2:-default_no}" if [[ "$AUTO_YES" == true ]]; then return 0; fi - local hint="[y/N]"; [[ "$default" == "default_yes" ]] && hint="[Y/n]" - read -rp "$(echo -e "${BOLD}${text} ${hint}:${NC} ")" answer < /dev/tty - case "$default" in - default_yes) [[ ! "$answer" =~ ^[Nn]$ ]] ;; - *) [[ "$answer" =~ ^[Yy]$ ]] ;; - esac + local rc=0 + if [[ "$default" == "default_yes" ]]; then + $GUM confirm --default=yes "$text" < /dev/tty || rc=$? + else + $GUM confirm "$text" < /dev/tty || rc=$? + fi + return $rc } toggle() { @@ -121,12 +256,13 @@ toggle() { printf -v "$var" '%s' "$default" return fi - local hint="[Y/n]"; [[ "$default" == "false" ]] && hint="[y/N]" - read -rp "$(echo -e " ${text} ${hint}: ")" answer < /dev/tty - case "$default" in - true) [[ "$answer" =~ ^[Nn]$ ]] && printf -v "$var" '%s' "false" || printf -v "$var" '%s' "true" ;; - false) [[ "$answer" =~ ^[Yy]$ ]] && printf -v "$var" '%s' "true" || printf -v "$var" '%s' "false" ;; - esac + local rc=0 + if [[ "$default" == "true" ]]; then + $GUM confirm --default=yes " $text" < /dev/tty || rc=$? + else + $GUM confirm " $text" < /dev/tty || rc=$? + fi + [[ $rc -eq 0 ]] && printf -v "$var" '%s' "true" || printf -v "$var" '%s' "false" } require_cmd() { command -v "$1" &>/dev/null || fail "$2"; } @@ -140,6 +276,54 @@ json_field() { jq -r ".$1" 2>/dev/null; } # URL-encode a string url_encode() { jq -rn --arg s "$1" '$s | @uri'; } +# ── Reusable helpers (DRY) ────────────────────────────────────────────────── + +# Animate a gum spinner with label for N seconds. +# Usage: animate_spinner