Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
193 changes: 193 additions & 0 deletions overlay/usr/local/bin/turnkey-oc
Original file line number Diff line number Diff line change
@@ -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 <<EOF
$(basename "$0") [-h|--help] COMMAND [ARGS]

TurnKey helper for the OpenCloud appliance.

Appliance commands:

update [version [X.Y.Z]] update the opencloud binary to X.Y.Z (or the
latest upstream release), verify sha256,
restart the service; rolls back on failure
status installed vs latest version, service state
logs [ARGS] service journal (journalctl args accepted)

Any other command is passed to the opencloud binary as the '$OC_USER'
user, e.g.:

$(basename "$0") version
$(basename "$0") idm resetpassword
$(basename "$0") backup consistency -p /var/lib/opencloud/storage/users
$(basename "$0") trash purge-empty-dirs -p ...

Env vars:

DEBUG verbose output for debugging
OC_USER user to run the opencloud binary as (default: opencloud)
EOF
}

installed_version() {
local target
target=$(readlink -f "$OC_BIN") \
|| fatal "cannot resolve $OC_BIN symlink"
basename "$target" | sed 's/^opencloud-//'
}

latest_version() {
curl -sfL "https://api.github.com/repos/${OPENCLOUD_REPO}/releases/latest" \
| sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' \
| head -1
}

service_health_ok() {
local waited=0
while (( waited < HEALTH_TIMEOUT )); do
if systemctl is-active --quiet opencloud \
&& curl -sf -o /dev/null "$HEALTH_URL"; then
return 0
fi
sleep 3
(( waited += 3 ))
done
return 1
}

# '|| true' so a systemctl hiccup under 'set -e' cannot abort the script
# before the health check + rollback logic runs
switch_binary() {
systemctl stop opencloud || true
ln -sfn "$1" "$OC_BIN"
systemctl start opencloud || true
}

do_update() {
local version=$1
[[ $EUID -eq 0 ]] || fatal "'update' must be run as root"

if [[ -z "$version" ]]; then
version=$(latest_version) \
|| fatal "could not query GitHub for the latest release"
[[ -n "$version" ]] \
|| fatal "could not parse the latest release version"
info "latest upstream release: $version"
fi

# version becomes part of URLs and root-owned install paths
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][A-Za-z0-9.]+)?$ ]] \
|| fatal "invalid/unexpected version string: $version"

local current
current=$(installed_version)
if [[ "$version" == "$current" ]]; then
info "already running OpenCloud $current - nothing to do"
return 0
fi

local filename="opencloud-${version}-linux-amd64"
local url="https://github.com/${OPENCLOUD_REPO}/releases/download/v${version}/${filename}"
local tmpdir
tmpdir=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf '$tmpdir'" EXIT

info "downloading OpenCloud $version"
curl -Lf -o "$tmpdir/$filename" "$url" \
|| fatal "download failed: $url"
curl -Lf -o "$tmpdir/$filename.sha256" "$url.sha256" \
|| fatal "checksum download failed: $url.sha256"
(cd "$tmpdir" && sha256sum --check --quiet "$filename.sha256") \
|| fatal "sha256 verification failed - keeping OpenCloud $current"

install -o root -g root -m 0755 "$tmpdir/$filename" \
"$OPENCLOUD_LIB_DIR/opencloud-$version"

local previous
previous=$(readlink -f "$OC_BIN")

info "switching to OpenCloud $version and restarting service"
switch_binary "$OPENCLOUD_LIB_DIR/opencloud-$version"

if service_health_ok; then
info "OpenCloud updated: $current -> $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
152 changes: 152 additions & 0 deletions tests/turnkey-oc.bats
Original file line number Diff line number Diff line change
@@ -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?)"* ]]
}
Loading