diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..03ec5c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install shellcheck + run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + - name: shellcheck + run: | + shellcheck \ + conf.d/downloads \ + conf.d/main \ + overlay/usr/local/bin/turnkey-oc \ + overlay/usr/lib/inithooks/firstboot.d/20regen-opencloud-secrets \ + overlay/usr/lib/inithooks/firstboot.d/40opencloud + - name: python syntax + run: python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" \ + overlay/usr/lib/inithooks/bin/opencloud.py + - name: no compiled artifacts in overlay + run: | + if find overlay \( -name '__pycache__' -o -name '*.pyc' \) | grep -q .; then + echo "compiled python artifacts found in overlay/" >&2 + exit 1 + fi + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install bats + run: sudo apt-get update -qq && sudo apt-get install -y -qq bats + - name: bats + run: sudo bats tests # root needed for the update-path tests diff --git a/overlay/usr/local/bin/turnkey-oc b/overlay/usr/local/bin/turnkey-oc new file mode 100755 index 0000000..5aa2776 --- /dev/null +++ b/overlay/usr/local/bin/turnkey-oc @@ -0,0 +1,193 @@ +#!/bin/bash -e +# turnkey-oc - TurnKey helper CLI for the OpenCloud appliance. +# +# Unknown subcommands are delegated to the opencloud binary, run as the +# service user with the appliance environment loaded. Appliance-level +# subcommands (update/status/logs) are handled here. + +[[ -z "$DEBUG" ]] || set -x + +OC_USER=${OC_USER:-opencloud} +OC_BIN=${OC_BIN:-/usr/local/bin/opencloud} +OC_ENV_FILE=${OC_ENV_FILE:-/etc/opencloud/opencloud.env} +TKL_OC_CONF=${TKL_OC_CONF:-/etc/opencloud/turnkey-oc.conf} +HEALTH_URL=${HEALTH_URL:-http://127.0.0.1:9200} +HEALTH_TIMEOUT=${HEALTH_TIMEOUT:-60} + +fatal() { echo "FATAL: $*" >&2; exit 1; } +info() { echo "INFO: $*"; } + +# shellcheck disable=SC1090 +[[ ! -r "$TKL_OC_CONF" ]] || . "$TKL_OC_CONF" +OPENCLOUD_REPO=${OPENCLOUD_REPO:-opencloud-eu/opencloud} +OPENCLOUD_LIB_DIR=${OPENCLOUD_LIB_DIR:-/usr/local/lib/opencloud} + +usage() { + cat < $version" + return 0 + fi + + info "health check failed - rolling back to $current" + switch_binary "$previous" + if service_health_ok; then + fatal "update to $version failed; rolled back to $current (service healthy)" + else + fatal "update to $version failed AND rollback is unhealthy - check 'turnkey-oc logs'" + fi +} + +do_status() { + local current latest service + current=$(installed_version) + latest=$(latest_version) || latest="" + service=$(systemctl is-active opencloud 2>/dev/null) || true + echo "Installed version: $current" + echo "Latest upstream: ${latest:-unknown (offline?)}" + echo "Service: ${service:-unknown}" + grep '^OC_URL=' "$OC_ENV_FILE" 2>/dev/null \ + || echo "OC_URL: not configured" +} + +run_delegate() { + [[ -x "$OC_BIN" ]] || fatal "binary missing or not executable: $OC_BIN" + local com + com="set -a; . '$OC_ENV_FILE'; set +a; exec '$OC_BIN' $(printf '%q ' "$@")" + runuser "$OC_USER" -s /bin/bash -c "$com" +} + +main() { + case "${1:-}" in + ""|-h|--help|help) + usage + ;; + update) + shift + [[ "${1:-}" != "version" ]] || shift + do_update "${1:-}" + ;; + status) + do_status + ;; + logs) + shift + journalctl -u opencloud "$@" + ;; + *) + run_delegate "$@" + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tests/turnkey-oc.bats b/tests/turnkey-oc.bats new file mode 100644 index 0000000..f172731 --- /dev/null +++ b/tests/turnkey-oc.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# Unit tests for the turnkey-oc CLI wrapper. The script guards main() with +# a BASH_SOURCE check, so it can be sourced with env overrides. + +setup() { + REPO_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + CLI="$REPO_DIR/overlay/usr/local/bin/turnkey-oc" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +@test "help exits 0 and documents update/status/logs" { + run bash "$CLI" --help + [ "$status" -eq 0 ] + [[ "$output" == *"update [version [X.Y.Z]]"* ]] + [[ "$output" == *"status"* ]] + [[ "$output" == *"logs"* ]] +} + +@test "no arguments prints usage" { + run bash "$CLI" + [ "$status" -eq 0 ] + [[ "$output" == *"TurnKey helper for the OpenCloud appliance"* ]] +} + +@test "installed_version parses the versioned symlink target" { + mkdir -p "$TMP/lib" + touch "$TMP/lib/opencloud-7.3.0" + ln -s "$TMP/lib/opencloud-7.3.0" "$TMP/opencloud" + run bash -c "export OC_BIN='$TMP/opencloud'; source '$CLI'; installed_version" + [ "$status" -eq 0 ] + [ "$output" = "7.3.0" ] +} + +@test "latest_version parses tag_name from the GitHub API response" { + mkdir -p "$TMP/bin" + cat > "$TMP/bin/curl" <<'EOF' +#!/bin/bash +echo '{"tag_name": "v7.4.1", "name": "OpenCloud 7.4.1"}' +EOF + chmod +x "$TMP/bin/curl" + run bash -c "export PATH='$TMP/bin':\$PATH; source '$CLI'; latest_version" + [ "$status" -eq 0 ] + [ "$output" = "7.4.1" ] +} + +@test "update refuses to run as non-root" { + if [ "$(id -u)" -eq 0 ]; then + skip "running as root" + fi + run bash "$CLI" update version 1.2.3 + [ "$status" -ne 0 ] + [[ "$output" == *"must be run as root"* ]] +} + +# stubs for the update path: fake current install, curl (download ok, +# health check always failing), systemctl (is-active fails), install (cp) +make_update_stubs() { + mkdir -p "$TMP/bin" "$TMP/lib" + touch "$TMP/lib/opencloud-1.0.0" + ln -s "$TMP/lib/opencloud-1.0.0" "$TMP/opencloud" + + cat > "$TMP/bin/curl" <<'EOF' +#!/bin/bash +out=""; url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) out=$2; shift 2 ;; + -*) shift ;; + *) url=$1; shift ;; + esac +done +if [[ -z "$out" ]]; then + exit 1 # health check URL: always unhealthy +fi +if [[ "$url" == *.sha256 ]]; then + if [[ -n "$BAD_SHA" ]]; then + printf '%064d %s\n' 0 "$(basename "${url%.sha256}")" > "$out" + else + (cd "$(dirname "$out")" && sha256sum "$(basename "${out%.sha256}")" > "$out") + fi +else + echo "fake-binary" > "$out" +fi +EOF + + cat > "$TMP/bin/systemctl" <<'EOF' +#!/bin/bash +[[ "$1" != "is-active" ]] || exit 1 +exit 0 +EOF + + cat > "$TMP/bin/install" <<'EOF' +#!/bin/bash +args=() +while [[ $# -gt 0 ]]; do + case "$1" in + -o|-g|-m) shift 2 ;; + -*) shift ;; + *) args+=("$1"); shift ;; + esac +done +cp "${args[0]}" "${args[1]}" +EOF + chmod +x "$TMP/bin/curl" "$TMP/bin/systemctl" "$TMP/bin/install" +} + +@test "update rejects malformed version strings" { + [ "$(id -u)" -eq 0 ] || skip "requires root (CI runs 'sudo bats tests')" + run bash -c "set -e; source '$CLI'; do_update '../../etc/cron.d/evil'" + [ "$status" -ne 0 ] + [[ "$output" == *"invalid/unexpected version string"* ]] +} + +@test "update aborts on sha256 mismatch and installs nothing" { + [ "$(id -u)" -eq 0 ] || skip "requires root (CI runs 'sudo bats tests')" + make_update_stubs + run bash -c "export PATH='$TMP/bin':\$PATH BAD_SHA=1 \ + OC_BIN='$TMP/opencloud' OPENCLOUD_LIB_DIR='$TMP/lib' HEALTH_TIMEOUT=1; \ + set -e; source '$CLI'; do_update 9.9.9" + [ "$status" -ne 0 ] + [[ "$output" == *"sha256 verification failed"* ]] + [ ! -e "$TMP/lib/opencloud-9.9.9" ] + [ "$(readlink "$TMP/opencloud")" = "$TMP/lib/opencloud-1.0.0" ] +} + +@test "update rolls back the symlink when the health check fails" { + [ "$(id -u)" -eq 0 ] || skip "requires root (CI runs 'sudo bats tests')" + make_update_stubs + run bash -c "export PATH='$TMP/bin':\$PATH \ + OC_BIN='$TMP/opencloud' OPENCLOUD_LIB_DIR='$TMP/lib' HEALTH_TIMEOUT=1; \ + set -e; source '$CLI'; do_update 9.9.9" + [ "$status" -ne 0 ] + [[ "$output" == *"rolling back"* ]] + [ "$(readlink "$TMP/opencloud")" = "$TMP/lib/opencloud-1.0.0" ] +} + +@test "status reports installed version without network" { + mkdir -p "$TMP/lib" "$TMP/bin" + touch "$TMP/lib/opencloud-7.3.0" + ln -s "$TMP/lib/opencloud-7.3.0" "$TMP/opencloud" + printf '#!/bin/bash\nexit 22\n' > "$TMP/bin/curl" + chmod +x "$TMP/bin/curl" + run bash -c "export PATH='$TMP/bin':\$PATH OC_BIN='$TMP/opencloud' \ + OC_ENV_FILE=/nonexistent; source '$CLI'; do_status" + [ "$status" -eq 0 ] + [[ "$output" == *"Installed version: 7.3.0"* ]] + [[ "$output" == *"unknown (offline?)"* ]] +}